From e09cf058a385add8caa8cc7aacb6d8407ed98a62 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 28 Jul 2026 15:42:29 +0530 Subject: [PATCH 1/4] feat: add ghost variant to Message.Bubble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ghost bubble drops the surface entirely — no background, border, padding or radius — and runs the full width of the message row instead of shrink-wrapping, which is the conventional treatment for assistant replies. It composes textVariants({ size: 'small' }) so it renders as literal Text output rather than a lookalike; `color` maps onto Text's colours. The grid widens via :has(), swapping the content column's fit-content(85%) for minmax(0, 1fr) and dropping the flexible spacer so hover actions land at the row's far edge. The Chat and ChatPanel examples now use it in place of the bare Text they were reaching for. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/ai-elements/chat-panel/demo.ts | 2 +- .../src/content/docs/ai-elements/chat/demo.ts | 4 +- .../content/docs/ai-elements/message/demo.ts | 36 ++++++++++++++ .../docs/ai-elements/message/index.mdx | 16 +++++-- .../content/docs/ai-elements/message/props.ts | 6 ++- .../message/__tests__/message.test.tsx | 47 ++++++++++++++++++- .../components/message/message-bubble.tsx | 31 ++++++++++-- .../components/message/message.module.css | 30 ++++++++++++ 8 files changed, 159 insertions(+), 13 deletions(-) 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..819afd71d 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} )} 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/packages/raystack/components/message/__tests__/message.test.tsx b/packages/raystack/components/message/__tests__/message.test.tsx index 7258c4fff..188f2e702 100644 --- a/packages/raystack/components/message/__tests__/message.test.tsx +++ b/packages/raystack/components/message/__tests__/message.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react'; import { createRef } from 'react'; import { describe, expect, it } from 'vitest'; +import textStyles from '../../text/text.module.css'; import { Message } from '../message'; import styles from '../message.module.css'; @@ -80,7 +81,10 @@ describe('Message', () => { ['solid', 'danger'], ['outline', 'accent'], ['outline', 'neutral'], - ['outline', 'danger'] + ['outline', 'danger'], + ['ghost', 'accent'], + ['ghost', 'neutral'], + ['ghost', 'danger'] ] as const)('renders the %s %s appearance', (variant, color) => { render( @@ -93,5 +97,46 @@ describe('Message', () => { expect(bubble).toHaveClass(styles[`bubble-${variant}`]); expect(bubble).toHaveClass(styles[`bubble-${color}`]); }); + + it('renders the ghost variant as small Text', () => { + render( + + text + + ); + const bubble = screen.getByTestId('bubble'); + expect(bubble).toHaveClass(textStyles.text); + expect(bubble).toHaveClass(textStyles['text-small']); + expect(bubble).toHaveClass(textStyles['text-primary']); + }); + + it.each([ + ['accent', 'text-accent'], + ['danger', 'text-danger'] + ] as const)('maps ghost %s onto the %s Text colour', (color, textClass) => { + render( + + text + + ); + expect(screen.getByTestId('bubble')).toHaveClass(textStyles[textClass]); + }); + + it('leaves the solid variant free of Text classes', () => { + render(text); + expect(screen.getByTestId('bubble')).not.toHaveClass(textStyles.text); + }); + + it('keeps a caller className last so it can override the ghost resets', () => { + render( + + text + + ); + const bubble = screen.getByTestId('bubble'); + const classes = bubble.className.split(' '); + expect(classes[classes.length - 1]).toBe('custom'); + expect(classes).toContain(styles['bubble-ghost']); + }); }); }); diff --git a/packages/raystack/components/message/message-bubble.tsx b/packages/raystack/components/message/message-bubble.tsx index 753017729..4c12962ef 100644 --- a/packages/raystack/components/message/message-bubble.tsx +++ b/packages/raystack/components/message/message-bubble.tsx @@ -2,13 +2,15 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { ComponentProps } from 'react'; +import { textVariants } from '../text/text'; import styles from './message.module.css'; const bubble = cva(styles.bubble, { variants: { variant: { solid: styles['bubble-solid'], - outline: styles['bubble-outline'] + outline: styles['bubble-outline'], + ghost: styles['bubble-ghost'] }, color: { accent: styles['bubble-accent'], @@ -22,14 +24,24 @@ const bubble = cva(styles.bubble, { } }); +// A ghost bubble has no surface, so it is plain body copy: it borrows Text's +// small-size typography and colour instead of the surface token pairs below. +const GHOST_TEXT_VARIANT = { + neutral: 'primary', + accent: 'accent', + danger: 'danger' +} as const; + export interface MessageBubbleProps extends ComponentProps<'div'>, VariantProps { /** - * 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. * @defaultValue "neutral" @@ -43,11 +55,22 @@ export function MessageBubble({ color = 'neutral', ...props }: MessageBubbleProps) { + const bubbleClassName = bubble({ variant, color, className }); + return (
); diff --git a/packages/raystack/components/message/message.module.css b/packages/raystack/components/message/message.module.css index f58884ca8..c17e1d678 100644 --- a/packages/raystack/components/message/message.module.css +++ b/packages/raystack/components/message/message.module.css @@ -31,6 +31,25 @@ justify-items: end; } +/* A ghost bubble is full-bleed, so the content column stops shrink-wrapping + at 85% and the flexible spacer goes away: the message runs the whole row + and the hover actions sit at its far edge. */ +.message:has(.bubble-ghost) { + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-areas: + ". header ." + "avatar content actions" + ". footer ."; +} + +.message[data-align="end"]:has(.bubble-ghost) { + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-areas: + ". header ." + "actions content avatar" + ". footer ."; +} + /* The avatar area only spans the content row, so flex-end anchors it to the bubble's bottom edge rather than to the footer line below it. */ .message-avatar { @@ -162,3 +181,14 @@ border-color: var(--rs-color-border-danger-emphasis); color: var(--rs-color-foreground-danger-primary); } + +/* Ghost drops the surface: no fill, no border, no padding, and the bubble + fills the (widened) content column instead of shrink-wrapping. Typography + and colour come from Text — see GHOST_TEXT_VARIANT in message-bubble.tsx. */ +.bubble-ghost { + width: 100%; + padding: 0; + border: none; + border-radius: 0; + background: none; +} From 8769afd75a3e3431749268fe845371b4cd135b94 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 28 Jul 2026 15:42:37 +0530 Subject: [PATCH 2/4] feat: focus the PromptInput textarea from the frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer reads as one field, so clicking its dead space — the padding and the gaps around the header and footer slots — now focuses the textarea, with `cursor: text` to advertise it. Handled on mousedown with preventDefault() rather than on click: focus never leaves the textarea and come back, a round trip that would dismiss anything anchored to it. Interactive targets, secondary mouse buttons, the disabled state and a consumer onMouseDown that calls preventDefault() all opt out. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/ai-elements/prompt-input/index.mdx | 9 ++- .../__tests__/prompt-input.test.tsx | 81 +++++++++++++++++++ .../prompt-input/prompt-input-root.tsx | 38 ++++++++- .../prompt-input/prompt-input.module.css | 4 + 4 files changed, 130 insertions(+), 2 deletions(-) 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..81b00fd54 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,11 @@ 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: clicking its dead space — the padding, or the gaps around the +header and footer slots — focuses the textarea. Controls inside the frame keep +their own behaviour, and a consumer `onMouseDown` that calls `preventDefault()` +opts out. @@ -120,3 +124,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 textarea and back again — a round trip that would + dismiss anything anchored to it. Keyboard focus order is untouched. 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 511b05eaa..7045467d3 100644 --- a/packages/raystack/components/prompt-input/__tests__/prompt-input.test.tsx +++ b/packages/raystack/components/prompt-input/__tests__/prompt-input.test.tsx @@ -219,4 +219,85 @@ describe('PromptInput', () => { ).toBeDisabled(); }); }); + + describe('Click to focus', () => { + it('focuses the textarea when the frame is clicked', () => { + const { container } = render(); + const form = container.querySelector('form') as HTMLFormElement; + + fireEvent.mouseDown(form); + + expect(screen.getByPlaceholderText('Reply…')).toHaveFocus(); + }); + + it('focuses the textarea from a layout slot', () => { + render(); + + fireEvent.mouseDown(screen.getByTestId('footer')); + + expect(screen.getByPlaceholderText('Reply…')).toHaveFocus(); + }); + + it('prevents the default focus shift so the caret is not dropped', () => { + const { container } = render(); + const form = container.querySelector('form') as HTMLFormElement; + + const notCancelled = fireEvent.mouseDown(form); + + expect(notCancelled).toBe(false); + }); + + it('leaves controls inside the frame alone', () => { + render(); + const submit = screen.getByRole('button', { name: 'Send message' }); + + const notCancelled = fireEvent.mouseDown(submit); + + expect(notCancelled).toBe(true); + expect(screen.getByPlaceholderText('Reply…')).not.toHaveFocus(); + }); + + it('leaves the textarea itself alone so the caret lands where clicked', () => { + render(); + + const notCancelled = fireEvent.mouseDown( + screen.getByPlaceholderText('Reply…') + ); + + expect(notCancelled).toBe(true); + }); + + it('does nothing while disabled', () => { + const { container } = render(); + const form = container.querySelector('form') as HTMLFormElement; + + fireEvent.mouseDown(form); + + expect(screen.getByPlaceholderText('Reply…')).not.toHaveFocus(); + }); + + it('ignores secondary buttons so the context menu still opens', () => { + const { container } = render(); + const form = container.querySelector('form') as HTMLFormElement; + + const notCancelled = fireEvent.mouseDown(form, { button: 2 }); + + expect(notCancelled).toBe(true); + expect(screen.getByPlaceholderText('Reply…')).not.toHaveFocus(); + }); + + it('yields to a consumer that prevents the mousedown', () => { + const onMouseDown = vi.fn(event => event.preventDefault()); + const { container } = render( + + + + ); + + fireEvent.mouseDown(container.querySelector('form') as HTMLFormElement); + + expect(onMouseDown).toHaveBeenCalled(); + expect(screen.getByPlaceholderText('Reply…')).not.toHaveFocus(); + }); + }); }); diff --git a/packages/raystack/components/prompt-input/prompt-input-root.tsx b/packages/raystack/components/prompt-input/prompt-input-root.tsx index 61c510d55..38a0b57e3 100644 --- a/packages/raystack/components/prompt-input/prompt-input-root.tsx +++ b/packages/raystack/components/prompt-input/prompt-input-root.tsx @@ -2,7 +2,14 @@ import { useControlled } from '@base-ui/utils/useControlled'; import { cx } from 'class-variance-authority'; -import { ComponentProps, FormEvent, useCallback, useMemo, useRef } from 'react'; +import { + ComponentProps, + FormEvent, + MouseEvent, + useCallback, + useMemo, + useRef +} from 'react'; import styles from './prompt-input.module.css'; import { PromptInputContext, @@ -46,6 +53,13 @@ export interface PromptInputRootProps disabled?: boolean; } +/** + * Targets that own the click: their own focus (or activation) must not be + * hijacked by the frame's click-to-focus. + */ +const INTERACTIVE_TARGET = + 'button, a[href], input, textarea, select, label, [contenteditable="true"], [tabindex]:not([tabindex="-1"])'; + export function PromptInputRoot({ className, value: valueProp, @@ -54,6 +68,7 @@ export function PromptInputRoot({ onSubmit, onStop, onReset, + onMouseDown, status = 'idle', disabled = false, children, @@ -107,6 +122,26 @@ export function PromptInputRoot({ setValue(''); }; + // The whole frame reads as one field, so its dead space (padding, the + // header/footer gaps) focuses the textarea. Handled on mousedown rather + // than click so focus never leaves the textarea in the first place — + // a blur/refocus round trip would close anything anchored to it. + const handleMouseDown = (event: MouseEvent) => { + onMouseDown?.(event); + if (event.defaultPrevented || disabled) return; + // Secondary buttons open the context menu / paste; leave them be. + if (event.button !== 0) return; + const target = event.target as HTMLElement; + // Controls (and the textarea itself) keep their native behaviour. + if (target.closest(INTERACTIVE_TARGET)) return; + const textarea = textareaRef.current; + if (!textarea || textarea.disabled) return; + // Suppresses the default focus shift to the frame, which would blur the + // textarea and drop the caret. + event.preventDefault(); + textarea.focus(); + }; + const contextValue = useMemo( () => ({ value, @@ -134,6 +169,7 @@ export function PromptInputRoot({ data-empty={value.trim() === '' || undefined} onSubmit={handleSubmit} onReset={handleReset} + onMouseDown={handleMouseDown} {...props} > {children} diff --git a/packages/raystack/components/prompt-input/prompt-input.module.css b/packages/raystack/components/prompt-input/prompt-input.module.css index 9f164f3d8..5b31bbea6 100644 --- a/packages/raystack/components/prompt-input/prompt-input.module.css +++ b/packages/raystack/components/prompt-input/prompt-input.module.css @@ -8,6 +8,9 @@ background: var(--rs-color-background-base-primary); border: 0.5px solid var(--rs-color-border-base-tertiary); border-radius: var(--rs-radius-4); + /* Clicking the frame's dead space focuses the textarea, so the whole thing + reads as one field. Controls inside re-assert their own cursor. */ + cursor: text; } @media (prefers-reduced-motion: no-preference) { @@ -25,6 +28,7 @@ .root[data-disabled] { opacity: 0.5; + cursor: default; } .header { From a955a92dec6f200d208a6b318a1b1bcb5443c304 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 28 Jul 2026 15:42:51 +0530 Subject: [PATCH 3/4] feat: animate ChatPanel mode changes, add CoPilotIcon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Co-pilot sparkle from the Figma icons file as CoPilotIcon and use it as the default glyph for the minimized ChatPanel bubble, replacing Radix's ChatBubbleIcon. Reusing an icons/ asset inside a component needs @svgr/rollup wired into the vitest config as a `pre` plugin, otherwise Vite resolves the .svg to a URL string and the named ReactComponent export is undefined in tests; the rollup build already had it. Mode changes flip the panel between in-flow and position: fixed, which no property can tween, so each mode now plays a transform + opacity entrance at --rs-duration-normal / --rs-ease-out: docked settles onto its edge, floating scales and lifts, the bubble pops in (retimed from fast to match). The docked slide runs inward rather than as a full-width drawer slide from off-edge — a transform still grows the nearest scroll container's scroll area, so sliding outward would flash a horizontal scrollbar in any layout that doesn't clip overflow. A data-mode-changed attribute keeps first paint static, and the floating entrance stands down while a drag or resize is in flight so it never fights the live dnd-kit transform. Everything is gated on prefers-reduced-motion. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/ai-elements/chat-panel/index.mdx | 20 +++++- .../chat-panel/__tests__/chat-panel.test.tsx | 56 +++++++++++++++ .../components/chat-panel/chat-panel-root.tsx | 11 +++ .../chat-panel/chat-panel-trigger.tsx | 4 +- .../chat-panel/chat-panel.module.css | 72 ++++++++++++++++++- packages/raystack/icons/assets/co-pilot.svg | 5 ++ packages/raystack/icons/index.tsx | 1 + packages/raystack/vitest.config.mjs | 7 +- 8 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 packages/raystack/icons/assets/co-pilot.svg 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..2585c84db 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 @@ -95,12 +95,30 @@ 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 each mode plays a short transform entrance instead +of a continuous morph: the docked panel settles onto its edge, the floating +window scales and lifts into place, and the minimized bubble pops in. All three +run at `--rs-duration-normal` with `--rs-ease-out`, and all three are skipped +under `prefers-reduced-motion: reduce`. + +The docked slide deliberately runs *inward* rather than as a full-width drawer +slide from off-edge: a transform still grows the scroll area of the nearest +scroll container, so sliding outward would flash a horizontal scrollbar in any +layout that doesn't clip its overflow. + +The entrances only play once the panel has actually changed mode — a panel that +renders docked or minimized on first paint is static — and they stand down +while a drag or resize is in flight so they never fight the live transform. + ## Examples ### Controlled mode 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..38e45bb44 100644 --- a/packages/raystack/components/chat-panel/__tests__/chat-panel.test.tsx +++ b/packages/raystack/components/chat-panel/__tests__/chat-panel.test.tsx @@ -240,6 +240,50 @@ describe('ChatPanel', () => { }); }); + describe('Mode transitions', () => { + it('does not flag a transition on first paint', () => { + render(); + expect(screen.getByTestId('panel')).not.toHaveAttribute( + 'data-mode-changed' + ); + }); + + it('flags the transition once the mode changes', async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole('button', { name: 'Pop out chat panel' }) + ); + + expect(screen.getByTestId('panel')).toHaveAttribute('data-mode-changed'); + }); + + it('flags the transition for a controlled mode change too', () => { + const { rerender } = render(); + expect(screen.getByTestId('panel')).not.toHaveAttribute( + 'data-mode-changed' + ); + + rerender(); + expect(screen.getByTestId('panel')).toHaveAttribute('data-mode-changed'); + }); + + it('keeps the flag after returning to the initial mode', async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole('button', { name: 'Pop out chat panel' }) + ); + await user.click(screen.getByRole('button', { name: 'Dock chat panel' })); + + const panel = screen.getByTestId('panel'); + expect(panel).toHaveAttribute('data-mode', 'docked'); + expect(panel).toHaveAttribute('data-mode-changed'); + }); + }); + describe('Floating mode', () => { it('applies the floating size as inline style', () => { render( @@ -654,6 +698,18 @@ 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'); + // Drawn from currentColor so the bubble's own colour carries through. + 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..e923ab965 100644 --- a/packages/raystack/components/chat-panel/chat-panel-root.tsx +++ b/packages/raystack/components/chat-panel/chat-panel-root.tsx @@ -225,6 +225,13 @@ export function ChatPanelRoot({ // defaultSize never contradicts its own max. const initialSizeRef = useRef(sizeProp ?? defaultSize ?? DEFAULT_SIZE); + // The mode entrance animations must not play on first paint, only once the + // panel has actually switched modes. Derived during render (not in an + // effect) so the very first switch is already animated. + const initialModeRef = useRef(mode); + const modeChangedRef = useRef(false); + if (mode !== initialModeRef.current) modeChangedRef.current = true; + const modeRef = useRef(mode); modeRef.current = mode; const positionRef = useRef(position); @@ -641,6 +648,7 @@ export function ChatPanelRoot({ side={side} draggable={draggable} resizing={resizing} + modeChanged={modeChangedRef.current} floatingStyle={floatingStyle} className={className} style={style} @@ -681,6 +689,7 @@ interface ChatPanelFrameProps extends ComponentProps<'aside'> { side: ChatPanelSide; draggable: boolean; resizing: boolean; + modeChanged: boolean; floatingStyle: CSSProperties | null; resizeHandles: ReactNode; } @@ -695,6 +704,7 @@ function ChatPanelFrame({ side, draggable, resizing, + modeChanged, floatingStyle, resizeHandles, className, @@ -733,6 +743,7 @@ function ChatPanelFrame({ data-draggable={dragEnabled || undefined} data-dragging={isDragging || undefined} data-resizing={resizing || undefined} + data-mode-changed={modeChanged || 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 ??