Compare commits

...

5 Commits

Author SHA1 Message Date
Raunak Bhagat
06444ab76e Put some more final touches 2025-12-11 18:17:18 -10:00
Raunak Bhagat
b9b949770c Prevent first message from animating 2025-12-11 17:28:05 -10:00
Raunak Bhagat
cc16432ec3 Save final changes 2025-12-11 17:18:38 -10:00
Raunak Bhagat
237c5b0ed8 Saving more changes 2025-12-11 16:55:35 -10:00
Raunak Bhagat
7a5e03613e Implement using Framer Motion instead 2025-12-11 16:20:36 -10:00
10 changed files with 334 additions and 9 deletions

View File

@@ -64,6 +64,7 @@
"date-fns": "^3.6.0",
"favicon-fetch": "^1.0.0",
"formik": "^2.2.9",
"framer-motion": "^12.23.26",
"highlight.js": "^11.11.1",
"js-cookie": "^3.0.5",
"katex": "^0.16.17",

View File

@@ -0,0 +1,150 @@
"use client";
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { motion } from "framer-motion";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
interface AnimatedMessageBubbleProps {
content: string;
startRect: DOMRect;
targetRect: DOMRect | null;
onAnimationComplete: () => void;
targetRef?: React.RefObject<HTMLDivElement | null>;
}
export default function AnimatedMessageBubble({
content,
startRect,
targetRect: initialTargetRect,
onAnimationComplete,
targetRef,
}: AnimatedMessageBubbleProps) {
const bubbleRef = useRef<HTMLDivElement>(null);
const [liveTargetRect, setLiveTargetRect] = useState<DOMRect | null>(
initialTargetRect
);
const [animationStarted, setAnimationStarted] = useState(false);
const [animationDurationS, setAnimationDurationS] = useState(0.3);
const [neutralBg, setNeutralBg] = useState<string>(
"var(--background-neutral-00)"
);
const [tintBg, setTintBg] = useState<string>("var(--background-tint-02)");
const prevTargetRectRef = useRef<DOMRect | null>(null);
const handleAnimationComplete = React.useCallback(() => {
if (targetRef?.current && bubbleRef.current) {
const finalRect = targetRef.current.getBoundingClientRect();
const finalDeltaX = finalRect.left - startRect.left;
const finalDeltaY = finalRect.top - startRect.top;
bubbleRef.current.style.transform = `translate(${finalDeltaX}px, ${finalDeltaY}px)`;
bubbleRef.current.style.width = `${finalRect.width}px`;
bubbleRef.current.style.height = `${finalRect.height}px`;
}
onAnimationComplete();
}, [onAnimationComplete, startRect.left, startRect.top, targetRef]);
// Read the CSS variable so styling controls duration.
useLayoutEffect(() => {
if (!bubbleRef.current) return;
const raw = getComputedStyle(bubbleRef.current).getPropertyValue(
"--message-send-duration"
);
const parsed = raw.trim().toLowerCase();
let seconds = parseFloat(parsed);
if (parsed.endsWith("ms")) seconds = seconds / 1000;
if (Number.isNaN(seconds) || seconds <= 0) return;
setAnimationDurationS(seconds);
}, []);
// Resolve CSS color variables to concrete values for Framer Motion to tween.
useLayoutEffect(() => {
const rootStyles = getComputedStyle(document.documentElement);
const neutral = rootStyles
.getPropertyValue("--background-neutral-00")
.trim();
const tint = rootStyles.getPropertyValue("--background-tint-02").trim();
if (neutral) setNeutralBg(neutral);
if (tint) setTintBg(tint);
}, []);
// Continuously track the latest target rect so the animation follows scroll/resize
useLayoutEffect(() => {
if (!targetRef?.current) return;
let frameId: number;
const updateRect = () => {
if (!targetRef?.current) return;
const nextRect = targetRef.current.getBoundingClientRect();
const prevRect = prevTargetRectRef.current;
const changed =
!prevRect ||
Math.abs(prevRect.left - nextRect.left) > 0.01 ||
Math.abs(prevRect.top - nextRect.top) > 0.01 ||
Math.abs(prevRect.width - nextRect.width) > 0.01 ||
Math.abs(prevRect.height - nextRect.height) > 0.01;
if (changed) {
prevTargetRectRef.current = nextRect;
setLiveTargetRect(nextRect);
}
frameId = requestAnimationFrame(updateRect);
};
// Set initial rect immediately
const initialRect = targetRef.current.getBoundingClientRect();
prevTargetRectRef.current = initialRect;
setLiveTargetRect(initialRect);
frameId = requestAnimationFrame(updateRect);
return () => cancelAnimationFrame(frameId);
}, [targetRef]);
useEffect(() => {
if ((liveTargetRect || initialTargetRect) && !animationStarted) {
setAnimationStarted(true);
}
}, [animationStarted, initialTargetRect, liveTargetRect]);
const targetRect = liveTargetRect ?? initialTargetRect;
const targetWidth = targetRect?.width ?? startRect.width;
const targetHeight = targetRect?.height ?? startRect.height;
const targetLeft = targetRect?.left ?? startRect.left;
const targetTop = targetRect?.top ?? startRect.top;
return (
<motion.div
ref={bubbleRef}
className={cn(
"fixed pointer-events-none z-[9999] whitespace-break-spaces rounded-t-16 rounded-bl-16 py-2 px-3 text-user-text overflow-hidden animated-message-bubble box-border"
)}
style={{
willChange: "transform, width, height, background-color",
boxSizing: "border-box",
}}
initial={{
left: startRect.left,
top: startRect.top,
width: startRect.width,
height: startRect.height,
backgroundColor: neutralBg,
}}
animate={{
left: targetLeft,
top: targetTop,
width: targetWidth,
height: targetHeight,
backgroundColor: tintBg,
}}
transition={{
duration: animationDurationS,
ease: [0.25, 0.1, 0.25, 1],
backgroundColor: { duration: animationDurationS },
}}
onAnimationComplete={handleAnimationComplete}
>
<Text mainContentBody>{content}</Text>
</motion.div>
);
}

View File

@@ -81,6 +81,11 @@ import AppPageLayout from "@/layouts/AppPageLayout";
import { HeaderData } from "@/lib/headers/fetchHeaderDataSS";
import IconButton from "@/refresh-components/buttons/IconButton";
import SvgChevronDown from "@/icons/chevron-down";
import {
MessageAnimationProvider,
useMessageAnimation,
} from "@/refresh-components/contexts/MessageAnimationContext";
import AnimatedMessageBubble from "@/app/chat/components/AnimatedMessageBubble";
const DEFAULT_CONTEXT_TOKENS = 120_000;
@@ -90,11 +95,25 @@ interface ChatPageProps {
headerData: HeaderData;
}
export default function ChatPage({
function ChatPageInner({
documentSidebarInitialWidth,
firstMessage,
headerData,
}: ChatPageProps) {
const { animatingMessage, clearAnimation } = useMessageAnimation();
const [targetMessageRect, setTargetMessageRect] = useState<DOMRect | null>(
null
);
const latestUserMessageRef = useRef<HTMLDivElement | null>(null);
// Track the position of the latest user message for animation
useEffect(() => {
if (animatingMessage && latestUserMessageRef.current) {
const rect = latestUserMessageRef.current.getBoundingClientRect();
setTargetMessageRect(rect);
}
}, [animatingMessage]);
// Performance tracking
// Keeping this here in case we need to track down slow renders in the future
// const renderCount = useRef(0);
@@ -768,6 +787,17 @@ export default function ChatPage({
<>
<HealthCheckBanner />
{/* Animated flying bubble during message send */}
{animatingMessage && targetMessageRect && (
<AnimatedMessageBubble
content={animatingMessage.content}
startRect={animatingMessage.startRect}
targetRect={targetMessageRect}
onAnimationComplete={clearAnimation}
targetRef={latestUserMessageRef}
/>
)}
{/* ChatPopup is a custom popup that displays a admin-specified message on initial user visit.
Only used in the EE version of the app. */}
{popup}
@@ -873,6 +903,7 @@ export default function ChatPage({
hasPerformedInitialScroll={hasPerformedInitialScroll}
chatSessionId={chatSessionId}
enterpriseSettings={enterpriseSettings}
latestUserMessageRef={latestUserMessageRef}
/>
</div>
@@ -970,6 +1001,7 @@ export default function ChatPage({
onboardingState.currentStep !==
OnboardingStep.Complete)
}
hasMessages={messageHistory.length > 0}
/>
</div>
@@ -999,3 +1031,11 @@ export default function ChatPage({
</>
);
}
export default function ChatPage(props: ChatPageProps) {
return (
<MessageAnimationProvider>
<ChatPageInner {...props} />
</MessageAnimationProvider>
);
}

View File

@@ -48,6 +48,7 @@ interface MessagesDisplayProps {
hasPerformedInitialScroll: boolean;
chatSessionId: string | null;
enterpriseSettings?: EnterpriseSettings | null;
latestUserMessageRef?: RefObject<HTMLDivElement | null>;
}
export const MessagesDisplay: React.FC<MessagesDisplayProps> = ({
@@ -72,6 +73,7 @@ export const MessagesDisplay: React.FC<MessagesDisplayProps> = ({
hasPerformedInitialScroll,
chatSessionId,
enterpriseSettings,
latestUserMessageRef,
}) => {
// Stable fallbacks to avoid changing prop identities on each render
const emptyDocs = useMemo<OnyxDocument[]>(() => [], []);
@@ -114,6 +116,17 @@ export const MessagesDisplay: React.FC<MessagesDisplayProps> = ({
return null;
}
// Find the last user message index for animation
const lastUserMessageIndex = useMemo(() => {
for (let i = messageHistory.length - 1; i >= 0; i--) {
const message = messageHistory[i];
if (message && message.type === "user") {
return i;
}
}
return -1;
}, [messageHistory]);
return (
<div
style={{ overflowAnchor: "none" }}
@@ -135,6 +148,7 @@ export const MessagesDisplay: React.FC<MessagesDisplayProps> = ({
if (message.type === "user") {
const nextMessage =
messageHistory.length > i + 1 ? messageHistory[i + 1] : null;
const isLatestUserMessage = i === lastUserMessageIndex;
return (
<div id={messageReactComponentKey} key={messageReactComponentKey}>
@@ -151,6 +165,9 @@ export const MessagesDisplay: React.FC<MessagesDisplayProps> = ({
parentMessage?.childrenNodeIds ?? emptyChildrenIds
}
onMessageSelection={onMessageSelection}
bubbleRef={
isLatestUserMessage ? latestUserMessageRef : undefined
}
/>
</div>
);

View File

@@ -22,7 +22,7 @@ import { truncateString, cn, hasNonImageFiles } from "@/lib/utils";
import { useUser } from "@/components/user/UserProvider";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
import { FileCard } from "./FileCard";
import { FileCard } from "@/app/chat/components/input/FileCard";
import {
ProjectFile,
UserFileStatus,
@@ -38,7 +38,8 @@ import SvgPlusCircle from "@/icons/plus-circle";
import {
getIconForAction,
hasSearchToolsAvailable,
} from "../../services/actionUtils";
} from "@/app/chat/services/actionUtils";
import { useMessageAnimation } from "@/refresh-components/contexts/MessageAnimationContext";
const MAX_INPUT_HEIGHT = 200;
@@ -105,6 +106,7 @@ export interface ChatInputBarProps {
setPresentingDocument?: (document: MinimalOnyxDocument) => void;
toggleDeepResearch: () => void;
disabled: boolean;
hasMessages: boolean;
}
function ChatInputBarInner({
@@ -130,10 +132,13 @@ function ChatInputBarInner({
toggleDeepResearch,
setPresentingDocument,
disabled,
hasMessages,
}: ChatInputBarProps) {
const { user } = useUser();
const { forcedToolIds, setForcedToolIds } = useForcedTools();
const { currentMessageFiles, setCurrentMessageFiles } = useProjectsContext();
const { startMessageAnimation } = useMessageAnimation();
const inputContainerRef = React.useRef<HTMLDivElement>(null);
const currentIndexingFiles = useMemo(() => {
return currentMessageFiles.filter(
@@ -417,7 +422,10 @@ function ChatInputBarInner({
</div>
)}
<div className="w-full h-full flex flex-col shadow-01 bg-background-neutral-00 rounded-16">
<div
ref={inputContainerRef}
className="w-full h-full flex flex-col shadow-01 bg-background-neutral-00 rounded-16"
>
{currentMessageFiles.length > 0 && (
<div className="p-1 rounded-t-16 flex flex-wrap gap-2">
{currentMessageFiles.map((file) => (
@@ -469,6 +477,16 @@ function ChatInputBarInner({
) {
event.preventDefault();
if (message) {
// Capture the textarea position before submit
if (hasMessages && chatState === "input") {
const sourceEl =
inputContainerRef.current ?? textAreaRef.current;
if (sourceEl) {
const rect = sourceEl.getBoundingClientRect();
startMessageAnimation(message, rect);
}
}
onSubmit();
}
}
@@ -645,6 +663,15 @@ function ChatInputBarInner({
if (chatState == "streaming") {
stopGenerating();
} else if (message) {
// Capture the textarea position before submit
if (hasMessages && chatState === "input") {
const sourceEl =
inputContainerRef.current ?? textAreaRef.current;
if (sourceEl) {
const rect = sourceEl.getBoundingClientRect();
startMessageAnimation(message, rect);
}
}
onSubmit();
}
}}

View File

@@ -14,6 +14,7 @@ import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import SvgEdit from "@/icons/edit";
import Button from "@/refresh-components/buttons/Button";
import ExpandableContentWrapper from "@/components/tools/ExpandableContentWrapper";
import { useMessageAnimation } from "@/refresh-components/contexts/MessageAnimationContext";
interface FileDisplayProps {
files: FileDescriptor[];
@@ -177,6 +178,9 @@ interface HumanMessageProps {
// Streaming and generation
stopGenerating?: () => void;
disableSwitchingForStreaming?: boolean;
// Animation ref
bubbleRef?: React.RefObject<HTMLDivElement | null>;
}
export default function HumanMessage({
@@ -189,6 +193,7 @@ export default function HumanMessage({
shared,
stopGenerating = () => null,
disableSwitchingForStreaming = false,
bubbleRef,
}: HumanMessageProps) {
// TODO (@raunakab):
//
@@ -199,6 +204,10 @@ export default function HumanMessage({
const [isHovered, setIsHovered] = useState(false);
const [isEditing, setIsEditing] = useState(false);
// Check if this message is currently animating
const { animatingMessage } = useMessageAnimation();
const isAnimating = animatingMessage?.content === content && !!bubbleRef;
const currentMessageInd = messageId
? otherMessagesCanSwitchTo?.indexOf(messageId)
: undefined;
@@ -228,7 +237,7 @@ export default function HumanMessage({
return (
<div
id="onyx-human-message"
className="pt-5 pb-1 w-full lg:px-5 flex -mr-6 relative"
className="pt-5 pb-1 w-full lg:px-5 flex -mr-6 relative dbg-red"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
@@ -243,7 +252,7 @@ export default function HumanMessage({
<FileDisplay alignBubble files={files || []} />
<div className="flex justify-end mt-1">
<div className="w-full ml-8 flex w-full w-[800px] break-words">
<div className="w-full ml-8 flex dbg-red break-words">
{isEditing ? (
<MessageEditing
content={content}
@@ -284,14 +293,16 @@ export default function HumanMessage({
</div>
<div
ref={bubbleRef}
className={cn(
"max-w-[25rem] whitespace-break-spaces rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3",
"max-w-[25rem] whitespace-break-spaces rounded-t-16 rounded-bl-16 dbg-red bg-background-tint-02 py-2 px-3",
!(
onEdit &&
isHovered &&
!isEditing &&
(!files || files.length === 0)
) && "ml-auto"
) && "ml-auto",
isAnimating && "opacity-0"
)}
>
<Text mainContentBody>{content}</Text>

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from "react";
import React, { RefObject, useCallback } from "react";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import { FileDescriptor } from "@/app/chat/interfaces";
import HumanMessage from "./HumanMessage";
@@ -12,6 +12,7 @@ interface BaseMemoizedHumanMessageProps {
shared?: boolean;
stopGenerating?: () => void;
disableSwitchingForStreaming?: boolean;
bubbleRef?: RefObject<HTMLDivElement | null>;
}
interface InternalMemoizedHumanMessageProps
@@ -33,6 +34,7 @@ const _MemoizedHumanMessage = React.memo(function _MemoizedHumanMessage({
stopGenerating,
disableSwitchingForStreaming,
onEdit,
bubbleRef,
}: InternalMemoizedHumanMessageProps) {
return (
<HumanMessage
@@ -45,6 +47,7 @@ const _MemoizedHumanMessage = React.memo(function _MemoizedHumanMessage({
stopGenerating={stopGenerating}
disableSwitchingForStreaming={disableSwitchingForStreaming}
onEdit={onEdit}
bubbleRef={bubbleRef}
/>
);
});
@@ -59,6 +62,7 @@ export const MemoizedHumanMessage = ({
stopGenerating,
disableSwitchingForStreaming,
handleEditWithMessageId,
bubbleRef,
}: MemoizedHumanMessageProps) => {
const onEdit = useCallback(
(editedContent: string) => {
@@ -85,6 +89,7 @@ export const MemoizedHumanMessage = ({
stopGenerating={stopGenerating}
disableSwitchingForStreaming={disableSwitchingForStreaming}
onEdit={onEdit}
bubbleRef={bubbleRef}
/>
);
};

View File

@@ -0,0 +1,8 @@
.animated-message-bubble {
--message-send-duration: 0.3s;
background-color: var(--background-neutral-00);
}
.animated-message-bubble--target {
background-color: var(--background-tint-02);
}

View File

@@ -4,6 +4,7 @@
@import "css/colors.css";
@import "css/inputs.css";
@import "css/line-item.css";
@import "css/animations.css";
@import "css/switch.css";
@tailwind base;

View File

@@ -0,0 +1,65 @@
"use client";
import React, { createContext, useContext, useState, useCallback } from "react";
interface AnimatingMessage {
content: string;
startRect: DOMRect;
timestamp: number;
}
interface MessageAnimationContextType {
animatingMessage: AnimatingMessage | null;
startMessageAnimation: (content: string, startRect: DOMRect) => void;
clearAnimation: () => void;
}
const MessageAnimationContext = createContext<
MessageAnimationContextType | undefined
>(undefined);
export function MessageAnimationProvider({
children,
}: {
children: React.ReactNode;
}) {
const [animatingMessage, setAnimatingMessage] =
useState<AnimatingMessage | null>(null);
const startMessageAnimation = useCallback(
(content: string, startRect: DOMRect) => {
setAnimatingMessage({
content,
startRect,
timestamp: Date.now(),
});
},
[]
);
const clearAnimation = useCallback(() => {
setAnimatingMessage(null);
}, []);
return (
<MessageAnimationContext.Provider
value={{
animatingMessage,
startMessageAnimation,
clearAnimation,
}}
>
{children}
</MessageAnimationContext.Provider>
);
}
export function useMessageAnimation() {
const context = useContext(MessageAnimationContext);
if (!context) {
throw new Error(
"useMessageAnimation must be used within MessageAnimationProvider"
);
}
return context;
}