feat: ghost message bubbles, PromptInput focus, ChatPanel motion - #868
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThe Message component adds a Sequence Diagram(s)sequenceDiagram
participant ChatPanel
participant ChatPanelRoot
participant ChatPanelFrame
participant ChatPanelCSS
ChatPanel->>ChatPanelRoot: pass mode and transition
ChatPanelRoot->>ChatPanelRoot: compute modeFrom and morph geometry
ChatPanelRoot->>ChatPanelFrame: pass transition and modeFrom
ChatPanelFrame->>ChatPanelCSS: render transition data attributes
sequenceDiagram
participant User
participant PromptInputForm
participant PromptInputRoot
participant RegisteredInput
User->>PromptInputForm: mousedown on frame dead space
PromptInputForm->>PromptInputRoot: invoke focus handler
PromptInputRoot->>RegisteredInput: focus registered inputRef
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/raystack/components/chat-panel/__tests__/chat-panel.test.tsx`:
- Around line 702-712: Update the “falls back to the co-pilot icon” test to
verify the rendered icon is specifically CoPilotIcon rather than only checking
generic SVG attributes. Use a stable CoPilot-specific marker or mock CoPilotIcon
and assert its rendering, while retaining the existing fallback scenario.
In `@packages/raystack/components/chat-panel/chat-panel.module.css`:
- Around line 208-215: Scope the chat-panel-trigger-in entrance animation in the
prefers-reduced-motion rule to `.root[data-mode-changed] .trigger`, while
keeping the existing interactive transition on `.trigger` unchanged.
In `@packages/raystack/components/prompt-input/prompt-input-root.tsx`:
- Around line 60-61: Update the INTERACTIVE_TARGET selector to match all
contenteditable elements, including empty and "plaintext-only" values, so their
native focus is preserved and they are not redirected to the textarea.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: fce4fd05-638f-4379-a62f-851a2c3ed7eb
⛔ Files ignored due to path filters (1)
packages/raystack/icons/assets/co-pilot.svgis excluded by!**/*.svg
📒 Files selected for processing (19)
apps/www/src/content/docs/ai-elements/chat-panel/demo.tsapps/www/src/content/docs/ai-elements/chat-panel/index.mdxapps/www/src/content/docs/ai-elements/chat/demo.tsapps/www/src/content/docs/ai-elements/message/demo.tsapps/www/src/content/docs/ai-elements/message/index.mdxapps/www/src/content/docs/ai-elements/message/props.tsapps/www/src/content/docs/ai-elements/prompt-input/index.mdxpackages/raystack/components/chat-panel/__tests__/chat-panel.test.tsxpackages/raystack/components/chat-panel/chat-panel-root.tsxpackages/raystack/components/chat-panel/chat-panel-trigger.tsxpackages/raystack/components/chat-panel/chat-panel.module.csspackages/raystack/components/message/__tests__/message.test.tsxpackages/raystack/components/message/message-bubble.tsxpackages/raystack/components/message/message.module.csspackages/raystack/components/prompt-input/__tests__/prompt-input.test.tsxpackages/raystack/components/prompt-input/prompt-input-root.tsxpackages/raystack/components/prompt-input/prompt-input.module.csspackages/raystack/icons/index.tsxpackages/raystack/vitest.config.mjs
| it('falls back to the co-pilot icon', () => { | ||
| render(<BasicChatPanel defaultMode='minimized' />); | ||
| 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' | ||
| ); | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Make the fallback assertion CoPilot-specific.
These checks only prove that some currentColor SVG rendered; reverting to another similarly styled fallback icon would still pass. Assert a stable CoPilot-specific characteristic, or mock CoPilotIcon and assert that mock is rendered.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/raystack/components/chat-panel/__tests__/chat-panel.test.tsx` around
lines 702 - 712, Update the “falls back to the co-pilot icon” test to verify the
rendered icon is specifically CoPilotIcon rather than only checking generic SVG
attributes. Use a stable CoPilot-specific marker or mock CoPilotIcon and assert
its rendering, while retaining the existing fallback scenario.
| @media (prefers-reduced-motion: no-preference) { | ||
| .trigger { | ||
| animation: chat-panel-trigger-in var(--rs-duration-fast) var(--rs-ease-out); | ||
| /* Same tempo as the docked/floating entrances above, so the three modes | ||
| read as one transition rather than three unrelated ones. */ | ||
| animation: chat-panel-trigger-in var(--rs-duration-normal) | ||
| var(--rs-ease-out); | ||
| transition: var(--rs-transition-interactive); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Gate the minimized-bubble entrance on a mode change.
.trigger animates whenever it mounts, so an initially minimized panel still animates despite lacking data-mode-changed. Keep the transition on .trigger, but scope the entrance animation to .root[data-mode-changed] .trigger.
Proposed fix
`@media` (prefers-reduced-motion: no-preference) {
.trigger {
- /* Same tempo as the docked/floating entrances above, so the three modes
- read as one transition rather than three unrelated ones. */
- animation: chat-panel-trigger-in var(--rs-duration-normal)
- var(--rs-ease-out);
transition: var(--rs-transition-interactive);
}
+ .root[data-mode-changed] .trigger {
+ animation: chat-panel-trigger-in var(--rs-duration-normal)
+ var(--rs-ease-out);
+ }
+
.trigger:active {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @media (prefers-reduced-motion: no-preference) { | |
| .trigger { | |
| animation: chat-panel-trigger-in var(--rs-duration-fast) var(--rs-ease-out); | |
| /* Same tempo as the docked/floating entrances above, so the three modes | |
| read as one transition rather than three unrelated ones. */ | |
| animation: chat-panel-trigger-in var(--rs-duration-normal) | |
| var(--rs-ease-out); | |
| transition: var(--rs-transition-interactive); | |
| } | |
| `@media` (prefers-reduced-motion: no-preference) { | |
| .trigger { | |
| transition: var(--rs-transition-interactive); | |
| } | |
| .root[data-mode-changed] .trigger { | |
| animation: chat-panel-trigger-in var(--rs-duration-normal) | |
| var(--rs-ease-out); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/raystack/components/chat-panel/chat-panel.module.css` around lines
208 - 215, Scope the chat-panel-trigger-in entrance animation in the
prefers-reduced-motion rule to `.root[data-mode-changed] .trigger`, while
keeping the existing interactive transition on `.trigger` unchanged.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/raystack/components/chat-panel/chat-panel-root.tsx`:
- Around line 97-101: Update the useEffect that restores the settled mode to use
a nested requestAnimationFrame: schedule setSettledMode(mode) inside a second
rAF, while preserving cancellation for both pending frames in the cleanup. Keep
the existing [from, mode] dependencies and starting-mode behavior unchanged.
In `@packages/raystack/components/chat-panel/chat-panel.module.css`:
- Around line 93-105: Update the morph measurement logic in useMorph within
chat-panel-root.tsx to check matchMedia('(prefers-reduced-motion: reduce)') and
avoid writing inline translate/scale values when reduced motion is enabled.
Preserve the existing morph behavior for users who prefer no motion reduction.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 5681af68-ef5c-476f-ac29-35dce8c5e061
📒 Files selected for processing (19)
apps/www/src/content/docs/ai-elements/chat-panel/demo.tsapps/www/src/content/docs/ai-elements/chat-panel/index.mdxapps/www/src/content/docs/ai-elements/chat-panel/props.tsapps/www/src/content/docs/ai-elements/prompt-input/index.mdxapps/www/src/content/docs/ai-elements/prompt-input/props.tspackages/raystack/components/chat-panel/__tests__/chat-panel.test.tsxpackages/raystack/components/chat-panel/chat-panel-root.tsxpackages/raystack/components/chat-panel/chat-panel.module.csspackages/raystack/components/chat-panel/index.tsxpackages/raystack/components/message/__tests__/message.test.tsxpackages/raystack/components/message/message-bubble.tsxpackages/raystack/components/message/message.module.csspackages/raystack/components/prompt-input/__tests__/prompt-input.test.tsxpackages/raystack/components/prompt-input/prompt-input-context.tsxpackages/raystack/components/prompt-input/prompt-input-root.tsxpackages/raystack/components/prompt-input/prompt-input-textarea.tsxpackages/raystack/components/prompt-input/prompt-input.module.csspackages/raystack/index.tsxpackages/raystack/vitest.config.mjs
💤 Files with no reviewable changes (2)
- packages/raystack/components/message/message.module.css
- packages/raystack/vitest.config.mjs
🚧 Files skipped from review as they are similar to previous changes (2)
- apps/www/src/content/docs/ai-elements/chat-panel/index.mdx
- apps/www/src/content/docs/ai-elements/prompt-input/index.mdx
| useEffect(() => { | ||
| if (!from) return; | ||
| const frame = requestAnimationFrame(() => setSettledMode(mode)); | ||
| return () => cancelAnimationFrame(frame); | ||
| }, [from, mode]); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
Does a style change written in a layout effect and reverted in the next requestAnimationFrame callback still trigger a CSS transition?
💡 Result:
No, a style change that is modified and then immediately reverted within the same requestAnimationFrame (rAF) callback will not trigger a CSS transition [1][2]. This behavior occurs because browsers optimize the rendering pipeline by batching DOM and style changes [3][4]. When you modify an element's style property (e.g., changing a width or opacity) and then change it back to its original value before the browser proceeds to the next rendering steps, the browser's style calculation and layout engines treat the net result as no change at all [2]. For a CSS transition to initiate, the browser must detect a change in the computed style of a transitionable property between two consecutive frames or style-recalculation points [1]. Because the rAF callback executes before the next repaint, the browser does not perform a "style flush" or reflow between your two conflicting style assignments [3][5]. As a result, the browser only sees the final state (the reverted state), which matches the initial state, effectively nullifying the transition [2]. To trigger a transition, you must ensure the browser processes the "initial" state and the "destination" state in separate rendering cycles [6][1]. Common techniques to achieve this include: 1. Double requestAnimationFrame: Wrapping the target style update in a nested rAF ensures the update happens after the browser has had an opportunity to paint the initial state [5][1]. 2. Forced Synchronous Layout (Reflow): Accessing a geometric property (such as offsetHeight or calling getComputedStyle) forces the browser to calculate the current layout synchronously, creating a "flush" that allows the subsequent style change to be recognized as a new destination [7][1][2]. Example of the reliable approach: box.style.width = '100px'; // Initial state requestAnimationFrame( => { requestAnimationFrame( => { box.style.transition = 'width 1s'; box.style.width = '200px'; // Destination state }); });
Citations:
- 1: https://stackoverflow.com/questions/72546326/requestanimationframe-and-getcomputedstyle-dont-work-as-expected-following-alo
- 2: https://stackoverflow.com/questions/37439605/restart-reset-and-replay-a-transition-css
- 3: https://stackoverflow.com/questions/18564942/clean-way-to-programmatically-use-css-transitions-from-js
- 4: https://chromium.googlesource.com/playground/chromium-org-site/+/refs/heads/main/developers/the-rendering-critical-path.md
- 5: https://macarthur.me/posts/box
- 6: https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Transitions/Using
- 7: https://stackoverflow.com/questions/68907532/changing-element-display-and-setting-transition-after-requestanimationcallback-n
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Locate file and related chat-panel files:"
git ls-files | rg 'packages/raystack/components/chat-panel|chat-panel-root\.tsx|chat-panel' | head -100
echo
echo "Inspect target file outline/size:"
wc -l packages/raystack/components/chat-panel/chat-panel-root.tsx
ast-grep outline packages/raystack/components/chat-panel/chat-panel-root.tsx --view compact || true
echo
echo "Relevant target file sections:"
sed -n '1,180p' packages/raystack/components/chat-panel/chat-panel-root.tsx | cat -n
echo
echo "Search for from/settled/data-mode-starting references:"
rg -n "from|settled|mode-starting|requestAnimationFrame|requestAnimationFrame\\(" packages/raystack/components/chat-panel packages/raystack -g '*.tsx' -g '*.ts' | head -200Repository: raystack/apsara
Length of output: 28071
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Relevant CSS transition/data-mode-starting references:"
rg -n "data-mode-starting|data-mode-from|transition|from \\[" packages/raystack/components/chat-panel/chat-panel.module.css packages/raystack -g '*.css' -g '*.scss' | head -200
echo
echo "chat-panel.module.css size and relevant sections:"
wc -l packages/raystack/components/chat-panel/chat-panel.module.css
sed -n '1,260p' packages/raystack/components/chat-panel/chat-panel.module.css | cat -n
echo
echo "ChatPanelRoot usage around setting mode/transition and rendered panel:"
sed -n '760,860p' packages/raystack/components/chat-panel/chat-panel-root.tsx | cat -n
rg -n "useMorph\\(|useModeChange\\(|data-mode-starting|modeTransition|ChatPanelPosition" packages/raystack/components/chat-panel/chat-panel-root.tsxRepository: raystack/apsara
Length of output: 34713
Use a nested rAF before restoring the settled mode.
The layout effect writes the translate/scale from-values synchronously, but this requestAnimationFrame callback can still run before repaint is interrupted, so the browser can batch the starting-frame styles and their removal into one recalc. A double rAF (or a trusted forced reflow) makes the from-frame style commit before removing data-mode-starting.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/raystack/components/chat-panel/chat-panel-root.tsx` around lines 97
- 101, Update the useEffect that restores the settled mode to use a nested
requestAnimationFrame: schedule setSettledMode(mode) inside a second rAF, while
preserving cancellation for both pending frames in the cleanup. Keep the
existing [from, mode] dependencies and starting-mode behavior unchanged.
| @media (prefers-reduced-motion: no-preference) { | ||
| .root { | ||
| transition: | ||
| scale var(--rs-duration-normal) var(--rs-ease-out), | ||
| translate var(--rs-duration-normal) var(--rs-ease-out), | ||
| opacity var(--rs-duration-normal) var(--rs-ease-out); | ||
| } | ||
|
|
||
| /* The from-values are jumped to, not tweened into — the tween is the frame | ||
| after this rule stops applying. */ | ||
| .root[data-mode-starting] { | ||
| transition: none; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Morph from-values aren't reduced-motion aware.
All transitions live inside prefers-reduced-motion: no-preference, but useMorph writes inline translate/scale regardless. Under reduced motion the panel is displaced for one frame and then snaps back with no tween — the opposite of the intent. Gate the morph measurement on matchMedia('(prefers-reduced-motion: reduce)') in chat-panel-root.tsx.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/raystack/components/chat-panel/chat-panel.module.css` around lines
93 - 105, Update the morph measurement logic in useMorph within
chat-panel-root.tsx to check matchMedia('(prefers-reduced-motion: reduce)') and
avoid writing inline translate/scale values when reduced motion is enabled.
Preserve the existing morph behavior for users who prefer no motion reduction.
Summary
variant="ghost"toMessage.Bubble— no background, border or padding, spanning the full message row. It composestextVariants({ size: 'small' })so it renders as literalTextoutput rather than a lookalike, and the grid widens via:has()(content columnfit-content(85%)→minmax(0, 1fr), spacer dropped) so hover actions land at the row's far edge. The Chat and ChatPanel examples now use it in place of the bareTextthey were reaching for.PromptInputframe's dead space focuses the textarea, withcursor: textto advertise it. Handled onmousedownwithpreventDefault()rather than onclick, so focus never leaves the textarea and comes back — a round trip that would dismiss anything anchored to it. Interactive targets, secondary mouse buttons, the disabled state and a consumeronMouseDownthat callspreventDefault()all opt out.CoPilotIcon, and use it as the default glyph for the minimizedChatPanelbubble (was Radix'sChatBubbleIcon).ChatPanelmode changes:positionflips between in-flow andfixed, which no property can tween, so each mode plays a transform + opacity entrance at--rs-duration-normal/--rs-ease-out. 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. Adata-mode-changedattribute keeps first paint static, the floating entrance stands down mid-drag/resize, and everything is gated onprefers-reduced-motion.@svgr/rollupinto the vitest config as apreplugin — reusing anicons/asset inside a component needs it, or Vite resolves the.svgto a URL string and the namedReactComponentexport is undefined in tests. Docs updated across Message, PromptInput and ChatPanel.