diff --git a/apps/www/src/content/docs/ai-elements/chat-panel/demo.ts b/apps/www/src/content/docs/ai-elements/chat-panel/demo.ts
new file mode 100644
index 000000000..3e34072f0
--- /dev/null
+++ b/apps/www/src/content/docs/ai-elements/chat-panel/demo.ts
@@ -0,0 +1,351 @@
+'use client';
+
+export const preview = {
+ type: 'code',
+ code: `function ChatPanelPreview() {
+ const RESPONSES = [
+ 'Done — I created the task and assigned it to you.',
+ 'On it. I\\'ll ping you when the draft is ready to review.',
+ 'Good question — the design tokens live in packages/raystack/styles.',
+ 'That shipped in the last release; update and it should just work.',
+ 'I\\'ve noted it down. Anything else you\\'d like me to pick up?'
+ ];
+
+ const [mode, setMode] = React.useState('docked');
+ const [status, setStatus] = React.useState('idle');
+ const [messages, setMessages] = React.useState([
+ { id: 1, role: 'user', text: 'create a task in design system 2' },
+ { id: 2, role: 'assistant', text: RESPONSES[0] }
+ ]);
+ const nextIdRef = React.useRef(3);
+ const timerRef = React.useRef(null);
+
+ React.useEffect(() => () => clearTimeout(timerRef.current), []);
+
+ const handleSubmit = (value, event) => {
+ event.currentTarget.reset();
+ setMessages(prev => [
+ ...prev,
+ { id: nextIdRef.current++, role: 'user', text: value }
+ ]);
+ setStatus('submitted');
+ timerRef.current = setTimeout(() => {
+ const reply = RESPONSES[Math.floor(Math.random() * RESPONSES.length)];
+ setMessages(prev => [
+ ...prev,
+ { id: nextIdRef.current++, role: 'assistant', text: reply }
+ ]);
+ setStatus('idle');
+ }, 900);
+ };
+
+ const handleStop = () => {
+ clearTimeout(timerRef.current);
+ setStatus('idle');
+ };
+
+ return (
+
+
+
+ Create task in design system 2
+
+
+
+
+
+
+
+
+ {messages.map(message => (
+
+
+
+ {message.role === 'user' ? (
+ {message.text}
+ ) : (
+ {message.text}
+ )}
+
+
+
+ ))}
+ {status === 'submitted' && (
+
+
+
+ Thinking…
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Main content
+
+ The docked panel is a real flex sibling — it squeezes this content
+ instead of covering it. Pop it out or minimize it from the header;
+ floating and minimized modes are fixed to the browser viewport.
+ Send a message to get a (canned) reply.
+
+
+
+ );
+}`
+};
+
+export const controlledDemo = {
+ type: 'code',
+ code: `function ControlledChatPanel() {
+ const [mode, setMode] = React.useState('docked');
+
+ return (
+
+
+ setMode('docked')}>Dock
+ setMode('floating')}>Float
+ setMode('minimized')}>Minimize
+
+ mode: {mode}
+
+
+
+ Floating and minimized modes leave this frame and pin to the
+ browser viewport.
+
+
+
+
+ Assistant
+
+
+
+
+
+
+
+
+ Drag the header to move the floating window; resize from any
+ edge or corner.
+
+
+
+
+
+
+
+ );
+}`
+};
+
+export const boundedDragDemo = {
+ type: 'code',
+ code: `function BoundedDragChatPanel() {
+ const boundaryRef = React.useRef(null);
+ const [position, setPosition] = React.useState(null);
+ const [open, setOpen] = React.useState(false);
+
+ const handleToggle = () => {
+ if (!open) {
+ // Start the floating window inside the boundary frame.
+ const rect = boundaryRef.current?.getBoundingClientRect();
+ if (rect) setPosition({ x: rect.left + 24, y: rect.top + 24 });
+ }
+ setOpen(!open);
+ };
+
+ return (
+
+
+
+ {open ? 'Close the floating window' : 'Open the floating window'}
+
+
+
+ {open && (
+
+
+ Assistant
+
+
+
+
+ Drag the header — the window can't leave the dashed frame.
+
+
+
+
+ )}
+
+
+ );
+}`
+};
+
+export const resizeDemo = {
+ type: 'code',
+ code: `function ResizeAxesChatPanel() {
+ const [mode, setMode] = React.useState('docked');
+ const [resize, setResize] = React.useState('both');
+
+ return (
+
+
+ resize:
+ {['both', 'horizontal', 'vertical', 'none'].map(value => (
+ setResize(value)}
+ >
+ {value}
+
+ ))}
+
+
+
+
+ Pop the panel out with the header button — the floating window
+ pins to the browser viewport and only renders handles for the
+ allowed axes. By default it can only shrink below its initial
+ size; this demo passes a larger maxSize so it can grow.
+
+
+
+
+ Assistant
+
+
+
+
+
+
+
+ {mode === 'floating'
+ ? resize === 'none'
+ ? 'Resizing is disabled.'
+ : 'Grab an edge or corner to resize me.'
+ : 'Pop me out to resize me.'}
+
+
+
+
+
+
+ );
+}`
+};
+
+export const draggableBubbleDemo = {
+ type: 'code',
+ code: `function DraggableBubbleChatPanel() {
+ const [mode, setMode] = React.useState('docked');
+
+ return (
+
+
+ The minimized bubble accepts draggable — a short click still
+ restores the panel, and the dropped spot is kept the next time you
+ minimize.
+
+
+
+
+ Minimize the panel from its header, then drag the bubble that
+ appears at the bottom-right of the browser window. Click it to
+ restore the panel here.
+
+
+
+
+ Assistant
+
+
+
+
+
+
+ Minimize me again from the header.
+
+
+
+
+
+
+ );
+}`
+};
+
+export const unreadBadgeDemo = {
+ type: 'code',
+ code: `function MinimizedWithBadge() {
+ const [mode, setMode] = React.useState('minimized');
+
+ // The transform makes the frame the containing block for the panel's
+ // fixed positioning, so this demo's trigger pins to the frame corner
+ // instead of stacking on the page's other panels.
+ return (
+
+
+ The minimized trigger is a slot — compose Indicator or Badge for
+ unread counts. Look at the bottom-right of this frame.
+
+
+
+
+ Click the bubble to restore the panel; minimize it again from the
+ header.
+
+
+
+
+ Assistant
+
+
+
+
+
+
+ Minimize me again from the header.
+
+
+
+ 💬
+
+
+
+
+ );
+}`
+};
diff --git a/apps/www/src/content/docs/ai-elements/chat-panel/index.mdx b/apps/www/src/content/docs/ai-elements/chat-panel/index.mdx
new file mode 100644
index 000000000..cfdfe71df
--- /dev/null
+++ b/apps/www/src/content/docs/ai-elements/chat-panel/index.mdx
@@ -0,0 +1,173 @@
+---
+title: ChatPanel
+description: A dockable, floating, minimizable window frame for chat — an inline sidebar that pops out to a draggable, resizable window or collapses to a corner bubble.
+source: packages/raystack/components/chat-panel
+tag: new
+---
+
+import { preview, controlledDemo, boundedDragDemo, resizeDemo, draggableBubbleDemo, unreadBadgeDemo } from "./demo.ts";
+
+
+
+## Anatomy
+
+```tsx
+import { ChatPanel } from '@raystack/apsara'
+
+
+
+
+
+ Create task in design system 2
+
+ {/* consumer extras, e.g. ⋯ menu */}
+
+
+
+
+ {/* Chat.Messages + PromptInput */}
+ {/* minimized bubble */}
+
+
+```
+
+The panel mounts **once** in your layout and morphs between modes with pure
+CSS — no portal, no remount, so chat state (scroll position, focus, streams)
+survives every transition:
+
+- **`docked`** — an in-flow sidebar (a flex sibling, like `SidePanel`) that
+ squeezes the main content rather than covering it.
+- **`floating`** — the same element switched to `position: fixed`: drag it by
+ the header (clamped to the viewport, or to a container via `dragBoundary`;
+ disable with `draggable={false}`) and resize it from any edge or corner
+ (constrain the axes with `resize`). Out of the box resizing can only
+ shrink the window below its initial size — pass a larger `maxSize` to let
+ it grow.
+- **`minimized`** — the frame collapses to `ChatPanel.Trigger`, a fixed
+ corner bubble; clicking it restores the previous mode. If you don't render
+ a trigger, minimized shows nothing and your app supplies its own affordance.
+
+There is deliberately no close (×) — header actions are slottable, so apps
+add their own controls next to the ready-made mode triggers.
+
+Because floating and minimized rely on `position: fixed`, mount the panel
+near the layout root: an ancestor with `transform`, `filter` or
+`backdrop-filter` would break fixed positioning.
+
+## API Reference
+
+### Root
+
+
+
+### Header
+
+The title bar. In floating mode it is the drag handle — pointer drags on it
+move the window (interactive children like buttons are excluded, as is
+anything marked `data-chat-panel-no-drag`).
+
+### Title
+
+The heading (`
`), truncated with an ellipsis.
+
+### Actions
+
+A slot row at the end of the header for controls.
+
+### MinimizeTrigger / ExpandTrigger
+
+Ready-made mode buttons for `ChatPanel.Actions`, built on `IconButton`.
+MinimizeTrigger switches to `minimized`; ExpandTrigger toggles between
+`docked` and `floating` with a state-aware accessible name. Both render a
+default icon (minus / size) that children override; ExpandTrigger children
+also accept a render function receiving `{ floating }` for per-state icons:
+
+```tsx
+
+ {({ floating }) => (floating ? : )}
+
+```
+
+### Content
+
+The body wrapper — a flex column that gives `Chat.Messages` its bounded
+height.
+
+### Trigger
+
+The minimized-state corner bubble. Children replace the default chat icon,
+and `draggable` lets the bubble be dragged around the viewport — a completed
+drag never triggers the restore click.
+
+
+
+## Examples
+
+### Controlled mode
+
+Control `mode` to persist it, or to open the panel from your own UI. The
+floating window here is fixed to the browser viewport — pop it out and drag
+its header.
+
+
+
+### Constraining the drag to a container
+
+Dragging is built on [dnd-kit](https://dndkit.com). By default the floating
+window is clamped to the viewport; pass an element (or a ref to one) as
+`dragBoundary` to confine dragging to it instead. The panel keeps
+`position: fixed`, so the boundary only limits where a drag can put it.
+
+
+
+### Constraining the resize axes
+
+`resize` mirrors the CSS `resize` property: `both` (default), `horizontal`,
+`vertical` or `none`. Only the handles for the allowed axes render. The
+default `maxSize` is the initial floating size, so this demo passes a larger
+one to allow growing.
+
+
+
+### Draggable minimized bubble
+
+
+
+### Unread badge on the bubble
+
+
+
+### Persisting placement
+
+Position and size are controllable for apps that restore the floating window
+across sessions:
+
+```tsx
+
+ …
+
+```
+
+## Accessibility
+
+- The root renders an `` (`complementary` landmark); give it an
+ `aria-label` when your page has more than one.
+- `ChatPanel.Title` is a real `` heading.
+- All mode triggers are `IconButton`s with descriptive, state-aware
+ accessible names ("Minimize chat panel", "Pop out chat panel", "Dock chat
+ panel", "Open chat").
+- Mode transitions never remount the content, so keyboard focus inside the
+ thread or composer is preserved when docking or popping out.
+- Dragging clamps so the header can never leave the viewport (or the
+ `dragBoundary` container), and the window and a dropped bubble both
+ re-clamp when the browser is resized.
+- Drag and resize are pointer gestures; keep docked mode reachable (the
+ ExpandTrigger toggle) so keyboard users can always use the inline layout.
diff --git a/apps/www/src/content/docs/ai-elements/chat-panel/props.ts b/apps/www/src/content/docs/ai-elements/chat-panel/props.ts
new file mode 100644
index 000000000..30f16b75a
--- /dev/null
+++ b/apps/www/src/content/docs/ai-elements/chat-panel/props.ts
@@ -0,0 +1,116 @@
+import type React from 'react';
+
+export interface ChatPanelPosition {
+ x: number;
+ y: number;
+}
+
+export interface ChatPanelSize {
+ width: number;
+ height: number;
+}
+
+export interface ChatPanelProps {
+ /** Presentation mode of the panel (controlled). */
+ mode?: 'docked' | 'floating' | 'minimized';
+
+ /**
+ * Initial mode when uncontrolled.
+ * @defaultValue "docked"
+ */
+ defaultMode?: 'docked' | 'floating' | 'minimized';
+
+ /** Called when the mode changes. */
+ onModeChange?: (mode: 'docked' | 'floating' | 'minimized') => void;
+
+ /**
+ * Which edge the panel docks to; also picks the corner used by the
+ * floating default position and the minimized trigger.
+ * @defaultValue "right"
+ */
+ side?: 'left' | 'right';
+
+ /** Floating window position in viewport pixels (controlled). */
+ position?: ChatPanelPosition | null;
+
+ /**
+ * Initial floating position when uncontrolled. When omitted the window
+ * starts at the bottom corner on the docked `side`.
+ */
+ defaultPosition?: ChatPanelPosition;
+
+ /** Called when a drag ends or resizing moves the floating window. */
+ onPositionChange?: (position: ChatPanelPosition) => void;
+
+ /** Floating window size in pixels (controlled). */
+ size?: ChatPanelSize;
+
+ /**
+ * Initial floating size when uncontrolled.
+ * @defaultValue { width: 400, height: 560 }
+ */
+ defaultSize?: ChatPanelSize;
+
+ /** Called when resizing changes the floating window size. */
+ onSizeChange?: (size: ChatPanelSize) => void;
+
+ /**
+ * Smallest allowed floating size.
+ * @defaultValue { width: 280, height: 320 }
+ */
+ minSize?: ChatPanelSize;
+
+ /**
+ * Largest allowed floating size, always additionally clamped by the
+ * viewport.
+ * @defaultValue the initial floating size — out of the box the window can
+ * only shrink; pass a larger `maxSize` to let it grow.
+ */
+ maxSize?: ChatPanelSize;
+
+ /**
+ * Which axes the floating window can be resized on; mirrors the CSS
+ * `resize` property vocabulary.
+ * @defaultValue "both"
+ */
+ resize?: 'both' | 'horizontal' | 'vertical' | 'none';
+
+ /**
+ * Whether the floating window can be dragged by its header.
+ * @defaultValue true
+ */
+ draggable?: boolean;
+
+ /**
+ * Confines floating-window dragging to an element instead of the
+ * viewport. Accepts the element or a ref to it.
+ */
+ dragBoundary?: HTMLElement | React.RefObject;
+
+ /** Custom CSS class names. */
+ className?: string;
+}
+
+export interface ChatPanelTriggerProps {
+ /**
+ * Bubble content. Defaults to a chat icon; compose `Indicator` or `Badge`
+ * for unread counts.
+ */
+ children?: React.ReactNode;
+
+ /**
+ * Whether the minimized bubble can be dragged around the viewport. The
+ * dropped position is kept across minimize/restore cycles.
+ * @defaultValue false
+ */
+ draggable?: boolean;
+
+ /**
+ * Accessible name of the bubble.
+ * @defaultValue "Open chat"
+ */
+ 'aria-label'?: string;
+
+ /** Custom CSS class names. */
+ className?: string;
+}
diff --git a/apps/www/src/content/docs/ai-elements/chat/demo.ts b/apps/www/src/content/docs/ai-elements/chat/demo.ts
index df5092aa2..c0783ab4c 100644
--- a/apps/www/src/content/docs/ai-elements/chat/demo.ts
+++ b/apps/www/src/content/docs/ai-elements/chat/demo.ts
@@ -31,14 +31,14 @@ export const preview = {
-
+
`
};
@@ -110,7 +110,7 @@ export const streamingDemo = {
))}
-
+
{
event.currentTarget.reset();
@@ -122,7 +122,7 @@ export const streamingDemo = {
-
+
);
diff --git a/apps/www/src/content/docs/ai-elements/chat/index.mdx b/apps/www/src/content/docs/ai-elements/chat/index.mdx
index a7c22455d..f8abce3d4 100644
--- a/apps/www/src/content/docs/ai-elements/chat/index.mdx
+++ b/apps/www/src/content/docs/ai-elements/chat/index.mdx
@@ -12,7 +12,7 @@ import { preview, attachmentDemo, streamingDemo } from "./demo.ts";
## Anatomy
```tsx
-import { Chat, Message, Reasoning } from '@raystack/apsara'
+import { Chat, Message, PromptInput, Reasoning } from '@raystack/apsara'
@@ -39,6 +39,9 @@ import { Chat, Message, Reasoning } from '@raystack/apsara'
+
+ {/* … */}
+
```
@@ -84,8 +87,17 @@ message looks like.
### Chat.JumpButton
The floating "↓ Latest" pill. It appears once the reader leaves the live edge
-and scrolls back down on click. Render it as a child of `Chat.Messages`;
-children replace the default arrow-and-label content.
+and scrolls back down on click. Render it as a child of `Chat.Messages`.
+Children are the text slot ("Latest" by default); the leading icon renders
+regardless, swap it with `leadingIcon` or remove it with `leadingIcon={null}`.
+
+
+
+### Chat.Composer
+
+The container under the messages, usually holding a
+[PromptInput](/docs/ai-elements/prompt-input). It sits flush against the
+messages viewport and pads the sides and bottom.
### useChatMessages
diff --git a/apps/www/src/content/docs/ai-elements/chat/props.ts b/apps/www/src/content/docs/ai-elements/chat/props.ts
index ff96e5a18..5bd8c3428 100644
--- a/apps/www/src/content/docs/ai-elements/chat/props.ts
+++ b/apps/www/src/content/docs/ai-elements/chat/props.ts
@@ -84,6 +84,23 @@ export interface ChatItemProps {
className?: string;
}
+export interface ChatJumpButtonProps {
+ /**
+ * Icon rendered before the label. Pass `null` to remove it.
+ * @defaultValue an arrow-down icon
+ */
+ leadingIcon?: React.ReactNode;
+
+ /**
+ * The text label.
+ * @defaultValue "Latest"
+ */
+ children?: React.ReactNode;
+
+ /** Custom CSS class names. */
+ className?: string;
+}
+
export interface ChatAttachmentProps {
/** File name or main label. */
title?: React.ReactNode;
diff --git a/apps/www/src/content/docs/ai-elements/meta.json b/apps/www/src/content/docs/ai-elements/meta.json
index ffc5c6461..e6538befc 100644
--- a/apps/www/src/content/docs/ai-elements/meta.json
+++ b/apps/www/src/content/docs/ai-elements/meta.json
@@ -1,4 +1,4 @@
{
"title": "AI Elements",
- "pages": ["chat", "message", "prompt-input", "reasoning"]
+ "pages": ["chat", "chat-panel", "message", "prompt-input", "reasoning"]
}
diff --git a/apps/www/src/content/docs/ai-elements/prompt-input/demo.ts b/apps/www/src/content/docs/ai-elements/prompt-input/demo.ts
index eb9e82cbb..032646235 100644
--- a/apps/www/src/content/docs/ai-elements/prompt-input/demo.ts
+++ b/apps/www/src/content/docs/ai-elements/prompt-input/demo.ts
@@ -24,8 +24,18 @@ export const preview = {
>
- Skills
- 📎
+
+ Skills
+
+
+ 📎
+
@@ -50,6 +60,34 @@ export const attachmentsDemo = {
`
};
+export const statusDemo = {
+ type: 'code',
+ code: `function PromptInputStatuses() {
+ const STATUSES = ['idle', 'submitted', 'streaming', 'error'];
+
+ return (
+
+ {STATUSES.map(status => (
+
+ status="{status}"
+ {}}
+ onStop={() => {}}
+ >
+
+
+
+
+
+
+ ))}
+
+ );
+}`
+};
+
export const controlledDemo = {
type: 'code',
code: `function ControlledPromptInput() {
@@ -79,7 +117,9 @@ export const disabledDemo = {
- Skills
+
+ Skills
+
diff --git a/apps/www/src/content/docs/ai-elements/prompt-input/index.mdx b/apps/www/src/content/docs/ai-elements/prompt-input/index.mdx
index 34dcd6eb3..432f924aa 100644
--- a/apps/www/src/content/docs/ai-elements/prompt-input/index.mdx
+++ b/apps/www/src/content/docs/ai-elements/prompt-input/index.mdx
@@ -5,7 +5,7 @@ source: packages/raystack/components/prompt-input
tag: new
---
-import { preview, attachmentsDemo, controlledDemo, disabledDemo } from "./demo.ts";
+import { preview, attachmentsDemo, statusDemo, controlledDemo, disabledDemo } from "./demo.ts";
@@ -22,7 +22,9 @@ import { PromptInput } from '@raystack/apsara'
{/* attachment previews */}
- Skills
+
+ Skills
+
@@ -56,14 +58,10 @@ while empty.
### Footer
-The bottom toolbar row. Hidden while empty.
-
-### Button
-
-A small neutral-ghost `Button` for the toolbar — attach, skills, model pickers
-and similar consumer-owned controls.
-
-
+The bottom toolbar row. Hidden while empty. Toolbar controls — attach, skills,
+model pickers and similar — are composed from regular Apsara components
+(`Button`, `IconButton`, `Select`, …); give them `type="button"` so they don't
+submit the form.
### Submit
@@ -82,6 +80,23 @@ drag-drop and uploads belong to your app.
+### Status
+
+`status` reflects the request lifecycle your app manages:
+
+- **`idle`** — the resting state; the submit button shows the send arrow and
+ disables itself while the input is empty.
+- **`submitted`** — the request is sent but nothing has streamed back yet;
+ the button shows a spinner, Enter/submit is blocked, and clicking calls
+ `onStop`.
+- **`streaming`** — tokens are arriving; the button flips to a stop square
+ that calls `onStop`, and submit stays blocked.
+- **`error`** — the request failed; the composer returns to a submittable
+ state (send arrow) so the user can retry. Render your error message
+ outside the composer.
+
+
+
### Controlled value
Control `value` to sync the draft elsewhere — persistence, slash-command
@@ -102,5 +117,6 @@ menus, mention pickers.
- While a response is in flight the submit button becomes `type="button"` with
an updated accessible name ("Stop response") so it can never resubmit the
form.
-- The composer frame carries the focus treatment (`:focus-within`), while the
- borderless textarea suppresses its own duplicate focus ring.
+- The composer frame carries the focus treatment — the border tints when a
+ child has visible focus (`:has(:focus-visible)`) — while the borderless
+ textarea suppresses its own duplicate focus ring.
diff --git a/apps/www/src/content/docs/ai-elements/prompt-input/props.ts b/apps/www/src/content/docs/ai-elements/prompt-input/props.ts
index e247b5f57..3a59a9154 100644
--- a/apps/www/src/content/docs/ai-elements/prompt-input/props.ts
+++ b/apps/www/src/content/docs/ai-elements/prompt-input/props.ts
@@ -70,26 +70,3 @@ export interface PromptInputSubmitProps {
/** Custom CSS class names. */
className?: string;
}
-
-export interface PromptInputButtonProps {
- /**
- * Visual style, forwarded to `Button`.
- * @defaultValue "ghost"
- */
- variant?: 'solid' | 'outline' | 'ghost' | 'text';
-
- /**
- * Color, forwarded to `Button`.
- * @defaultValue "neutral"
- */
- color?: 'accent' | 'danger' | 'neutral' | 'success';
-
- /**
- * Size, forwarded to `Button`.
- * @defaultValue "small"
- */
- size?: 'small' | 'normal';
-
- /** Custom CSS class names. */
- className?: string;
-}
diff --git a/packages/raystack/components/chat-panel/__tests__/chat-panel.test.tsx b/packages/raystack/components/chat-panel/__tests__/chat-panel.test.tsx
new file mode 100644
index 000000000..1ecf62231
--- /dev/null
+++ b/packages/raystack/components/chat-panel/__tests__/chat-panel.test.tsx
@@ -0,0 +1,833 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+import { ChatPanel } from '../chat-panel';
+
+const mockRect = (left: number, top: number, width: number, height: number) =>
+ ({
+ left,
+ top,
+ width,
+ height,
+ right: left + width,
+ bottom: top + height,
+ x: left,
+ y: top,
+ toJSON: () => ({})
+ }) as DOMRect;
+
+// dnd-kit keeps a click-swallowing capture listener on the document for 50ms
+// after a drag ends; wait it out so later tests can click.
+const flushDragSuppression = () =>
+ new Promise(resolve => setTimeout(resolve, 60));
+
+// jsdom's viewport is fixed at 1024x768; override it for resize tests.
+const setViewport = (width: number, height: number) => {
+ Object.defineProperty(window, 'innerWidth', {
+ configurable: true,
+ writable: true,
+ value: width
+ });
+ Object.defineProperty(window, 'innerHeight', {
+ configurable: true,
+ writable: true,
+ value: height
+ });
+};
+
+const BasicChatPanel = (props: Partial[0]>) => (
+
+
+ Assistant
+
+
+
+
+
+ Thread
+
+
+);
+
+describe('ChatPanel', () => {
+ describe('Basic Rendering', () => {
+ it('renders docked on the right by default', () => {
+ render( );
+ const panel = screen.getByTestId('panel');
+ expect(panel).toHaveAttribute('data-mode', 'docked');
+ expect(panel).toHaveAttribute('data-side', 'right');
+ expect(panel.tagName).toBe('ASIDE');
+ });
+
+ it('renders the title as a heading', () => {
+ render( );
+ expect(
+ screen.getByRole('heading', { name: 'Assistant' })
+ ).toBeInTheDocument();
+ });
+
+ it('applies custom className', () => {
+ render( );
+ expect(screen.getByTestId('panel')).toHaveClass('custom');
+ });
+
+ it('respects the side prop', () => {
+ render( );
+ expect(screen.getByTestId('panel')).toHaveAttribute('data-side', 'left');
+ });
+
+ it('throws when parts are used outside the root', () => {
+ const spy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined);
+ expect(() => render( )).toThrow(
+ /must be used within /
+ );
+ spy.mockRestore();
+ });
+ });
+
+ describe('Mode switching', () => {
+ it('minimizes via the MinimizeTrigger and shows the bubble', async () => {
+ const onModeChange = vi.fn();
+ const user = userEvent.setup();
+ render( );
+
+ expect(screen.queryByTestId('bubble')).not.toBeInTheDocument();
+
+ await user.click(
+ screen.getByRole('button', { name: 'Minimize chat panel' })
+ );
+
+ expect(onModeChange).toHaveBeenCalledWith('minimized');
+ expect(screen.getByTestId('panel')).toHaveAttribute(
+ 'data-mode',
+ 'minimized'
+ );
+ expect(screen.getByTestId('bubble')).toBeInTheDocument();
+ });
+
+ it('restores the previous mode from the bubble', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(
+ screen.getByRole('button', { name: 'Minimize chat panel' })
+ );
+ expect(screen.getByTestId('panel')).toHaveAttribute(
+ 'data-mode',
+ 'minimized'
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Open chat' }));
+ expect(screen.getByTestId('panel')).toHaveAttribute(
+ 'data-mode',
+ 'floating'
+ );
+ });
+
+ it('toggles docked and floating via the ExpandTrigger', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(
+ screen.getByRole('button', { name: 'Pop out chat panel' })
+ );
+ expect(screen.getByTestId('panel')).toHaveAttribute(
+ 'data-mode',
+ 'floating'
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Dock chat panel' }));
+ expect(screen.getByTestId('panel')).toHaveAttribute(
+ 'data-mode',
+ 'docked'
+ );
+ });
+
+ it('keeps the same default ExpandTrigger icon in both states', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const trigger = screen.getByRole('button', {
+ name: 'Pop out chat panel'
+ });
+ const dockedIcon = trigger.innerHTML;
+ expect(trigger.querySelector('svg')).not.toBeNull();
+
+ await user.click(trigger);
+ expect(
+ screen.getByRole('button', { name: 'Dock chat panel' }).innerHTML
+ ).toBe(dockedIcon);
+ });
+
+ it('overrides the trigger icons with custom children', () => {
+ render(
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ expect(screen.getByTestId('custom-minimize')).toBeInTheDocument();
+ expect(screen.getByTestId('custom-expand')).toBeInTheDocument();
+ });
+
+ it('passes the state to an ExpandTrigger render function', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+
+
+ {({ floating }) =>
+ floating ? (
+
+ ) : (
+
+ )
+ }
+
+
+
+
+ );
+
+ expect(screen.getByTestId('float-icon')).toBeInTheDocument();
+ await user.click(
+ screen.getByRole('button', { name: 'Pop out chat panel' })
+ );
+ expect(screen.getByTestId('dock-icon')).toBeInTheDocument();
+ expect(screen.queryByTestId('float-icon')).not.toBeInTheDocument();
+ });
+
+ it('supports controlled mode', () => {
+ const { rerender } = render( );
+ expect(screen.getByTestId('panel')).toHaveAttribute(
+ 'data-mode',
+ 'docked'
+ );
+
+ rerender( );
+ expect(screen.getByTestId('panel')).toHaveAttribute(
+ 'data-mode',
+ 'minimized'
+ );
+ expect(screen.getByTestId('bubble')).toBeInTheDocument();
+ });
+
+ it('does not change mode itself when controlled', async () => {
+ const onModeChange = vi.fn();
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(
+ screen.getByRole('button', { name: 'Minimize chat panel' })
+ );
+ expect(onModeChange).toHaveBeenCalledWith('minimized');
+ // The parent did not update the prop, so the panel stays docked.
+ expect(screen.getByTestId('panel')).toHaveAttribute(
+ 'data-mode',
+ 'docked'
+ );
+ });
+ });
+
+ describe('Floating mode', () => {
+ it('applies the floating size as inline style', () => {
+ render(
+
+ );
+ const panel = screen.getByTestId('panel');
+ expect(panel.style.width).toBe('420px');
+ expect(panel.style.height).toBe('500px');
+ });
+
+ it('applies a provided position as inline style', () => {
+ render(
+
+ );
+ const panel = screen.getByTestId('panel');
+ expect(panel.style.left).toBe('40px');
+ expect(panel.style.top).toBe('60px');
+ });
+
+ it('moves the window when the header is dragged', async () => {
+ const onPositionChange = vi.fn();
+ render(
+
+ );
+ // jsdom reports zero rects; give the panel its real geometry.
+ screen.getByTestId('panel').getBoundingClientRect = () =>
+ mockRect(100, 100, 400, 500);
+
+ // dnd-kit activates on the header and tracks the pointer on the
+ // document. The first move only satisfies the distance constraint;
+ // the second is the tracked movement.
+ fireEvent.pointerDown(screen.getByTestId('header'), {
+ pointerId: 1,
+ button: 0,
+ isPrimary: true,
+ clientX: 200,
+ clientY: 200
+ });
+ fireEvent.pointerMove(document, {
+ pointerId: 1,
+ clientX: 210,
+ clientY: 200
+ });
+ fireEvent.pointerMove(document, {
+ pointerId: 1,
+ clientX: 240,
+ clientY: 170
+ });
+ fireEvent.pointerUp(document, { pointerId: 1 });
+ await flushDragSuppression();
+
+ expect(onPositionChange).toHaveBeenCalled();
+ const next =
+ onPositionChange.mock.calls[onPositionChange.mock.calls.length - 1][0];
+ expect(next).toEqual({ x: 140, y: 70 });
+ });
+
+ it('does not start a drag from header buttons', () => {
+ const onPositionChange = vi.fn();
+ render(
+
+ );
+ const button = screen.getByRole('button', {
+ name: 'Minimize chat panel'
+ });
+ fireEvent.pointerDown(button, {
+ pointerId: 1,
+ button: 0,
+ isPrimary: true,
+ clientX: 200,
+ clientY: 200
+ });
+ fireEvent.pointerMove(document, {
+ pointerId: 1,
+ clientX: 260,
+ clientY: 260
+ });
+ fireEvent.pointerUp(document, { pointerId: 1 });
+ expect(onPositionChange).not.toHaveBeenCalled();
+ });
+
+ it('does not drag while docked', () => {
+ const onPositionChange = vi.fn();
+ render( );
+ fireEvent.pointerDown(screen.getByTestId('header'), {
+ pointerId: 1,
+ button: 0,
+ isPrimary: true,
+ clientX: 10,
+ clientY: 10
+ });
+ fireEvent.pointerMove(document, {
+ pointerId: 1,
+ clientX: 50,
+ clientY: 50
+ });
+ fireEvent.pointerUp(document, { pointerId: 1 });
+ expect(onPositionChange).not.toHaveBeenCalled();
+ });
+
+ it('clamps the dragged position to the viewport', async () => {
+ const onPositionChange = vi.fn();
+ render(
+
+ );
+ screen.getByTestId('panel').getBoundingClientRect = () =>
+ mockRect(100, 100, 400, 500);
+
+ fireEvent.pointerDown(screen.getByTestId('header'), {
+ pointerId: 1,
+ button: 0,
+ isPrimary: true,
+ clientX: 200,
+ clientY: 200
+ });
+ fireEvent.pointerMove(document, {
+ pointerId: 1,
+ clientX: 210,
+ clientY: 200
+ });
+ fireEvent.pointerMove(document, {
+ pointerId: 1,
+ clientX: -5000,
+ clientY: -5000
+ });
+ fireEvent.pointerUp(document, { pointerId: 1 });
+ await flushDragSuppression();
+
+ const next =
+ onPositionChange.mock.calls[onPositionChange.mock.calls.length - 1][0];
+ expect(next).toEqual({ x: 0, y: 0 });
+ });
+
+ it('clamps the dragged position to the dragBoundary element', async () => {
+ const onPositionChange = vi.fn();
+ const boundary = document.createElement('div');
+ boundary.getBoundingClientRect = () => mockRect(50, 60, 750, 640);
+
+ render(
+
+ );
+ screen.getByTestId('panel').getBoundingClientRect = () =>
+ mockRect(100, 100, 400, 500);
+
+ fireEvent.pointerDown(screen.getByTestId('header'), {
+ pointerId: 1,
+ button: 0,
+ isPrimary: true,
+ clientX: 200,
+ clientY: 200
+ });
+ fireEvent.pointerMove(document, {
+ pointerId: 1,
+ clientX: 210,
+ clientY: 200
+ });
+ fireEvent.pointerMove(document, {
+ pointerId: 1,
+ clientX: 5000,
+ clientY: 5000
+ });
+ fireEvent.pointerUp(document, { pointerId: 1 });
+ await flushDragSuppression();
+
+ const next =
+ onPositionChange.mock.calls[onPositionChange.mock.calls.length - 1][0];
+ // Right edge: 50 + 750 - 400 wide = 400. Bottom edge keeps the header
+ // reachable: 60 + 640 - 48 = 652.
+ expect(next).toEqual({ x: 400, y: 652 });
+ });
+
+ it('renders no resize handles while docked', () => {
+ const { container } = render( );
+ expect(
+ container.querySelectorAll('[class*="resize-handle"]').length
+ ).toBe(0);
+ });
+
+ it('renders eight resize handles while floating', () => {
+ const { container } = render( );
+ expect(
+ container.querySelectorAll('[class*="resize-handle"]').length
+ ).toBe(8);
+ });
+
+ it('renders only the edge handles for each resize axis', () => {
+ const { container, rerender } = render(
+
+ );
+ let handles = container.querySelectorAll('[class*="resize-handle"]');
+ expect(handles.length).toBe(2);
+ expect(container.querySelector('[class*="resize-e"]')).not.toBeNull();
+ expect(container.querySelector('[class*="resize-w"]')).not.toBeNull();
+
+ rerender( );
+ handles = container.querySelectorAll('[class*="resize-handle"]');
+ expect(handles.length).toBe(2);
+ expect(container.querySelector('[class*="resize-n"]')).not.toBeNull();
+ expect(container.querySelector('[class*="resize-s"]')).not.toBeNull();
+ });
+
+ it('renders no resize handles with resize="none"', () => {
+ const { container } = render(
+
+ );
+ expect(
+ container.querySelectorAll('[class*="resize-handle"]').length
+ ).toBe(0);
+ });
+
+ it('marks the floating panel draggable by default', () => {
+ const { rerender, unmount } = render(
+
+ );
+ expect(screen.getByTestId('panel')).toHaveAttribute('data-draggable');
+
+ rerender( );
+ expect(screen.getByTestId('panel')).not.toHaveAttribute('data-draggable');
+ unmount();
+
+ render( );
+ expect(screen.getByTestId('panel')).not.toHaveAttribute('data-draggable');
+ });
+
+ it('does not drag the header when draggable is false', () => {
+ const onPositionChange = vi.fn();
+ render(
+
+ );
+ screen.getByTestId('panel').getBoundingClientRect = () =>
+ mockRect(100, 100, 400, 500);
+
+ fireEvent.pointerDown(screen.getByTestId('header'), {
+ pointerId: 1,
+ button: 0,
+ isPrimary: true,
+ clientX: 200,
+ clientY: 200
+ });
+ fireEvent.pointerMove(document, {
+ pointerId: 1,
+ clientX: 240,
+ clientY: 170
+ });
+ fireEvent.pointerUp(document, { pointerId: 1 });
+ expect(onPositionChange).not.toHaveBeenCalled();
+ });
+
+ it('cannot grow past the initial size by default', () => {
+ const onSizeChange = vi.fn();
+ const { container } = render(
+
+ );
+ screen.getByTestId('panel').getBoundingClientRect = () =>
+ mockRect(0, 0, 400, 500);
+ const handle = container.querySelector(
+ '[class*="resize-se"]'
+ ) as HTMLElement;
+ handle.setPointerCapture = vi.fn();
+ handle.releasePointerCapture = vi.fn();
+ handle.hasPointerCapture = vi.fn().mockReturnValue(true);
+
+ fireEvent.pointerDown(handle, {
+ pointerId: 2,
+ button: 0,
+ clientX: 400,
+ clientY: 500
+ });
+ fireEvent.pointerMove(handle, {
+ pointerId: 2,
+ clientX: 500,
+ clientY: 600
+ });
+ fireEvent.pointerUp(handle, { pointerId: 2 });
+
+ const next =
+ onSizeChange.mock.calls[onSizeChange.mock.calls.length - 1][0];
+ expect(next).toEqual({ width: 400, height: 500 });
+ });
+
+ it('grows past the initial size when maxSize allows it', () => {
+ const onSizeChange = vi.fn();
+ const { container } = render(
+
+ );
+ screen.getByTestId('panel').getBoundingClientRect = () =>
+ mockRect(0, 0, 400, 500);
+ const handle = container.querySelector(
+ '[class*="resize-se"]'
+ ) as HTMLElement;
+ handle.setPointerCapture = vi.fn();
+ handle.releasePointerCapture = vi.fn();
+ handle.hasPointerCapture = vi.fn().mockReturnValue(true);
+
+ fireEvent.pointerDown(handle, {
+ pointerId: 2,
+ button: 0,
+ clientX: 400,
+ clientY: 500
+ });
+ fireEvent.pointerMove(handle, {
+ pointerId: 2,
+ clientX: 500,
+ clientY: 600
+ });
+ fireEvent.pointerUp(handle, { pointerId: 2 });
+
+ const next =
+ onSizeChange.mock.calls[onSizeChange.mock.calls.length - 1][0];
+ expect(next).toEqual({ width: 500, height: 600 });
+ });
+
+ it('resizes from the south-east corner', () => {
+ const onSizeChange = vi.fn();
+ const { container } = render(
+
+ );
+ const handle = container.querySelector(
+ '[class*="resize-se"]'
+ ) as HTMLElement;
+ handle.setPointerCapture = vi.fn();
+ handle.releasePointerCapture = vi.fn();
+ handle.hasPointerCapture = vi.fn().mockReturnValue(true);
+
+ fireEvent.pointerDown(handle, {
+ pointerId: 2,
+ button: 0,
+ clientX: 400,
+ clientY: 500
+ });
+ fireEvent.pointerMove(handle, {
+ pointerId: 2,
+ clientX: 360,
+ clientY: 460
+ });
+ fireEvent.pointerUp(handle, { pointerId: 2 });
+
+ expect(onSizeChange).toHaveBeenCalled();
+ const next =
+ onSizeChange.mock.calls[onSizeChange.mock.calls.length - 1][0];
+ // jsdom reports a zero rect, so the delta clamps to the minimum size.
+ expect(next.width).toBeGreaterThanOrEqual(280);
+ expect(next.height).toBeGreaterThanOrEqual(320);
+ });
+ });
+
+ describe('Minimized mode', () => {
+ it('renders the bubble only while minimized', () => {
+ render( );
+ expect(screen.getByTestId('bubble')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Open chat' })).toBeVisible();
+ });
+
+ it('restores to docked when minimized was the initial mode', async () => {
+ const user = userEvent.setup();
+ render( );
+ await user.click(screen.getByRole('button', { name: 'Open chat' }));
+ expect(screen.getByTestId('panel')).toHaveAttribute(
+ 'data-mode',
+ 'docked'
+ );
+ });
+
+ it('renders custom children inside the bubble', () => {
+ render(
+
+
+ 3
+
+
+ );
+ expect(screen.getByTestId('badge')).toBeInTheDocument();
+ });
+ });
+
+ describe('Draggable bubble', () => {
+ const DraggableBubblePanel = (
+ props: Partial[0]>
+ ) => (
+
+
+ Assistant
+
+
+
+
+ Thread
+
+
+ );
+
+ const dragBubble = (
+ from: { x: number; y: number },
+ to: { x: number; y: number }
+ ) => {
+ fireEvent.pointerDown(screen.getByTestId('bubble'), {
+ pointerId: 1,
+ button: 0,
+ isPrimary: true,
+ clientX: from.x,
+ clientY: from.y
+ });
+ // First move satisfies the distance constraint, second is tracked.
+ fireEvent.pointerMove(document, {
+ pointerId: 1,
+ clientX: from.x - 10,
+ clientY: from.y
+ });
+ fireEvent.pointerMove(document, {
+ pointerId: 1,
+ clientX: to.x,
+ clientY: to.y
+ });
+ fireEvent.pointerUp(document, { pointerId: 1 });
+ };
+
+ it('applies the dropped position to the minimized frame', async () => {
+ render( );
+ screen.getByTestId('bubble').getBoundingClientRect = () =>
+ mockRect(900, 700, 44, 44);
+
+ dragBubble({ x: 920, y: 720 }, { x: 820, y: 620 });
+ await flushDragSuppression();
+
+ const panel = screen.getByTestId('panel');
+ // Origin (900, 700) plus the (-100, -100) delta.
+ expect(panel.style.left).toBe('800px');
+ expect(panel.style.top).toBe('600px');
+ expect(panel).toHaveAttribute('data-mode', 'minimized');
+ });
+
+ it('clamps the dropped bubble to the viewport', async () => {
+ render( );
+ screen.getByTestId('bubble').getBoundingClientRect = () =>
+ mockRect(900, 700, 44, 44);
+
+ dragBubble({ x: 920, y: 720 }, { x: -5000, y: -5000 });
+ await flushDragSuppression();
+
+ const panel = screen.getByTestId('panel');
+ expect(panel.style.left).toBe('0px');
+ expect(panel.style.top).toBe('0px');
+ });
+
+ it('re-clamps the dropped bubble when the viewport shrinks', async () => {
+ render( );
+ screen.getByTestId('bubble').getBoundingClientRect = () =>
+ mockRect(900, 700, 44, 44);
+
+ dragBubble({ x: 920, y: 720 }, { x: 820, y: 620 });
+ await flushDragSuppression();
+
+ const panel = screen.getByTestId('panel');
+ expect(panel.style.left).toBe('800px');
+
+ try {
+ setViewport(500, 400);
+ fireEvent(window, new Event('resize'));
+ // 500 - 44 wide and 400 - 44 tall: the bubble stays fully on screen.
+ expect(panel.style.left).toBe('456px');
+ expect(panel.style.top).toBe('356px');
+ } finally {
+ setViewport(1024, 768);
+ }
+ });
+
+ it('re-clamps a stale dropped position when re-minimizing', async () => {
+ // The bubble remounts on re-minimize, so mock rects on the prototype.
+ const rectSpy = vi
+ .spyOn(HTMLElement.prototype, 'getBoundingClientRect')
+ .mockReturnValue(mockRect(900, 700, 44, 44));
+ try {
+ render( );
+ dragBubble({ x: 920, y: 720 }, { x: 820, y: 620 });
+ await new Promise(resolve => setTimeout(resolve, 150));
+
+ fireEvent.click(screen.getByTestId('bubble'));
+ const panel = screen.getByTestId('panel');
+ expect(panel).toHaveAttribute('data-mode', 'docked');
+
+ // The viewport shrinks while the panel is docked, then re-minimizes:
+ // the stale drop position re-clamps on entering minimized.
+ setViewport(500, 400);
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Minimize chat panel' })
+ );
+ expect(panel).toHaveAttribute('data-mode', 'minimized');
+ expect(panel.style.left).toBe('456px');
+ expect(panel.style.top).toBe('356px');
+ } finally {
+ rectSpy.mockRestore();
+ setViewport(1024, 768);
+ }
+ });
+
+ it('keeps the dropped position across restore and minimize', async () => {
+ render( );
+ screen.getByTestId('bubble').getBoundingClientRect = () =>
+ mockRect(900, 700, 44, 44);
+
+ dragBubble({ x: 920, y: 720 }, { x: 820, y: 620 });
+ // Wait out both dnd-kit's click swallow and the trigger's own
+ // wasDragged suppression window.
+ await new Promise(resolve => setTimeout(resolve, 150));
+
+ fireEvent.click(screen.getByTestId('bubble'));
+ const panel = screen.getByTestId('panel');
+ expect(panel).toHaveAttribute('data-mode', 'docked');
+ expect(panel.style.left).toBe('');
+
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Minimize chat panel' })
+ );
+ expect(panel).toHaveAttribute('data-mode', 'minimized');
+ expect(panel.style.left).toBe('800px');
+ expect(panel.style.top).toBe('600px');
+ });
+
+ it('does not restore from the click that ends a drag', async () => {
+ render( );
+ screen.getByTestId('bubble').getBoundingClientRect = () =>
+ mockRect(900, 700, 44, 44);
+
+ dragBubble({ x: 920, y: 720 }, { x: 820, y: 620 });
+ fireEvent.click(screen.getByTestId('bubble'));
+ expect(screen.getByTestId('panel')).toHaveAttribute(
+ 'data-mode',
+ 'minimized'
+ );
+
+ await new Promise(resolve => setTimeout(resolve, 150));
+ fireEvent.click(screen.getByTestId('bubble'));
+ expect(screen.getByTestId('panel')).toHaveAttribute(
+ 'data-mode',
+ 'docked'
+ );
+ });
+
+ it('does not drag the bubble without the draggable prop', () => {
+ render( );
+ screen.getByTestId('bubble').getBoundingClientRect = () =>
+ mockRect(900, 700, 44, 44);
+
+ dragBubble({ x: 920, y: 720 }, { x: 820, y: 620 });
+ const panel = screen.getByTestId('panel');
+ expect(panel.style.left).toBe('');
+ expect(panel.style.top).toBe('');
+ });
+ });
+});
diff --git a/packages/raystack/components/chat-panel/chat-panel-context.tsx b/packages/raystack/components/chat-panel/chat-panel-context.tsx
new file mode 100644
index 000000000..4e9419452
--- /dev/null
+++ b/packages/raystack/components/chat-panel/chat-panel-context.tsx
@@ -0,0 +1,36 @@
+'use client';
+
+import type { DraggableSyntheticListeners } from '@dnd-kit/core';
+import { createContext, RefObject, useContext } from 'react';
+
+export type ChatPanelMode = 'docked' | 'floating' | 'minimized';
+export type ChatPanelSide = 'left' | 'right';
+
+export interface ChatPanelContextValue {
+ mode: ChatPanelMode;
+ side: ChatPanelSide;
+ setMode: (mode: ChatPanelMode) => void;
+ minimize: () => void;
+ restore: () => void;
+ toggleFloating: () => void;
+ /** dnd-kit activator ref for the drag handle (the header). */
+ dragHandleRef: (element: HTMLElement | null) => void;
+ /** dnd-kit activator listeners; undefined while dragging is disabled. */
+ dragListeners: DraggableSyntheticListeners;
+ /** dnd-kit id of the minimized bubble's draggable. */
+ bubbleDraggableId: string;
+ /** The bubble element, so the root can measure it when a drag starts. */
+ bubbleElementRef: RefObject;
+}
+
+export const ChatPanelContext = createContext(
+ null
+);
+
+export function useChatPanelContext(part: string): ChatPanelContextValue {
+ const context = useContext(ChatPanelContext);
+ if (!context) {
+ throw new Error(`ChatPanel.${part} must be used within `);
+ }
+ return context;
+}
diff --git a/packages/raystack/components/chat-panel/chat-panel-parts.tsx b/packages/raystack/components/chat-panel/chat-panel-parts.tsx
new file mode 100644
index 000000000..fb9a056f1
--- /dev/null
+++ b/packages/raystack/components/chat-panel/chat-panel-parts.tsx
@@ -0,0 +1,146 @@
+'use client';
+
+import { useMergedRefs } from '@base-ui/utils/useMergedRefs';
+import { MinusIcon, SizeIcon } from '@radix-ui/react-icons';
+import { cx } from 'class-variance-authority';
+import { ComponentProps, MouseEvent, PointerEvent, ReactNode } from 'react';
+import { IconButton, type IconButtonProps } from '../icon-button/icon-button';
+import styles from './chat-panel.module.css';
+import { useChatPanelContext } from './chat-panel-context';
+
+export interface ChatPanelHeaderProps extends ComponentProps<'header'> {}
+
+export function ChatPanelHeader({
+ className,
+ onPointerDown,
+ ref,
+ ...props
+}: ChatPanelHeaderProps) {
+ const { dragHandleRef, dragListeners } = useChatPanelContext('Header');
+ const mergedRef = useMergedRefs(dragHandleRef, ref);
+
+ const handlePointerDown = (event: PointerEvent) => {
+ onPointerDown?.(event);
+ if (event.defaultPrevented) return;
+ dragListeners?.onPointerDown?.(event);
+ };
+
+ return (
+
+ );
+}
+
+ChatPanelHeader.displayName = 'ChatPanel.Header';
+
+export interface ChatPanelTitleProps extends ComponentProps<'h2'> {}
+
+export function ChatPanelTitle({ className, ...props }: ChatPanelTitleProps) {
+ return ;
+}
+
+ChatPanelTitle.displayName = 'ChatPanel.Title';
+
+export interface ChatPanelActionsProps extends ComponentProps<'div'> {}
+
+export function ChatPanelActions({
+ className,
+ ...props
+}: ChatPanelActionsProps) {
+ return
;
+}
+
+ChatPanelActions.displayName = 'ChatPanel.Actions';
+
+export interface ChatPanelContentProps extends ComponentProps<'div'> {}
+
+export function ChatPanelContent({
+ className,
+ ...props
+}: ChatPanelContentProps) {
+ return
;
+}
+
+ChatPanelContent.displayName = 'ChatPanel.Content';
+
+export interface ChatPanelMinimizeTriggerProps extends IconButtonProps {}
+
+export function ChatPanelMinimizeTrigger({
+ children,
+ onClick,
+ 'aria-label': ariaLabel = 'Minimize chat panel',
+ ...props
+}: ChatPanelMinimizeTriggerProps) {
+ const { minimize } = useChatPanelContext('MinimizeTrigger');
+
+ const handleClick = (event: MouseEvent) => {
+ onClick?.(event);
+ if (event.defaultPrevented) return;
+ minimize();
+ };
+
+ return (
+
+ {children ?? }
+
+ );
+}
+
+ChatPanelMinimizeTrigger.displayName = 'ChatPanel.MinimizeTrigger';
+
+export interface ChatPanelExpandTriggerState {
+ /** Whether the panel is currently floating (true) or docked (false). */
+ floating: boolean;
+}
+
+export interface ChatPanelExpandTriggerProps
+ extends Omit {
+ /**
+ * The icon. Defaults to a size icon in both states; pass a render
+ * function to swap icons based on the current state.
+ */
+ children?: ReactNode | ((state: ChatPanelExpandTriggerState) => ReactNode);
+}
+
+export function ChatPanelExpandTrigger({
+ children,
+ onClick,
+ 'aria-label': ariaLabel,
+ ...props
+}: ChatPanelExpandTriggerProps) {
+ const { mode, toggleFloating } = useChatPanelContext('ExpandTrigger');
+ const floating = mode === 'floating';
+
+ const handleClick = (event: MouseEvent) => {
+ onClick?.(event);
+ if (event.defaultPrevented) return;
+ toggleFloating();
+ };
+
+ const resolvedChildren =
+ typeof children === 'function' ? children({ floating }) : children;
+
+ return (
+
+ {resolvedChildren ?? }
+
+ );
+}
+
+ChatPanelExpandTrigger.displayName = 'ChatPanel.ExpandTrigger';
diff --git a/packages/raystack/components/chat-panel/chat-panel-root.tsx b/packages/raystack/components/chat-panel/chat-panel-root.tsx
new file mode 100644
index 000000000..9d80eb0b5
--- /dev/null
+++ b/packages/raystack/components/chat-panel/chat-panel-root.tsx
@@ -0,0 +1,753 @@
+'use client';
+
+import { useControlled } from '@base-ui/utils/useControlled';
+import {
+ DndContext,
+ type DragEndEvent,
+ type DragStartEvent,
+ type Modifier,
+ PointerSensor,
+ useDraggable,
+ useSensor,
+ useSensors
+} from '@dnd-kit/core';
+import { cx } from 'class-variance-authority';
+import {
+ ComponentProps,
+ CSSProperties,
+ ReactNode,
+ PointerEvent as ReactPointerEvent,
+ RefObject,
+ useCallback,
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState
+} from 'react';
+import styles from './chat-panel.module.css';
+import {
+ ChatPanelContext,
+ type ChatPanelContextValue,
+ type ChatPanelMode,
+ type ChatPanelSide
+} from './chat-panel-context';
+
+export interface ChatPanelPosition {
+ x: number;
+ y: number;
+}
+
+export interface ChatPanelSize {
+ width: number;
+ height: number;
+}
+
+/** An element (or ref to one) that confines floating-window dragging. */
+export type ChatPanelDragBoundary = HTMLElement | RefObject;
+
+/** Keep at least this much of the header on screen while clamping. */
+const HEADER_SAFE_PX = 48;
+
+const DEFAULT_SIZE: ChatPanelSize = { width: 400, height: 560 };
+const DEFAULT_MIN_SIZE: ChatPanelSize = { width: 280, height: 320 };
+
+type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
+
+/** Which resize handles to render, mirroring the CSS `resize` vocabulary. */
+export type ChatPanelResize = 'both' | 'horizontal' | 'vertical' | 'none';
+
+const RESIZE_HANDLES: Record = {
+ both: ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'],
+ horizontal: ['e', 'w'],
+ vertical: ['n', 's'],
+ none: []
+};
+
+function clamp(value: number, min: number, max: number) {
+ return Math.min(Math.max(value, min), Math.max(min, max));
+}
+
+interface DragBounds {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+}
+
+function resolveDragBoundary(
+ boundary: ChatPanelDragBoundary | undefined
+): HTMLElement | null {
+ if (!boundary) return null;
+ return 'current' in boundary ? boundary.current : boundary;
+}
+
+/**
+ * A PointerSensor that never starts a drag from interactive children. The
+ * minimized bubble is the one draggable button: its listeners are only
+ * attached while its drag is enabled, so allowing it here is safe.
+ */
+class ChatPanelPointerSensor extends PointerSensor {
+ static activators = [
+ {
+ eventName: 'onPointerDown' as const,
+ handler: ({ nativeEvent: event }: ReactPointerEvent) => {
+ if (event.button !== 0 || event.isPrimary === false) return false;
+ const target = event.target as Element;
+ if (target.closest('[data-chat-panel-trigger]')) return true;
+ return !target.closest(
+ 'button, a, input, textarea, select, [data-chat-panel-no-drag]'
+ );
+ }
+ }
+ ];
+}
+
+export interface ChatPanelRootProps extends ComponentProps<'aside'> {
+ /** Presentation mode of the panel (controlled). */
+ mode?: ChatPanelMode;
+ /**
+ * Initial mode when uncontrolled.
+ * @defaultValue "docked"
+ */
+ defaultMode?: ChatPanelMode;
+ /** Called when the mode changes. */
+ onModeChange?: (mode: ChatPanelMode) => void;
+ /**
+ * Which edge the panel docks to; also picks the corner used by the
+ * floating default position and the minimized trigger.
+ * @defaultValue "right"
+ */
+ side?: ChatPanelSide;
+ /** Floating window position in viewport pixels (controlled). */
+ position?: ChatPanelPosition | null;
+ /**
+ * Initial floating position when uncontrolled. When omitted the window
+ * starts at the bottom corner on the docked `side`.
+ */
+ defaultPosition?: ChatPanelPosition;
+ /** Called when a drag ends or resizing moves the floating window. */
+ onPositionChange?: (position: ChatPanelPosition) => void;
+ /** Floating window size in pixels (controlled). */
+ size?: ChatPanelSize;
+ /**
+ * Initial floating size when uncontrolled.
+ * @defaultValue { width: 400, height: 560 }
+ */
+ defaultSize?: ChatPanelSize;
+ /** Called when resizing changes the floating window size. */
+ onSizeChange?: (size: ChatPanelSize) => void;
+ /**
+ * Smallest allowed floating size.
+ * @defaultValue { width: 280, height: 320 }
+ */
+ minSize?: ChatPanelSize;
+ /**
+ * Largest allowed floating size, always additionally clamped by the
+ * viewport.
+ * @defaultValue the initial floating size — out of the box the window can
+ * only shrink; pass a larger `maxSize` to let it grow.
+ */
+ maxSize?: ChatPanelSize;
+ /**
+ * Which axes the floating window can be resized on; mirrors the CSS
+ * `resize` property vocabulary.
+ * @defaultValue "both"
+ */
+ resize?: ChatPanelResize;
+ /**
+ * Whether the floating window can be dragged by its header. Shadows the
+ * (useless here) native `draggable` attribute.
+ * @defaultValue true
+ */
+ draggable?: boolean;
+ /**
+ * Confines floating-window dragging to an element instead of the
+ * viewport. Accepts the element or a ref to it.
+ */
+ dragBoundary?: ChatPanelDragBoundary;
+}
+
+export function ChatPanelRoot({
+ className,
+ children,
+ style,
+ mode: modeProp,
+ defaultMode = 'docked',
+ onModeChange,
+ side = 'right',
+ position: positionProp,
+ defaultPosition,
+ onPositionChange,
+ size: sizeProp,
+ defaultSize,
+ onSizeChange,
+ minSize,
+ maxSize,
+ resize = 'both',
+ draggable = true,
+ dragBoundary,
+ ref,
+ ...props
+}: ChatPanelRootProps) {
+ const panelRef = useRef(null);
+ const bubbleElementRef = useRef(null);
+ const draggableId = useId();
+ const bubbleDraggableId = useId();
+
+ const [mode, setModeUnwrapped] = useControlled({
+ controlled: modeProp,
+ default: defaultMode,
+ name: 'ChatPanel',
+ state: 'mode'
+ });
+ const [position, setPositionUnwrapped] =
+ useControlled({
+ controlled: positionProp,
+ default: defaultPosition ?? null,
+ name: 'ChatPanel',
+ state: 'position'
+ });
+ const [size, setSizeUnwrapped] = useControlled({
+ controlled: sizeProp,
+ default: defaultSize ?? DEFAULT_SIZE,
+ name: 'ChatPanel',
+ state: 'size'
+ });
+
+ const [resizing, setResizing] = useState(false);
+ // Where the minimized bubble was dropped; internal only, survives
+ // minimize/restore cycles because it lives here rather than in the trigger.
+ const [bubblePosition, setBubblePosition] =
+ useState(null);
+
+ // The size the window first resolved to; the default maxSize, so a custom
+ // defaultSize never contradicts its own max.
+ const initialSizeRef = useRef(sizeProp ?? defaultSize ?? DEFAULT_SIZE);
+
+ const modeRef = useRef(mode);
+ modeRef.current = mode;
+ const positionRef = useRef(position);
+ positionRef.current = position;
+ const sizeRef = useRef(size);
+ sizeRef.current = size;
+ // The mode restored when leaving 'minimized'.
+ const previousModeRef = useRef>(
+ defaultMode === 'minimized' ? 'docked' : defaultMode
+ );
+
+ const onModeChangeRef = useRef(onModeChange);
+ onModeChangeRef.current = onModeChange;
+ const onPositionChangeRef = useRef(onPositionChange);
+ onPositionChangeRef.current = onPositionChange;
+ const onSizeChangeRef = useRef(onSizeChange);
+ onSizeChangeRef.current = onSizeChange;
+ const minSizeRef = useRef(minSize ?? DEFAULT_MIN_SIZE);
+ minSizeRef.current = minSize ?? DEFAULT_MIN_SIZE;
+ const maxSizeRef = useRef(maxSize ?? initialSizeRef.current);
+ maxSizeRef.current = maxSize ?? initialSizeRef.current;
+ const dragBoundaryRef = useRef(dragBoundary);
+ dragBoundaryRef.current = dragBoundary;
+
+ const setMode = useCallback(
+ (next: ChatPanelMode) => {
+ if (next === modeRef.current) return;
+ if (modeRef.current !== 'minimized') {
+ previousModeRef.current = modeRef.current as Exclude<
+ ChatPanelMode,
+ 'minimized'
+ >;
+ }
+ setModeUnwrapped(next);
+ onModeChangeRef.current?.(next);
+ },
+ [setModeUnwrapped]
+ );
+
+ const setPosition = useCallback(
+ (next: ChatPanelPosition) => {
+ setPositionUnwrapped(next);
+ onPositionChangeRef.current?.(next);
+ },
+ [setPositionUnwrapped]
+ );
+
+ const setSize = useCallback(
+ (next: ChatPanelSize) => {
+ setSizeUnwrapped(next);
+ onSizeChangeRef.current?.(next);
+ },
+ [setSizeUnwrapped]
+ );
+
+ const getDragBounds = useCallback((): DragBounds => {
+ const element = resolveDragBoundary(dragBoundaryRef.current);
+ if (element) {
+ const rect = element.getBoundingClientRect();
+ return {
+ left: rect.left,
+ top: rect.top,
+ right: rect.right,
+ bottom: rect.bottom
+ };
+ }
+ return {
+ left: 0,
+ top: 0,
+ right: window.innerWidth,
+ bottom: window.innerHeight
+ };
+ }, []);
+
+ const clampPosition = useCallback(
+ (next: ChatPanelPosition, width: number): ChatPanelPosition => {
+ const bounds = getDragBounds();
+ return {
+ x: Math.round(clamp(next.x, bounds.left, bounds.right - width)),
+ y: Math.round(clamp(next.y, bounds.top, bounds.bottom - HEADER_SAFE_PX))
+ };
+ },
+ [getDragBounds]
+ );
+
+ /* ------------------------------- dragging ------------------------------ */
+
+ // The distance constraint keeps bubble clicks working: a press that moves
+ // less than 4px stays a click and never activates a drag.
+ const sensors = useSensors(
+ useSensor(ChatPanelPointerSensor, {
+ activationConstraint: { distance: 4 }
+ })
+ );
+
+ // Clamps the live drag transform the same way the committed position is
+ // clamped: fully inside the bounds horizontally, header kept reachable
+ // vertically. The bubble is small, so it stays fully on screen instead.
+ const restrictToDragBounds = useCallback(
+ ({ transform, draggingNodeRect, active }) => {
+ if (!draggingNodeRect) return transform;
+ if (active?.id === bubbleDraggableId) {
+ return {
+ ...transform,
+ x: clamp(
+ transform.x,
+ -draggingNodeRect.left,
+ window.innerWidth - draggingNodeRect.left - draggingNodeRect.width
+ ),
+ y: clamp(
+ transform.y,
+ -draggingNodeRect.top,
+ window.innerHeight - draggingNodeRect.top - draggingNodeRect.height
+ )
+ };
+ }
+ const bounds = getDragBounds();
+ return {
+ ...transform,
+ x: clamp(
+ transform.x,
+ bounds.left - draggingNodeRect.left,
+ bounds.right - draggingNodeRect.left - draggingNodeRect.width
+ ),
+ y: clamp(
+ transform.y,
+ bounds.top - draggingNodeRect.top,
+ bounds.bottom - HEADER_SAFE_PX - draggingNodeRect.top
+ )
+ };
+ },
+ [getDragBounds, bubbleDraggableId]
+ );
+ const modifiers = useMemo(
+ () => [restrictToDragBounds],
+ [restrictToDragBounds]
+ );
+
+ const dragOriginRef = useRef<{ x: number; y: number; width: number } | null>(
+ null
+ );
+ const bubbleDragOriginRef = useRef<{
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ } | null>(null);
+
+ const handleDragStart = useCallback(
+ (event: DragStartEvent) => {
+ if (event.active.id === bubbleDraggableId) {
+ const bubble = bubbleElementRef.current;
+ if (!bubble) return;
+ const rect = bubble.getBoundingClientRect();
+ bubbleDragOriginRef.current = {
+ x: rect.left,
+ y: rect.top,
+ width: rect.width,
+ height: rect.height
+ };
+ return;
+ }
+ const panel = panelRef.current;
+ if (!panel) return;
+ const rect = panel.getBoundingClientRect();
+ dragOriginRef.current = { x: rect.left, y: rect.top, width: rect.width };
+ // Anchor a corner-positioned panel so the drag delta has a fixed origin.
+ if (!positionRef.current) {
+ setPosition({ x: Math.round(rect.left), y: Math.round(rect.top) });
+ }
+ },
+ [setPosition, bubbleDraggableId]
+ );
+
+ // dnd-kit's end delta is the raw translate (modifiers are not applied to
+ // it), so the commit re-clamps with the same bounds as the modifier.
+ const handleDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ if (event.active.id === bubbleDraggableId) {
+ const origin = bubbleDragOriginRef.current;
+ bubbleDragOriginRef.current = null;
+ if (!origin) return;
+ setBubblePosition({
+ x: Math.round(
+ clamp(origin.x + event.delta.x, 0, window.innerWidth - origin.width)
+ ),
+ y: Math.round(
+ clamp(
+ origin.y + event.delta.y,
+ 0,
+ window.innerHeight - origin.height
+ )
+ )
+ });
+ return;
+ }
+ const origin = dragOriginRef.current;
+ dragOriginRef.current = null;
+ if (!origin) return;
+ setPosition(
+ clampPosition(
+ { x: origin.x + event.delta.x, y: origin.y + event.delta.y },
+ origin.width
+ )
+ );
+ },
+ [clampPosition, setPosition, bubbleDraggableId]
+ );
+
+ const handleDragCancel = useCallback(() => {
+ dragOriginRef.current = null;
+ bubbleDragOriginRef.current = null;
+ }, []);
+
+ /* ------------------------------- resizing ------------------------------ */
+
+ const resizeStateRef = useRef<{
+ pointerId: number;
+ direction: ResizeDirection;
+ startX: number;
+ startY: number;
+ rect: { left: number; top: number; width: number; height: number };
+ } | null>(null);
+
+ const handleResizeDown = useCallback(
+ (direction: ResizeDirection) =>
+ (event: ReactPointerEvent) => {
+ if (event.button !== 0) return;
+ const panel = panelRef.current;
+ if (!panel) return;
+ const rect = panel.getBoundingClientRect();
+ resizeStateRef.current = {
+ pointerId: event.pointerId,
+ direction,
+ startX: event.clientX,
+ startY: event.clientY,
+ rect: {
+ left: rect.left,
+ top: rect.top,
+ width: rect.width,
+ height: rect.height
+ }
+ };
+ // Anchor a corner-positioned panel so growing an edge doesn't slide
+ // the opposite one.
+ if (!positionRef.current) {
+ setPosition({ x: Math.round(rect.left), y: Math.round(rect.top) });
+ }
+ event.currentTarget.setPointerCapture(event.pointerId);
+ setResizing(true);
+ event.preventDefault();
+ },
+ [setPosition]
+ );
+
+ const handleResizeMove = useCallback(
+ (event: ReactPointerEvent) => {
+ const state = resizeStateRef.current;
+ if (!state || event.pointerId !== state.pointerId) return;
+ const { direction, rect } = state;
+ const dx = event.clientX - state.startX;
+ const dy = event.clientY - state.startY;
+ const min = minSizeRef.current;
+ const maxWidth = Math.min(
+ maxSizeRef.current?.width ?? Infinity,
+ window.innerWidth
+ );
+ const maxHeight = Math.min(
+ maxSizeRef.current?.height ?? Infinity,
+ window.innerHeight
+ );
+ let { left, top, width, height } = rect;
+ if (direction.includes('e')) {
+ width = clamp(
+ rect.width + dx,
+ min.width,
+ Math.min(maxWidth, window.innerWidth - rect.left)
+ );
+ }
+ if (direction.includes('s')) {
+ height = clamp(
+ rect.height + dy,
+ min.height,
+ Math.min(maxHeight, window.innerHeight - rect.top)
+ );
+ }
+ if (direction.includes('w')) {
+ const right = rect.left + rect.width;
+ width = clamp(rect.width - dx, min.width, Math.min(maxWidth, right));
+ left = right - width;
+ }
+ if (direction.includes('n')) {
+ const bottom = rect.top + rect.height;
+ height = clamp(
+ rect.height - dy,
+ min.height,
+ Math.min(maxHeight, bottom)
+ );
+ top = bottom - height;
+ }
+ setSize({ width: Math.round(width), height: Math.round(height) });
+ if (direction.includes('w') || direction.includes('n')) {
+ setPosition({ x: Math.round(left), y: Math.round(top) });
+ }
+ },
+ [setPosition, setSize]
+ );
+
+ const handleResizeEnd = useCallback(
+ (event: ReactPointerEvent) => {
+ const state = resizeStateRef.current;
+ if (!state || event.pointerId !== state.pointerId) return;
+ resizeStateRef.current = null;
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ }
+ setResizing(false);
+ },
+ []
+ );
+
+ // Keep the floating window reachable when the viewport shrinks.
+ useEffect(() => {
+ if (mode !== 'floating' || typeof window === 'undefined') return;
+ const handleWindowResize = () => {
+ const current = positionRef.current;
+ const panel = panelRef.current;
+ if (!current || !panel) return;
+ const next = clampPosition(current, panel.getBoundingClientRect().width);
+ if (next.x !== current.x || next.y !== current.y) setPosition(next);
+ };
+ window.addEventListener('resize', handleWindowResize);
+ return () => window.removeEventListener('resize', handleWindowResize);
+ }, [mode, clampPosition, setPosition]);
+
+ // Keep a dropped bubble reachable too: unlike the floating window it has
+ // no header to grab, so an off-screen bubble would strand the panel. Runs
+ // on entering minimized as well, covering resizes made in other modes.
+ useEffect(() => {
+ if (mode !== 'minimized' || typeof window === 'undefined') return;
+ const clampBubble = () => {
+ setBubblePosition(current => {
+ if (!current) return current;
+ const rect = bubbleElementRef.current?.getBoundingClientRect();
+ const next = {
+ x: Math.round(
+ clamp(current.x, 0, window.innerWidth - (rect?.width ?? 0))
+ ),
+ y: Math.round(
+ clamp(current.y, 0, window.innerHeight - (rect?.height ?? 0))
+ )
+ };
+ return next.x === current.x && next.y === current.y ? current : next;
+ });
+ };
+ clampBubble();
+ window.addEventListener('resize', clampBubble);
+ return () => window.removeEventListener('resize', clampBubble);
+ }, [mode]);
+
+ const baseContext = useMemo(
+ () => ({
+ mode,
+ side,
+ setMode,
+ minimize: () => setMode('minimized'),
+ restore: () => setMode(previousModeRef.current),
+ toggleFloating: () =>
+ setMode(modeRef.current === 'floating' ? 'docked' : 'floating'),
+ bubbleDraggableId,
+ bubbleElementRef
+ }),
+ [mode, side, setMode, bubbleDraggableId]
+ );
+
+ const floatingStyle =
+ mode === 'floating'
+ ? {
+ width: size.width,
+ height: size.height,
+ ...(position
+ ? {
+ left: position.x,
+ top: position.y,
+ right: 'auto',
+ bottom: 'auto'
+ }
+ : null)
+ }
+ : mode === 'minimized' && bubblePosition
+ ? {
+ // A dropped bubble overrides the CSS corner pinning.
+ left: bubblePosition.x,
+ top: bubblePosition.y,
+ right: 'auto',
+ bottom: 'auto'
+ }
+ : null;
+
+ return (
+
+ (
+
+ ))
+ : null
+ }
+ {...props}
+ >
+ {children}
+
+
+ );
+}
+
+ChatPanelRoot.displayName = 'ChatPanel';
+
+interface ChatPanelFrameProps extends ComponentProps<'aside'> {
+ draggableId: string;
+ baseContext: Omit;
+ panelRef: RefObject;
+ mode: ChatPanelMode;
+ side: ChatPanelSide;
+ draggable: boolean;
+ resizing: boolean;
+ floatingStyle: CSSProperties | null;
+ resizeHandles: ReactNode;
+}
+
+// useDraggable needs the DndContext provider above it, so the frame lives in
+// its own component under the root's DndContext.
+function ChatPanelFrame({
+ draggableId,
+ baseContext,
+ panelRef,
+ mode,
+ side,
+ draggable,
+ resizing,
+ floatingStyle,
+ resizeHandles,
+ className,
+ style,
+ children,
+ ref,
+ ...props
+}: ChatPanelFrameProps) {
+ const dragEnabled = mode === 'floating' && draggable;
+ const { setNodeRef, setActivatorNodeRef, listeners, transform, isDragging } =
+ useDraggable({
+ id: draggableId,
+ disabled: !dragEnabled
+ });
+
+ const contextValue = useMemo(
+ () => ({
+ ...baseContext,
+ dragHandleRef: setActivatorNodeRef,
+ dragListeners: listeners
+ }),
+ [baseContext, setActivatorNodeRef, listeners]
+ );
+
+ return (
+ {
+ panelRef.current = node;
+ setNodeRef(node);
+ if (typeof ref === 'function') ref(node);
+ else if (ref) ref.current = node;
+ }}
+ className={cx(styles.root, className)}
+ data-mode={mode}
+ data-side={side}
+ data-draggable={dragEnabled || undefined}
+ data-dragging={isDragging || undefined}
+ data-resizing={resizing || undefined}
+ style={{
+ ...floatingStyle,
+ // The committed position only updates when the drag ends; the live
+ // movement is the dnd-kit transform.
+ ...(transform
+ ? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` }
+ : null),
+ ...style
+ }}
+ {...props}
+ >
+
+ {children}
+ {resizeHandles}
+
+
+ );
+}
diff --git a/packages/raystack/components/chat-panel/chat-panel-trigger.tsx b/packages/raystack/components/chat-panel/chat-panel-trigger.tsx
new file mode 100644
index 000000000..c155d4180
--- /dev/null
+++ b/packages/raystack/components/chat-panel/chat-panel-trigger.tsx
@@ -0,0 +1,111 @@
+'use client';
+
+import { useDraggable } from '@dnd-kit/core';
+import { ChatBubbleIcon } from '@radix-ui/react-icons';
+import { cx } from 'class-variance-authority';
+import {
+ ComponentProps,
+ MouseEvent,
+ PointerEvent,
+ useEffect,
+ useRef
+} from 'react';
+import styles from './chat-panel.module.css';
+import { useChatPanelContext } from './chat-panel-context';
+
+export interface ChatPanelTriggerProps extends ComponentProps<'button'> {
+ /**
+ * Whether the minimized bubble can be dragged around the viewport. The
+ * dropped position is kept across minimize/restore cycles. Shadows the
+ * (useless here) native `draggable` attribute.
+ * @defaultValue false
+ */
+ draggable?: boolean;
+}
+
+export function ChatPanelTrigger({
+ className,
+ children,
+ onClick,
+ onPointerDown,
+ style,
+ draggable = false,
+ 'aria-label': ariaLabel = 'Open chat',
+ ref,
+ ...props
+}: ChatPanelTriggerProps) {
+ const { mode, restore, bubbleDraggableId, bubbleElementRef } =
+ useChatPanelContext('Trigger');
+
+ const dragEnabled = mode === 'minimized' && draggable;
+ const { setNodeRef, listeners, transform, isDragging } = useDraggable({
+ id: bubbleDraggableId,
+ disabled: !dragEnabled
+ });
+
+ // A completed drag must not restore the panel. dnd-kit swallows the click
+ // right after a drag ends; this ref backs that up (jsdom, slow frames).
+ const wasDraggedRef = useRef(false);
+ const prevDraggingRef = useRef(false);
+ useEffect(() => {
+ const wasDragging = prevDraggingRef.current;
+ prevDraggingRef.current = isDragging;
+ if (wasDragging && !isDragging) {
+ wasDraggedRef.current = true;
+ const timer = setTimeout(() => {
+ wasDraggedRef.current = false;
+ }, 100);
+ return () => clearTimeout(timer);
+ }
+ }, [isDragging]);
+
+ if (mode !== 'minimized') return null;
+
+ const handleClick = (event: MouseEvent) => {
+ if (wasDraggedRef.current) {
+ wasDraggedRef.current = false;
+ return;
+ }
+ onClick?.(event);
+ if (event.defaultPrevented) return;
+ restore();
+ };
+
+ const handlePointerDown = (event: PointerEvent) => {
+ onPointerDown?.(event);
+ if (event.defaultPrevented) return;
+ listeners?.onPointerDown?.(event);
+ };
+
+ return (
+ {
+ bubbleElementRef.current = node;
+ setNodeRef(node);
+ if (typeof ref === 'function') ref(node);
+ else if (ref) ref.current = node;
+ }}
+ className={cx(styles.trigger, className)}
+ data-chat-panel-trigger=''
+ data-draggable={dragEnabled || undefined}
+ data-dragging={isDragging || undefined}
+ aria-label={ariaLabel}
+ onClick={handleClick}
+ onPointerDown={handlePointerDown}
+ style={{
+ // The committed position lands on the panel frame; the live movement
+ // is the dnd-kit transform.
+ ...(transform
+ ? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` }
+ : null),
+ ...style
+ }}
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+ChatPanelTrigger.displayName = 'ChatPanel.Trigger';
diff --git a/packages/raystack/components/chat-panel/chat-panel.module.css b/packages/raystack/components/chat-panel/chat-panel.module.css
new file mode 100644
index 000000000..2ec462264
--- /dev/null
+++ b/packages/raystack/components/chat-panel/chat-panel.module.css
@@ -0,0 +1,259 @@
+.root {
+ --chat-panel-docked-width: 360px;
+
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ background: var(--rs-color-background-base-primary);
+}
+
+/* Docked: a real in-flow sidebar that squeezes its flex siblings. */
+.root[data-mode="docked"] {
+ width: var(--chat-panel-docked-width);
+ flex-shrink: 0;
+ min-height: 0;
+}
+
+.root[data-mode="docked"][data-side="right"] {
+ border-left: 0.5px solid var(--rs-color-border-base-primary);
+}
+
+.root[data-mode="docked"][data-side="left"] {
+ border-right: 0.5px solid var(--rs-color-border-base-primary);
+}
+
+/* Floating: same element, switched to a fixed window via CSS only. */
+.root[data-mode="floating"] {
+ position: fixed;
+ z-index: var(--rs-z-index-portal);
+ border: 0.5px solid var(--rs-color-border-base-primary);
+ border-radius: var(--rs-radius-5);
+ box-shadow: var(--rs-shadow-floating);
+ overflow: hidden;
+ max-width: calc(100vw - var(--rs-space-9));
+ max-height: calc(100vh - var(--rs-space-9));
+}
+
+.root[data-mode="floating"][data-side="right"] {
+ right: var(--rs-space-5);
+ bottom: var(--rs-space-5);
+}
+
+.root[data-mode="floating"][data-side="left"] {
+ left: var(--rs-space-5);
+ bottom: var(--rs-space-5);
+}
+
+.root[data-dragging],
+.root[data-resizing] {
+ user-select: none;
+}
+
+/* Minimized: the frame collapses to the corner trigger. */
+.root[data-mode="minimized"] {
+ position: fixed;
+ z-index: var(--rs-z-index-portal);
+ bottom: var(--rs-space-7);
+ width: auto;
+ height: auto;
+ background: none;
+ border: none;
+}
+
+.root[data-mode="minimized"][data-side="right"] {
+ right: var(--rs-space-7);
+}
+
+.root[data-mode="minimized"][data-side="left"] {
+ left: var(--rs-space-7);
+}
+
+.root[data-mode="minimized"] > :not(.trigger) {
+ display: none;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ gap: var(--rs-space-3);
+ min-height: var(--rs-space-11);
+ padding: var(--rs-space-3) var(--rs-space-5);
+ border-bottom: 0.5px solid var(--rs-color-border-base-primary);
+ flex-shrink: 0;
+ box-sizing: border-box;
+}
+
+/* Gated on data-draggable (not floating mode alone) so draggable={false}
+ drops the grab affordance with the listeners. */
+.root[data-draggable] .header {
+ cursor: grab;
+ user-select: none;
+ touch-action: none;
+}
+
+.root[data-dragging] .header {
+ cursor: grabbing;
+}
+
+.title {
+ margin: 0;
+ flex: 1;
+ min-width: 0;
+ font-size: var(--rs-font-size-small);
+ line-height: var(--rs-line-height-small);
+ font-weight: var(--rs-font-weight-medium);
+ color: var(--rs-color-foreground-base-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: var(--rs-space-2);
+ margin-left: auto;
+ flex-shrink: 0;
+}
+
+.content {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+}
+
+.trigger {
+ appearance: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--rs-space-11);
+ height: var(--rs-space-11);
+ padding: 0;
+ border: 0.5px solid var(--rs-color-border-base-primary);
+ border-radius: var(--rs-radius-full);
+ background: var(--rs-color-background-base-primary);
+ color: var(--rs-color-foreground-base-primary);
+ box-shadow: var(--rs-shadow-floating);
+ cursor: pointer;
+ outline: none;
+}
+
+/* The scale applies unconditionally (an instant state change); only the
+ eased tween is motion, so only the transitions are gated. Mirrors button. */
+@media (prefers-reduced-motion: no-preference) {
+ .trigger {
+ animation: chat-panel-trigger-in var(--rs-duration-fast) var(--rs-ease-out);
+ transition: var(--rs-transition-interactive);
+ }
+
+ .trigger:active {
+ transition: var(--rs-transition-pressed);
+ }
+}
+
+@keyframes chat-panel-trigger-in {
+ from {
+ opacity: 0;
+ transform: scale(0.8);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+.trigger:hover {
+ background: var(--rs-color-background-base-primary-hover);
+}
+
+.trigger:active {
+ transform: scale(var(--rs-scale-pressed));
+}
+
+.trigger:focus-visible {
+ outline: var(--rs-focus-ring);
+}
+
+.trigger[data-draggable] {
+ touch-action: none;
+}
+
+/* The eased transform tween would lag the pointer mid-drag. */
+.trigger[data-dragging] {
+ transition: none;
+ cursor: grabbing;
+ user-select: none;
+}
+
+/* Resize handles line the floating window's edges and corners. */
+.resize-handle {
+ position: absolute;
+ z-index: 1;
+ touch-action: none;
+}
+
+.resize-n {
+ top: 0;
+ left: var(--rs-space-3);
+ right: var(--rs-space-3);
+ height: var(--rs-space-2);
+ cursor: ns-resize;
+}
+
+.resize-s {
+ bottom: 0;
+ left: var(--rs-space-3);
+ right: var(--rs-space-3);
+ height: var(--rs-space-2);
+ cursor: ns-resize;
+}
+
+.resize-e {
+ right: 0;
+ top: var(--rs-space-3);
+ bottom: var(--rs-space-3);
+ width: var(--rs-space-2);
+ cursor: ew-resize;
+}
+
+.resize-w {
+ left: 0;
+ top: var(--rs-space-3);
+ bottom: var(--rs-space-3);
+ width: var(--rs-space-2);
+ cursor: ew-resize;
+}
+
+.resize-ne {
+ top: 0;
+ right: 0;
+ width: var(--rs-space-3);
+ height: var(--rs-space-3);
+ cursor: nesw-resize;
+}
+
+.resize-nw {
+ top: 0;
+ left: 0;
+ width: var(--rs-space-3);
+ height: var(--rs-space-3);
+ cursor: nwse-resize;
+}
+
+.resize-se {
+ bottom: 0;
+ right: 0;
+ width: var(--rs-space-3);
+ height: var(--rs-space-3);
+ cursor: nwse-resize;
+}
+
+.resize-sw {
+ bottom: 0;
+ left: 0;
+ width: var(--rs-space-3);
+ height: var(--rs-space-3);
+ cursor: nesw-resize;
+}
diff --git a/packages/raystack/components/chat-panel/chat-panel.tsx b/packages/raystack/components/chat-panel/chat-panel.tsx
new file mode 100644
index 000000000..be435b507
--- /dev/null
+++ b/packages/raystack/components/chat-panel/chat-panel.tsx
@@ -0,0 +1,22 @@
+'use client';
+
+import {
+ ChatPanelActions,
+ ChatPanelContent,
+ ChatPanelExpandTrigger,
+ ChatPanelHeader,
+ ChatPanelMinimizeTrigger,
+ ChatPanelTitle
+} from './chat-panel-parts';
+import { ChatPanelRoot } from './chat-panel-root';
+import { ChatPanelTrigger } from './chat-panel-trigger';
+
+export const ChatPanel = Object.assign(ChatPanelRoot, {
+ Header: ChatPanelHeader,
+ Title: ChatPanelTitle,
+ Actions: ChatPanelActions,
+ Content: ChatPanelContent,
+ MinimizeTrigger: ChatPanelMinimizeTrigger,
+ ExpandTrigger: ChatPanelExpandTrigger,
+ Trigger: ChatPanelTrigger
+});
diff --git a/packages/raystack/components/chat-panel/index.tsx b/packages/raystack/components/chat-panel/index.tsx
new file mode 100644
index 000000000..c2da56e0a
--- /dev/null
+++ b/packages/raystack/components/chat-panel/index.tsx
@@ -0,0 +1,17 @@
+export { ChatPanel } from './chat-panel';
+export type {
+ ChatPanelMode,
+ ChatPanelSide
+} from './chat-panel-context';
+export type {
+ ChatPanelExpandTriggerProps,
+ ChatPanelExpandTriggerState
+} from './chat-panel-parts';
+export type {
+ ChatPanelDragBoundary,
+ ChatPanelPosition,
+ ChatPanelResize,
+ ChatPanelRootProps as ChatPanelProps,
+ ChatPanelSize
+} from './chat-panel-root';
+export type { ChatPanelTriggerProps } from './chat-panel-trigger';
diff --git a/packages/raystack/components/chat/__tests__/chat.test.tsx b/packages/raystack/components/chat/__tests__/chat.test.tsx
index 04e6f24f2..21da26bf9 100644
--- a/packages/raystack/components/chat/__tests__/chat.test.tsx
+++ b/packages/raystack/components/chat/__tests__/chat.test.tsx
@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { createRef } from 'react';
+import { createRef, ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { Chat } from '../chat';
import styles from '../chat.module.css';
@@ -51,6 +51,18 @@ describe('Chat', () => {
});
});
+ describe('Chat.Composer', () => {
+ it('renders its children and applies custom className', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByPlaceholderText('Reply…')).toBeInTheDocument();
+ expect(screen.getByTestId('composer')).toHaveClass('custom');
+ });
+ });
+
describe('Chat.Attachment', () => {
it('renders title and description', () => {
render( );
@@ -118,7 +130,55 @@ describe('Chat', () => {
expect(jump).toHaveAttribute('tabindex', '-1');
await user.click(jump);
});
+ });
+
+ describe('Chat.JumpButton', () => {
+ const renderJump = (jump: ReactNode) =>
+ render({jump} );
+
+ it('renders the default icon and label', () => {
+ renderJump( );
+ const jump = screen.getByTestId('jump');
+ expect(jump).toHaveTextContent('Latest');
+ expect(
+ jump.querySelector(`.${styles['jump-icon']} svg`)
+ ).toBeInTheDocument();
+ });
+
+ it('renders children as the label, keeping the icon', () => {
+ renderJump(
+ New replies
+ );
+ const jump = screen.getByTestId('jump');
+ expect(jump).toHaveTextContent('New replies');
+ expect(jump).not.toHaveTextContent('Latest');
+ expect(
+ jump.querySelector(`.${styles['jump-icon']} svg`)
+ ).toBeInTheDocument();
+ });
+
+ it('renders a custom leadingIcon', () => {
+ renderJump(
+ ↓}
+ />
+ );
+ expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('jump').querySelector(`.${styles['jump-icon']} svg`)
+ ).toBeNull();
+ });
+
+ it('removes the icon with leadingIcon={null}', () => {
+ renderJump( );
+ const jump = screen.getByTestId('jump');
+ expect(jump.querySelector(`.${styles['jump-icon']}`)).toBeNull();
+ expect(jump).toHaveTextContent('Latest');
+ });
+ });
+ describe('Chat.Messages commands', () => {
it('exposes scroll commands through actionsRef', () => {
const actionsRef = createRef();
render(
diff --git a/packages/raystack/components/chat/chat-composer.tsx b/packages/raystack/components/chat/chat-composer.tsx
new file mode 100644
index 000000000..326bfa4af
--- /dev/null
+++ b/packages/raystack/components/chat/chat-composer.tsx
@@ -0,0 +1,14 @@
+'use client';
+
+import { cx } from 'class-variance-authority';
+import { ComponentProps } from 'react';
+import styles from './chat.module.css';
+
+export interface ChatComposerProps extends ComponentProps<'div'> {}
+
+/** The container under the messages, usually holding a `PromptInput`. */
+export function ChatComposer({ className, ...props }: ChatComposerProps) {
+ return
;
+}
+
+ChatComposer.displayName = 'Chat.Composer';
diff --git a/packages/raystack/components/chat/chat-messages.tsx b/packages/raystack/components/chat/chat-messages.tsx
index 2113c595b..339d85755 100644
--- a/packages/raystack/components/chat/chat-messages.tsx
+++ b/packages/raystack/components/chat/chat-messages.tsx
@@ -6,6 +6,7 @@ import { cx } from 'class-variance-authority';
import {
ComponentProps,
MouseEvent,
+ ReactNode,
RefObject,
useCallback,
useEffect,
@@ -418,12 +419,19 @@ export function ChatMessages({
ChatMessages.displayName = 'Chat.Messages';
-export interface ChatJumpButtonProps extends ComponentProps<'button'> {}
+export interface ChatJumpButtonProps extends ComponentProps<'button'> {
+ /**
+ * Icon rendered before the label. Pass `null` to remove it.
+ * @defaultValue an arrow-down icon
+ */
+ leadingIcon?: ReactNode;
+}
export function ChatJumpButton({
className,
children,
onClick,
+ leadingIcon,
...props
}: ChatJumpButtonProps) {
const { atBottom } = useChatMessagesState('Chat.JumpButton');
@@ -446,12 +454,12 @@ export function ChatJumpButton({
onClick={handleClick}
{...props}
>
- {children ?? (
- <>
-
- Latest
- >
+ {leadingIcon !== null && (
+
+ {leadingIcon ?? }
+
)}
+ {children ?? 'Latest'}
);
}
diff --git a/packages/raystack/components/chat/chat.module.css b/packages/raystack/components/chat/chat.module.css
index 9d24f18b6..0a2171da3 100644
--- a/packages/raystack/components/chat/chat.module.css
+++ b/packages/raystack/components/chat/chat.module.css
@@ -6,7 +6,6 @@
flex: 1;
min-height: 0;
height: 100%;
- gap: var(--rs-space-3);
}
/* -------------------------------- messages -------------------------------- */
@@ -70,7 +69,7 @@
color: var(--rs-color-foreground-base-primary);
font-size: var(--rs-font-size-mini);
line-height: var(--rs-line-height-mini);
- box-shadow: var(--rs-shadow-floating);
+ box-shadow: var(--rs-shadow-lifted);
cursor: pointer;
outline: none;
opacity: 0;
@@ -110,6 +109,31 @@
outline: var(--rs-focus-ring);
}
+/* Rendered smaller than the icon's native 15px box. */
+.jump-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 12px;
+ height: 12px;
+ flex-shrink: 0;
+}
+
+.jump-icon > svg {
+ width: 100%;
+ height: 100%;
+}
+
+/* -------------------------------- composer -------------------------------- */
+
+/* Sits flush against the messages viewport; the messages' own content
+ padding provides the breathing room above it. */
+.composer {
+ flex-shrink: 0;
+ box-sizing: border-box;
+ padding: 0 var(--rs-space-3) var(--rs-space-3);
+}
+
/* -------------------------------- separator ------------------------------- */
.separator {
diff --git a/packages/raystack/components/chat/chat.tsx b/packages/raystack/components/chat/chat.tsx
index dd6d9aadf..ec40a34b0 100644
--- a/packages/raystack/components/chat/chat.tsx
+++ b/packages/raystack/components/chat/chat.tsx
@@ -4,6 +4,7 @@ import { cx } from 'class-variance-authority';
import { ComponentProps } from 'react';
import styles from './chat.module.css';
import { ChatAttachment } from './chat-attachment';
+import { ChatComposer } from './chat-composer';
import { ChatItem } from './chat-item';
import { ChatJumpButton, ChatMessages } from './chat-messages';
import { ChatSeparator } from './chat-separator';
@@ -20,6 +21,7 @@ export const Chat = Object.assign(ChatRoot, {
Messages: ChatMessages,
Item: ChatItem,
JumpButton: ChatJumpButton,
+ Composer: ChatComposer,
Separator: ChatSeparator,
Attachment: ChatAttachment
});
diff --git a/packages/raystack/components/chat/index.tsx b/packages/raystack/components/chat/index.tsx
index d998cdb29..5654f2b2b 100644
--- a/packages/raystack/components/chat/index.tsx
+++ b/packages/raystack/components/chat/index.tsx
@@ -5,3 +5,4 @@ export {
type UseChatMessagesReturn,
useChatMessages
} from './chat-context';
+export type { ChatJumpButtonProps } from './chat-messages';
diff --git a/packages/raystack/components/prompt-input/__tests__/prompt-input.test.tsx b/packages/raystack/components/prompt-input/__tests__/prompt-input.test.tsx
index a13c6941e..511b05eaa 100644
--- a/packages/raystack/components/prompt-input/__tests__/prompt-input.test.tsx
+++ b/packages/raystack/components/prompt-input/__tests__/prompt-input.test.tsx
@@ -10,7 +10,6 @@ const BasicPromptInput = (
- Skills
@@ -18,13 +17,10 @@ const BasicPromptInput = (
describe('PromptInput', () => {
describe('Basic Rendering', () => {
- it('renders the textarea, toolbar button and submit', () => {
+ it('renders the textarea and submit', () => {
render( );
expect(screen.getByPlaceholderText('Reply…')).toBeInTheDocument();
- expect(
- screen.getByRole('button', { name: 'Skills' })
- ).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Send message' })
).toBeInTheDocument();
@@ -214,11 +210,10 @@ describe('PromptInput', () => {
});
describe('Disabled state', () => {
- it('disables the textarea, buttons and submit', () => {
+ it('disables the textarea and submit', () => {
render( );
expect(screen.getByPlaceholderText('Reply…')).toBeDisabled();
- expect(screen.getByRole('button', { name: 'Skills' })).toBeDisabled();
expect(
screen.getByRole('button', { name: 'Send message' })
).toBeDisabled();
diff --git a/packages/raystack/components/prompt-input/prompt-input-parts.tsx b/packages/raystack/components/prompt-input/prompt-input-parts.tsx
index 020084304..dea8550b9 100644
--- a/packages/raystack/components/prompt-input/prompt-input-parts.tsx
+++ b/packages/raystack/components/prompt-input/prompt-input-parts.tsx
@@ -2,9 +2,7 @@
import { cx } from 'class-variance-authority';
import { ComponentProps } from 'react';
-import { Button } from '../button';
import styles from './prompt-input.module.css';
-import { usePromptInputContext } from './prompt-input-context';
export interface PromptInputHeaderProps extends ComponentProps<'div'> {}
@@ -27,30 +25,3 @@ export function PromptInputFooter({
}
PromptInputFooter.displayName = 'PromptInput.Footer';
-
-export interface PromptInputButtonProps extends ComponentProps {}
-
-export function PromptInputButton({
- className,
- variant = 'ghost',
- color = 'neutral',
- size = 'small',
- type = 'button',
- disabled,
- ...props
-}: PromptInputButtonProps) {
- const context = usePromptInputContext('Button');
- return (
-
- );
-}
-
-PromptInputButton.displayName = 'PromptInput.Button';
diff --git a/packages/raystack/components/prompt-input/prompt-input.module.css b/packages/raystack/components/prompt-input/prompt-input.module.css
index 4cd369aec..9f164f3d8 100644
--- a/packages/raystack/components/prompt-input/prompt-input.module.css
+++ b/packages/raystack/components/prompt-input/prompt-input.module.css
@@ -16,9 +16,11 @@
}
}
-/* Focus treatment matches Input's wrapper: the border tints accent. */
-.root:focus-within {
- border-color: var(--rs-color-border-accent-emphasis);
+/* The frame carries the focus treatment: the border tints only for
+ focus-visible focus (textareas match it on click too, so mouse users
+ still see it when focusing the field itself). */
+.root:has(:focus-visible) {
+ border-color: var(--rs-color-border-base-tertiary-hover);
}
.root[data-disabled] {
@@ -69,10 +71,6 @@
display: none;
}
-.button {
- flex-shrink: 0;
-}
-
.submit {
appearance: none;
display: inline-flex;
@@ -81,8 +79,8 @@
box-sizing: border-box;
flex-shrink: 0;
margin-left: auto;
- width: var(--rs-space-8);
- height: var(--rs-space-8);
+ width: var(--rs-space-7);
+ height: var(--rs-space-7);
padding: 0;
border: none;
border-radius: var(--rs-radius-full);
@@ -104,6 +102,12 @@
}
}
+/* Rendered slightly smaller than the icons' native 15px box. */
+.submit > svg {
+ width: 14px;
+ height: 14px;
+}
+
.submit:hover:not(:disabled) {
background: var(--rs-color-background-accent-emphasis-hover);
}
diff --git a/packages/raystack/components/prompt-input/prompt-input.tsx b/packages/raystack/components/prompt-input/prompt-input.tsx
index 12b386d53..307d941ae 100644
--- a/packages/raystack/components/prompt-input/prompt-input.tsx
+++ b/packages/raystack/components/prompt-input/prompt-input.tsx
@@ -1,10 +1,6 @@
'use client';
-import {
- PromptInputButton,
- PromptInputFooter,
- PromptInputHeader
-} from './prompt-input-parts';
+import { PromptInputFooter, PromptInputHeader } from './prompt-input-parts';
import { PromptInputRoot } from './prompt-input-root';
import { PromptInputSubmit } from './prompt-input-submit';
import { PromptInputTextarea } from './prompt-input-textarea';
@@ -13,6 +9,5 @@ export const PromptInput = Object.assign(PromptInputRoot, {
Textarea: PromptInputTextarea,
Header: PromptInputHeader,
Footer: PromptInputFooter,
- Button: PromptInputButton,
Submit: PromptInputSubmit
});
diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx
index de9074bf8..621d5cd5f 100644
--- a/packages/raystack/index.tsx
+++ b/packages/raystack/index.tsx
@@ -24,10 +24,24 @@ export { Callout } from './components/callout';
export {
Chat,
type ChatAttachmentState,
+ type ChatJumpButtonProps,
type ChatMessagesActions,
type UseChatMessagesReturn,
useChatMessages
} from './components/chat';
+export {
+ ChatPanel,
+ type ChatPanelDragBoundary,
+ type ChatPanelExpandTriggerProps,
+ type ChatPanelExpandTriggerState,
+ type ChatPanelMode,
+ type ChatPanelPosition,
+ type ChatPanelProps,
+ type ChatPanelResize,
+ type ChatPanelSide,
+ type ChatPanelSize,
+ type ChatPanelTriggerProps
+} from './components/chat-panel';
export { Checkbox } from './components/checkbox';
export { Chip } from './components/chip';
export { CodeBlock } from './components/code-block';
diff --git a/packages/raystack/package.json b/packages/raystack/package.json
index 0816a502f..75cb3fad9 100644
--- a/packages/raystack/package.json
+++ b/packages/raystack/package.json
@@ -115,6 +115,7 @@
"dependencies": {
"@base-ui/react": "~1.6.0",
"@base-ui/utils": "~0.3.1",
+ "@dnd-kit/core": "^6.3.1",
"@radix-ui/react-icons": "^1.3.2",
"@tanstack/match-sorter-utils": "^8.8.4",
"@tanstack/react-table": "^8.9.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b00561747..148d84356 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -181,6 +181,9 @@ importers:
'@base-ui/utils':
specifier: ~0.3.1
version: 0.3.1(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ '@dnd-kit/core':
+ specifier: ^6.3.1
+ version: 6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-icons':
specifier: ^1.3.2
version: 1.3.2(react@19.2.1)
@@ -1106,6 +1109,22 @@ packages:
'@date-fns/tz@1.2.0':
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
+ '@dnd-kit/accessibility@3.1.1':
+ resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
+ peerDependencies:
+ react: '>=16.8.0'
+
+ '@dnd-kit/core@6.3.1':
+ resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@dnd-kit/utilities@3.2.2':
+ resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
+ peerDependencies:
+ react: '>=16.8.0'
+
'@emnapi/runtime@1.7.0':
resolution: {integrity: sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==}
@@ -8037,13 +8056,13 @@ snapshots:
'@azure/abort-controller@2.1.2':
dependencies:
- tslib: 2.6.3
+ tslib: 2.8.1
'@azure/core-auth@1.9.0':
dependencies:
'@azure/abort-controller': 2.1.2
'@azure/core-util': 1.12.0
- tslib: 2.6.3
+ tslib: 2.8.1
transitivePeerDependencies:
- supports-color
@@ -8055,7 +8074,7 @@ snapshots:
'@azure/core-tracing': 1.2.0
'@azure/core-util': 1.12.0
'@azure/logger': 1.2.0
- tslib: 2.6.3
+ tslib: 2.8.1
transitivePeerDependencies:
- supports-color
@@ -8067,19 +8086,19 @@ snapshots:
'@azure/core-util': 1.12.0
'@azure/logger': 1.2.0
'@typespec/ts-http-runtime': 0.2.3
- tslib: 2.6.3
+ tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@azure/core-tracing@1.2.0':
dependencies:
- tslib: 2.6.3
+ tslib: 2.8.1
'@azure/core-util@1.12.0':
dependencies:
'@azure/abort-controller': 2.1.2
'@typespec/ts-http-runtime': 0.2.3
- tslib: 2.6.3
+ tslib: 2.8.1
transitivePeerDependencies:
- supports-color
@@ -8102,7 +8121,7 @@ snapshots:
'@azure/logger@1.2.0':
dependencies:
'@typespec/ts-http-runtime': 0.2.3
- tslib: 2.6.3
+ tslib: 2.8.1
transitivePeerDependencies:
- supports-color
@@ -8991,6 +9010,24 @@ snapshots:
'@date-fns/tz@1.2.0': {}
+ '@dnd-kit/accessibility@3.1.1(react@19.2.1)':
+ dependencies:
+ react: 19.2.1
+ tslib: 2.8.1
+
+ '@dnd-kit/core@6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
+ dependencies:
+ '@dnd-kit/accessibility': 3.1.1(react@19.2.1)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.1)
+ react: 19.2.1
+ react-dom: 19.2.1(react@19.2.1)
+ tslib: 2.8.1
+
+ '@dnd-kit/utilities@3.2.2(react@19.2.1)':
+ dependencies:
+ react: 19.2.1
+ tslib: 2.8.1
+
'@emnapi/runtime@1.7.0':
dependencies:
tslib: 2.8.1
@@ -12104,7 +12141,7 @@ snapshots:
aria-hidden@1.2.4:
dependencies:
- tslib: 2.6.3
+ tslib: 2.8.1
aria-query@5.1.3:
dependencies: