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 index 3e34072f0..4244b4107 100644 --- a/apps/www/src/content/docs/ai-elements/chat-panel/demo.ts +++ b/apps/www/src/content/docs/ai-elements/chat-panel/demo.ts @@ -64,7 +64,7 @@ export const preview = { {message.role === 'user' ? ( {message.text} ) : ( - {message.text} + {message.text} )} @@ -151,6 +151,57 @@ export const controlledDemo = { }` }; +export const transitionDemo = { + type: 'code', + code: `function ChatPanelTransitions() { + const [transition, setTransition] = React.useState('morph'); + const [mode, setMode] = React.useState('docked'); + + return ( + + + + {['minimal', 'morph'].map(type => ( + + ))} + + + {['docked', 'floating', 'minimized'].map(next => ( + + ))} + + + + + + Switch modes with either transition to compare them. Morph tweens + the panel out of the box it just left; minimal eases it in from its + own edge. + + + + + Assistant + + + + + + + + + transition: {transition} + + + + + + + + ); +}` +}; + export const boundedDragDemo = { type: 'code', code: `function BoundedDragChatPanel() { 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 index cfdfe71df..7c105f172 100644 --- a/apps/www/src/content/docs/ai-elements/chat-panel/index.mdx +++ b/apps/www/src/content/docs/ai-elements/chat-panel/index.mdx @@ -5,7 +5,7 @@ source: packages/raystack/components/chat-panel tag: new --- -import { preview, controlledDemo, boundedDragDemo, resizeDemo, draggableBubbleDemo, unreadBadgeDemo } from "./demo.ts"; +import { preview, controlledDemo, transitionDemo, boundedDragDemo, resizeDemo, draggableBubbleDemo, unreadBadgeDemo } from "./demo.ts"; @@ -31,9 +31,9 @@ import { ChatPanel } from '@raystack/apsara' ``` -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: +The panel mounts **once** in your layout and switches between modes in place — +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. @@ -95,12 +95,44 @@ height. ### Trigger -The minimized-state corner bubble. Children replace the default chat icon, +The minimized-state corner bubble. Children replace the default `CoPilotIcon`, and `draggable` lets the bubble be dragged around the viewport — a completed drag never triggers the restore click. +## Mode transitions + +Switching mode flips the panel between in-flow and `position: fixed`, which no +CSS property can tween, so the transform bridges the gap: the panel is rendered +in its new mode already holding the shape it is coming out of, then eased back +to rest. `transition` picks which shape that is. + +**`minimal`** (default) brings the new mode in from its own edge or corner and +fades it up: the docked sidebar slides in from the edge it docks to, the +floating window scales and lifts out of its corner, and the bubble grows out of +its own. `--rs-duration-normal`. + +**`morph`** measures the box the panel is leaving and draws the new mode back +over it — an inverse `translate` and `scale` off its top-left corner — so the +panel travels and reshapes into its new place, the way a shared-layout +animation does. Docking and popping out morph outright. The bubble is a tenth +of the panel's size, so its two pairs cross-fade as well, and the bubble itself +only travels — scaling it up to the panel's box would stretch its icon for the +length of the tween. `--rs-duration-moderate`, because there is real distance to +cover. + +Both use `--rs-ease-out`, so a transition is off the mark on its first frame and +settles into place rather than easing away from a standstill. + + + +Both are skipped under `prefers-reduced-motion: reduce`, and neither runs on +first paint — the transition needs a mode to have changed. Only the individual +transform properties (`scale`, `translate`) are transitioned, never `transform` +itself: dragging writes an inline `transform`, which stays instant, so a drag +never lags the pointer and ending one never replays the transition. + ## Examples ### Controlled mode 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 index 30f16b75a..4d98ca0b0 100644 --- a/apps/www/src/content/docs/ai-elements/chat-panel/props.ts +++ b/apps/www/src/content/docs/ai-elements/chat-panel/props.ts @@ -87,6 +87,14 @@ export interface ChatPanelProps { */ dragBoundary?: HTMLElement | React.RefObject; + /** + * How a mode change animates. `"minimal"` eases the new mode in from its own + * edge or corner; `"morph"` measures the box the panel is leaving and tweens + * the new mode out of it, so the panel moves and reshapes into place. + * @defaultValue "minimal" + */ + transition?: 'minimal' | 'morph'; + /** 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 c0783ab4c..a3785adc4 100644 --- a/apps/www/src/content/docs/ai-elements/chat/demo.ts +++ b/apps/www/src/content/docs/ai-elements/chat/demo.ts @@ -25,7 +25,7 @@ export const preview = { - I created the task in Design System 2 and assigned it to you. + I created the task in Design System 2 and assigned it to you. @@ -102,7 +102,7 @@ export const streamingDemo = { {message.align === 'end' ? ( {message.text} ) : ( - {message.text} + {message.text} )} diff --git a/apps/www/src/content/docs/ai-elements/message/demo.ts b/apps/www/src/content/docs/ai-elements/message/demo.ts index 6f44dc631..cf8679fec 100644 --- a/apps/www/src/content/docs/ai-elements/message/demo.ts +++ b/apps/www/src/content/docs/ai-elements/message/demo.ts @@ -106,7 +106,43 @@ export const bubbleVariantsDemo = { Bordered bubble on the base background. Accent outline bubble. Danger outline bubble. +` + }, + { + name: 'Ghost', + code: ` + No surface at all — plain body copy. + Accent ghost bubble. + Danger ghost bubble. ` } ] }; + +export const ghostDemo = { + type: 'code', + code: ` + + + Summarise the release notes for me. + + + + + + + Assistant + + + Three things shipped: the timeline renderer, the AI element set, and a + rewrite of the token docs. The ghost bubble spans the whole row, so + long replies read as page content rather than as a chat bubble. + + + 1:52 AM + + + + +` +}; diff --git a/apps/www/src/content/docs/ai-elements/message/index.mdx b/apps/www/src/content/docs/ai-elements/message/index.mdx index cfb89efb5..c8a96bf80 100644 --- a/apps/www/src/content/docs/ai-elements/message/index.mdx +++ b/apps/www/src/content/docs/ai-elements/message/index.mdx @@ -5,7 +5,7 @@ source: packages/raystack/components/message tag: new --- -import { preview, anatomyDemo, groupDemo, bubbleVariantsDemo } from "./demo.ts"; +import { preview, anatomyDemo, groupDemo, bubbleVariantsDemo, ghostDemo } from "./demo.ts"; @@ -72,11 +72,21 @@ appear once. ### Bubble variants -`variant` picks the surface treatment (`solid` or `outline`) and `color` the -palette (`neutral`, `accent` or `danger`). +`variant` picks the surface treatment (`solid`, `outline` or `ghost`) and +`color` the palette (`neutral`, `accent` or `danger`). +### Ghost bubbles + +`variant="ghost"` removes the surface — no background, border or padding — and +the bubble runs the full width of the message row instead of shrink-wrapping. +It renders as plain body copy (Text at `size="small"`), which is the +conventional treatment for assistant replies: the reader's own messages keep a +bubble, the assistant's read as page content. + + + ## Accessibility - `Message.Actions` reveal on hover **and** on focus-within, and stay visible diff --git a/apps/www/src/content/docs/ai-elements/message/props.ts b/apps/www/src/content/docs/ai-elements/message/props.ts index b1f055b9d..0e51e63e4 100644 --- a/apps/www/src/content/docs/ai-elements/message/props.ts +++ b/apps/www/src/content/docs/ai-elements/message/props.ts @@ -13,10 +13,12 @@ export interface MessageProps { export interface MessageBubbleProps { /** - * Visual style of the message surface. + * Visual style of the message surface. `"ghost"` drops the surface + * entirely — no background, border or padding — and renders the message as + * full-width body copy. * @defaultValue "solid" */ - variant?: 'solid' | 'outline'; + variant?: 'solid' | 'outline' | 'ghost'; /** * Color of the message surface. 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 432f924aa..ac6348a43 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 @@ -39,7 +39,13 @@ anything else. ### Root -The `
` that holds the value and submit semantics. +The `` that holds the value and submit semantics. The whole frame reads +as one field: pressing its dead space — the header and footer padding, or the +gap between their controls — focuses the input, while anything you put in those +slots keeps its own press. (The slots sit outside hit testing, so those presses +land on the form, which does the focusing.) `PromptInput.Textarea` registers +itself as that input on mount; pass `inputRef` when you render a custom input +instead. A consumer `onMouseDown` that calls `preventDefault()` opts out. @@ -120,3 +126,6 @@ menus, mention pickers. - 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. +- Click-to-focus runs on `mousedown` and cancels the default focus shift, so + focus never leaves the input and back again — a round trip that would dismiss + anything anchored to it. Keyboard focus order is untouched. 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 3a59a9154..62d20aa41 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 @@ -40,6 +40,13 @@ export interface PromptInputProps { */ disabled?: boolean; + /** + * The element the frame focuses when its own padding is clicked. + * `PromptInput.Textarea` registers itself here on mount; pass a ref of your + * own when you render a custom input instead. Whichever is set first wins. + */ + inputRef?: React.RefObject; + /** 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 index 1ecf62231..f22b0b90d 100644 --- a/packages/raystack/components/chat-panel/__tests__/chat-panel.test.tsx +++ b/packages/raystack/components/chat-panel/__tests__/chat-panel.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { ChatPanel } from '../chat-panel'; @@ -240,6 +240,149 @@ describe('ChatPanel', () => { }); }); + describe('Mode transitions', () => { + // The morph measures the panel in both modes; jsdom reports zero rects. + const MORPH_BOXES: Record = { + docked: mockRect(640, 0, 360, 800), + floating: mockRect(500, 200, 400, 560), + minimized: mockRect(956, 700, 44, 44) + }; + + const mockModeGeometry = () => + vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + const mode = this.dataset.mode; + return (mode && MORPH_BOXES[mode]) || mockRect(0, 0, 0, 0); + }); + + it('animates minimally by default', () => { + render(); + expect(screen.getByTestId('panel')).toHaveAttribute( + 'data-transition', + 'minimal' + ); + }); + + it('does not apply the starting style on first paint', () => { + render(); + expect(screen.getByTestId('panel')).not.toHaveAttribute( + 'data-mode-starting' + ); + }); + + it('applies the starting style when the mode changes', () => { + render(); + + fireEvent.click( + screen.getByRole('button', { name: 'Pop out chat panel' }) + ); + + const panel = screen.getByTestId('panel'); + expect(panel).toHaveAttribute('data-mode', 'floating'); + expect(panel).toHaveAttribute('data-mode-starting'); + expect(panel).toHaveAttribute('data-mode-from', 'docked'); + }); + + it('applies the starting style for a controlled mode change too', () => { + const { rerender } = render(); + expect(screen.getByTestId('panel')).not.toHaveAttribute( + 'data-mode-starting' + ); + + rerender(); + expect(screen.getByTestId('panel')).toHaveAttribute('data-mode-starting'); + }); + + it('drops the starting style on the next frame, so the rest tweens', async () => { + const { rerender } = render(); + rerender(); + + await waitFor(() => + expect(screen.getByTestId('panel')).not.toHaveAttribute( + 'data-mode-starting' + ) + ); + expect(screen.getByTestId('panel')).toHaveAttribute( + 'data-mode', + 'floating' + ); + }); + + it('leaves the transform alone with the minimal transition', () => { + const rectSpy = mockModeGeometry(); + try { + render(); + fireEvent.click( + screen.getByRole('button', { name: 'Pop out chat panel' }) + ); + const panel = screen.getByTestId('panel'); + expect(panel.style.translate).toBe(''); + expect(panel.style.scale).toBe(''); + } finally { + rectSpy.mockRestore(); + } + }); + + it('morphs out of the box the panel just left', () => { + const rectSpy = mockModeGeometry(); + try { + render(); + fireEvent.click( + screen.getByRole('button', { name: 'Pop out chat panel' }) + ); + + // The floating panel is drawn back over the docked box it left: + // offset by (640 - 500, 0 - 200) and scaled by 360/400 by 800/560. + const panel = screen.getByTestId('panel'); + expect(panel).toHaveAttribute('data-transition', 'morph'); + expect(panel.style.translate).toBe('140px -200px'); + const [x, y] = panel.style.scale.split(' ').map(Number); + expect(x).toBeCloseTo(0.9); + expect(y).toBeCloseTo(1.4286); + } finally { + rectSpy.mockRestore(); + } + }); + + it('releases the morph transform on the next frame', async () => { + const rectSpy = mockModeGeometry(); + try { + render(); + fireEvent.click( + screen.getByRole('button', { name: 'Pop out chat panel' }) + ); + const panel = screen.getByTestId('panel'); + expect(panel.style.scale).not.toBe(''); + + await waitFor(() => expect(panel.style.scale).toBe('')); + expect(panel.style.translate).toBe(''); + } finally { + rectSpy.mockRestore(); + } + }); + + it('travels the minimized bubble without scaling it', () => { + const rectSpy = mockModeGeometry(); + try { + render(); + fireEvent.click( + screen.getByRole('button', { name: 'Minimize chat panel' }) + ); + + // The bubble starts over the middle of the docked box it replaces — + // centre (820, 400) against its own (978, 722) — at its own size, so + // its icon is never stretched. + const panel = screen.getByTestId('panel'); + expect(panel).toHaveAttribute('data-mode-from', 'docked'); + expect(panel.style.translate).toBe('-158px -322px'); + expect(panel.style.scale).toBe(''); + } finally { + rectSpy.mockRestore(); + } + }); + }); + describe('Floating mode', () => { it('applies the floating size as inline style', () => { render( @@ -654,6 +797,17 @@ describe('ChatPanel', () => { ); expect(screen.getByTestId('badge')).toBeInTheDocument(); }); + + it('falls back to the co-pilot icon', () => { + render(); + const icon = screen.getByTestId('bubble').querySelector('svg'); + expect(icon).not.toBeNull(); + expect(icon).toHaveAttribute('aria-hidden', 'true'); + expect(icon?.querySelector('path')).toHaveAttribute( + 'fill', + 'currentColor' + ); + }); }); describe('Draggable bubble', () => { diff --git a/packages/raystack/components/chat-panel/chat-panel-root.tsx b/packages/raystack/components/chat-panel/chat-panel-root.tsx index 9d80eb0b5..baf6189d0 100644 --- a/packages/raystack/components/chat-panel/chat-panel-root.tsx +++ b/packages/raystack/components/chat-panel/chat-panel-root.tsx @@ -1,6 +1,7 @@ 'use client'; import { useControlled } from '@base-ui/utils/useControlled'; +import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { DndContext, type DragEndEvent, @@ -57,6 +58,9 @@ 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'; +/** How a mode change animates. */ +export type ChatPanelTransition = 'minimal' | 'morph'; + const RESIZE_HANDLES: Record = { both: ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'], horizontal: ['e', 'w'], @@ -82,6 +86,86 @@ function resolveDragBoundary( return 'current' in boundary ? boundary.current : boundary; } +/** + * The mode the panel is leaving, for the one frame that renders the new mode so + * there are values to transition *from*. Null on first paint and once settled. + */ +function useModeChange(mode: ChatPanelMode) { + const [settledMode, setSettledMode] = useState(mode); + const from = settledMode === mode ? null : settledMode; + + useEffect(() => { + if (!from) return; + const frame = requestAnimationFrame(() => setSettledMode(mode)); + return () => cancelAnimationFrame(frame); + }, [from, mode]); + + return from; +} + +interface MorphBox { + x: number; + y: number; + width: number; + height: number; +} + +function readBox(node: HTMLElement): MorphBox { + const { left, top, width, height } = node.getBoundingClientRect(); + return { x: left, y: top, width, height }; +} + +/** + * Draws the panel back over the box it just left — an inverse translate and + * scale off its top-left corner — and lets go a frame later, so the two modes + * read as one shape moving. The old box has to be read while the DOM still + * shows the old mode, hence the measurement in render; the inverse can only be + * worked out once the new mode has laid out, hence the layout effect. + */ +function useMorph( + panelRef: RefObject, + mode: ChatPanelMode, + starting: boolean, + enabled: boolean +) { + const measuredModeRef = useRef(mode); + const fromRef = useRef(null); + + if (measuredModeRef.current !== mode) { + measuredModeRef.current = mode; + const panel = panelRef.current; + fromRef.current = enabled && panel ? readBox(panel) : null; + } + + useIsoLayoutEffect(() => { + const panel = panelRef.current; + if (!panel) return; + const from = fromRef.current; + if (!starting || !from) { + panel.style.translate = ''; + panel.style.scale = ''; + return; + } + const to = readBox(panel); + if (!to.width || !to.height) return; + // Minimized is the one mode whose whole content is a single small control. + // Blowing the bubble up to the panel's box would stretch its icon into mush + // for the length of the tween, so it only travels — from the middle of the + // box it is replacing, since there is no shape left to line its edges up + // with. + if (mode === 'minimized') { + const x = from.x + from.width / 2 - (to.x + to.width / 2); + const y = from.y + from.height / 2 - (to.y + to.height / 2); + panel.style.translate = `${x}px ${y}px`; + return; + } + panel.style.translate = `${from.x - to.x}px ${from.y - to.y}px`; + panel.style.scale = `${from.width / to.width} ${from.height / to.height}`; + // `mode` is a dependency so a second change mid-tween re-inverts from + // wherever the panel has visually reached instead of leaving it stranded. + }, [panelRef, starting, mode]); +} + /** * A PointerSensor that never starts a drag from interactive children. The * minimized bubble is the one draggable button: its listeners are only @@ -166,6 +250,13 @@ export interface ChatPanelRootProps extends ComponentProps<'aside'> { * viewport. Accepts the element or a ref to it. */ dragBoundary?: ChatPanelDragBoundary; + /** + * How a mode change animates. `"minimal"` eases the new mode in from its own + * edge or corner; `"morph"` measures the box the panel is leaving and tweens + * the new mode out of it, so the panel moves and reshapes into place. + * @defaultValue "minimal" + */ + transition?: ChatPanelTransition; } export function ChatPanelRoot({ @@ -187,6 +278,7 @@ export function ChatPanelRoot({ resize = 'both', draggable = true, dragBoundary, + transition = 'minimal', ref, ...props }: ChatPanelRootProps) { @@ -225,6 +317,9 @@ export function ChatPanelRoot({ // defaultSize never contradicts its own max. const initialSizeRef = useRef(sizeProp ?? defaultSize ?? DEFAULT_SIZE); + const modeFrom = useModeChange(mode); + useMorph(panelRef, mode, modeFrom !== null, transition === 'morph'); + const modeRef = useRef(mode); modeRef.current = mode; const positionRef = useRef(position); @@ -641,6 +736,8 @@ export function ChatPanelRoot({ side={side} draggable={draggable} resizing={resizing} + transition={transition} + modeFrom={modeFrom} floatingStyle={floatingStyle} className={className} style={style} @@ -681,6 +778,8 @@ interface ChatPanelFrameProps extends ComponentProps<'aside'> { side: ChatPanelSide; draggable: boolean; resizing: boolean; + transition: ChatPanelTransition; + modeFrom: ChatPanelMode | null; floatingStyle: CSSProperties | null; resizeHandles: ReactNode; } @@ -695,6 +794,8 @@ function ChatPanelFrame({ side, draggable, resizing, + transition, + modeFrom, floatingStyle, resizeHandles, className, @@ -733,6 +834,9 @@ function ChatPanelFrame({ data-draggable={dragEnabled || undefined} data-dragging={isDragging || undefined} data-resizing={resizing || undefined} + data-transition={transition} + data-mode-starting={modeFrom ? '' : undefined} + data-mode-from={modeFrom ?? undefined} style={{ ...floatingStyle, // The committed position only updates when the drag ends; the live diff --git a/packages/raystack/components/chat-panel/chat-panel-trigger.tsx b/packages/raystack/components/chat-panel/chat-panel-trigger.tsx index c155d4180..422feb7de 100644 --- a/packages/raystack/components/chat-panel/chat-panel-trigger.tsx +++ b/packages/raystack/components/chat-panel/chat-panel-trigger.tsx @@ -1,7 +1,6 @@ 'use client'; import { useDraggable } from '@dnd-kit/core'; -import { ChatBubbleIcon } from '@radix-ui/react-icons'; import { cx } from 'class-variance-authority'; import { ComponentProps, @@ -10,6 +9,7 @@ import { useEffect, useRef } from 'react'; +import { ReactComponent as CoPilotIcon } from '../../icons/assets/co-pilot.svg'; import styles from './chat-panel.module.css'; import { useChatPanelContext } from './chat-panel-context'; @@ -103,7 +103,7 @@ export function ChatPanelTrigger({ }} {...props} > - {children ??