refactor(chat): enhance chat components and improve message handling

- Simplified the `handleMessageSubmit` function in the chat page for better readability.
- Refactored the `ChatMessagesContainer` to improve message rendering logic, including the addition of the `FindBlocksTool` for tool call outputs.
- Updated the `ChatSidebar` component for better organization and clarity in props definition.
- Introduced a new `MorphingTextAnimation` component to enhance visual feedback during message transitions.
- Removed the obsolete `chat-store.ts` file to streamline the codebase.

These changes aim to improve the overall functionality and user experience of the chat interface.
This commit is contained in:
abhi1992002
2026-02-02 12:23:30 +05:30
parent afb74a8ff1
commit b5d6853223
13 changed files with 257 additions and 197 deletions

View File

@@ -1,122 +1,103 @@
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import {
Message,
MessageContent,
MessageResponse,
} from "@/components/ai-elements/message";
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import {
Message,
MessageContent,
MessageResponse,
} from "@/components/ai-elements/message";
import { MessageSquareIcon } from "lucide-react";
import { UIMessage, UIDataTypes, UITools } from "ai";
import { FindBlocksTool } from "../../tools/FindBlocks/FindBlocks";
interface ChatMessagesContainerProps {
messages: UIMessage<unknown, UIDataTypes, UITools>[];
status: string;
error: Error | undefined;
handleSubmit: (e: React.FormEvent) => void;
input: string;
setInput: (input: string) => void;
}
messages: UIMessage<unknown, UIDataTypes, UITools>[];
status: string;
error: Error | undefined;
handleSubmit: (e: React.FormEvent) => void;
input: string;
setInput: (input: string) => void;
}
export const ChatMessagesContainer = ({messages, status, error, handleSubmit, input, setInput}: ChatMessagesContainerProps) => {
export const ChatMessagesContainer = ({
messages,
status,
error,
handleSubmit,
input,
setInput,
}: ChatMessagesContainerProps) => {
return (
<div className="flex h-full flex-1 flex-col">
<Conversation className="flex-1">
<ConversationContent>
{messages.length === 0 ? (
<ConversationEmptyState
icon={<MessageSquareIcon className="size-12" />}
title="Start a conversation"
description="Type a message below to begin chatting"
/>
) : (
messages.map((message) => (
<Message from={message.role} key={message.id}>
<MessageContent>
{message.parts.map((part, i) => {
switch (part.type) {
case "text":
return (
<MessageResponse key={`${message.id}-${i}`}>
{part.text}
</MessageResponse>
);
case "tool-find_block":
return (
<div
key={`${message.id}-${i}`}
className="w-fit rounded-xl border border-zinc-200 bg-zinc-100 p-2 text-xs text-zinc-700"
>
{part.state === "input-streaming" && (
<p>Finding blocks for you</p>
)}
{part.state === "input-available" && (
<p>
Searching blocks for{" "}
{(part.input as { query: string }).query}
</p>
)}
{part.state === "output-available" && (
<p>
Found{" "}
{
(
JSON.parse(part.output as string) as {
count: number;
}
).count
}{" "}
blocks
</p>
)}
</div>
);
default:
return null;
}
})}
</MessageContent>
</Message>
))
)}
{status === "submitted" && (
<Message from="assistant">
<Conversation className="flex-1">
<ConversationContent>
{messages.length === 0 ? (
<ConversationEmptyState
icon={<MessageSquareIcon className="size-12" />}
title="Start a conversation"
description="Type a message below to begin chatting"
/>
) : (
messages.map((message) => (
<Message from={message.role} key={message.id}>
<MessageContent>
<p className="text-zinc-500">Thinking...</p>
{message.parts.map((part, i) => {
switch (part.type) {
case "text":
return (
<MessageResponse key={`${message.id}-${i}`}>
{part.text}
</MessageResponse>
);
case "tool-find_block":
return (
<FindBlocksTool message={message} i={i} part={part} />
);
default:
return null;
}
})}
</MessageContent>
</Message>
)}
{error && (
<div className="rounded-lg bg-red-50 p-3 text-red-600">
Error: {error.message}
</div>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
))
)}
{status === "submitted" && (
<Message from="assistant">
<MessageContent>
<p className="text-zinc-500">Thinking...</p>
</MessageContent>
</Message>
)}
{error && (
<div className="rounded-lg bg-red-50 p-3 text-red-600">
Error: {error.message}
</div>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<form onSubmit={handleSubmit} className="border-t p-4">
<div className="mx-auto flex max-w-2xl gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={status !== "ready"}
placeholder="Say something..."
className="flex-1 rounded-md border border-zinc-300 px-4 py-2 focus:border-zinc-500 focus:outline-none"
/>
<button
type="submit"
disabled={status !== "ready" || !input.trim()}
className="rounded-md bg-zinc-900 px-4 py-2 text-white transition-colors hover:bg-zinc-800 disabled:opacity-50"
>
Send
</button>
</div>
</form>
</div>
<form onSubmit={handleSubmit} className="border-t p-4">
<div className="mx-auto flex max-w-2xl gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={status !== "ready"}
placeholder="Say something..."
className="flex-1 rounded-md border border-zinc-300 px-4 py-2 focus:border-zinc-500 focus:outline-none"
/>
<button
type="submit"
disabled={status !== "ready" || !input.trim()}
className="rounded-md bg-zinc-900 px-4 py-2 text-white transition-colors hover:bg-zinc-800 disabled:opacity-50"
>
Send
</button>
</div>
</form>
</div>
);
};
};

View File

@@ -2,7 +2,13 @@ import { useState } from "react";
import { postV2CreateSession } from "@/app/api/__generated__/endpoints/chat/chat";
import { parseAsString, useQueryState } from "nuqs";
export const ChatSidebar = ({ isCreating, setIsCreating }: { isCreating: boolean, setIsCreating: (isCreating: boolean) => void }) => {
export const ChatSidebar = ({
isCreating,
setIsCreating,
}: {
isCreating: boolean;
setIsCreating: (isCreating: boolean) => void;
}) => {
const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString);
async function createSession(): Promise<string | null> {
@@ -29,17 +35,15 @@ export const ChatSidebar = ({ isCreating, setIsCreating }: { isCreating: boolean
}
}
return (
<div className="flex w-64 flex-col border-r border-zinc-200 bg-zinc-50 p-4">
<button
onClick={handleNewSession}
disabled={isCreating}
className="rounded-md bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{isCreating ? "Creating..." : "New Session"}
</button>
</div>
<button
onClick={handleNewSession}
disabled={isCreating}
className="rounded-md bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{isCreating ? "Creating..." : "New Session"}
</button>
</div>
);
};
};

View File

@@ -20,4 +20,4 @@ export function EmptySession({ isCreating, onCreateSession }: Props) {
</form>
</div>
);
}
}

View File

@@ -0,0 +1,57 @@
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
const MorphingTextAnimationComponent = ({
currentText,
}: {
currentText: string;
}) => {
const letters = currentText.split("");
return (
<motion.span className="inline-flex overflow-hidden">
{letters.map((char, index) => (
<motion.span
key={`${currentText}-${index}`}
initial={{ opacity: 0, y: 8, rotateX: "80deg", filter: "blur(6px)" }}
animate={{ opacity: 1, y: 0, rotateX: "0deg", filter: "blur(0px)" }}
exit={{ opacity: 0, y: -8, rotateX: "-80deg", filter: "blur(6px)" }}
style={{ willChange: "transform" }}
transition={{
delay: 0.015 * index,
type: "spring",
bounce: 0.5,
}}
className="inline-block"
>
{char === " " ? "\u00A0" : char}
</motion.span>
))}
</motion.span>
);
};
export const MorphingTextAnimation = () => {
const textArray = ["Searching for Twitter blocks", "Found 10 twitter blocks"];
const [currentText, setCurrentText] = useState(textArray[0]);
useEffect(() => {
const interval = setInterval(() => {
setCurrentText(textArray[Math.floor(Math.random() * textArray.length)]);
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<div>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={currentText}
className="whitespace-nowrap text-sm text-muted-foreground"
>
<MorphingTextAnimationComponent currentText={currentText} />
</motion.div>
</AnimatePresence>
</div>
);
};

View File

@@ -57,12 +57,11 @@ export default function Page() {
function handleMessageSubmit(e: React.FormEvent) {
e.preventDefault();
if (!input.trim() || !sessionId) return;
sendMessage({ text: input });
setInput("");
}
return (
<div className="flex h-full">
<ChatSidebar isCreating={isCreating} setIsCreating={setIsCreating} />

View File

@@ -0,0 +1,17 @@
import { UIMessage, UIDataTypes, UITools, UIMessagePart } from "ai";
export const FindBlocksTool = ({
message,
i,
part,
}: {
message: UIMessage<unknown, UIDataTypes, UITools>;
i: number;
part: UIMessagePart<any, any>;
}) => {
return (
<div>
<h1>Find Blocks</h1>
</div>
);
};

View File

@@ -1,5 +1,6 @@
"use client";
import { MorphingTextAnimation } from "@/app/(platform)/copilot-2/components/MorphingTextAnimation/MorphingTextAnimation";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ArrowDownIcon } from "lucide-react";
@@ -50,18 +51,23 @@ export const ConversationEmptyState = ({
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
className,
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-neutral-500 dark:text-neutral-400">{icon}</div>}
{icon && (
<div className="text-neutral-500 dark:text-neutral-400">{icon}</div>
)}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
<h3 className="text-sm font-medium">{title}</h3>
{description && (
<p className="text-neutral-500 text-sm dark:text-neutral-400">{description}</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{description}
</p>
)}
<MorphingTextAnimation />
</div>
</>
)}
@@ -84,8 +90,8 @@ export const ConversationScrollButton = ({
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-white dark:hover:bg-neutral-100 dark:dark:bg-neutral-950 dark:dark:hover:bg-neutral-800",
className
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-white dark:dark:bg-neutral-950 dark:dark:hover:bg-neutral-800 dark:hover:bg-neutral-100",
className,
)}
onClick={handleScrollToBottom}
size="icon"

View File

@@ -1,10 +1,7 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ButtonGroup,
ButtonGroupText,
} from "@/components/ui/button-group";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
@@ -31,7 +28,7 @@ export const Message = ({ className, from, ...props }: MessageProps) => (
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
className,
)}
{...props}
/>
@@ -49,7 +46,7 @@ export const MessageContent = ({
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-neutral-100 group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-neutral-950 dark:group-[.is-user]:bg-neutral-800 dark:group-[.is-user]:text-neutral-50",
"group-[.is-assistant]:text-neutral-950 dark:group-[.is-assistant]:text-neutral-50",
className
className,
)}
{...props}
>
@@ -115,16 +112,14 @@ interface MessageBranchContextType {
}
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null
null,
);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error(
"MessageBranch components must be used within"
);
throw new Error("MessageBranch components must be used within");
}
return context;
@@ -200,7 +195,7 @@ export const MessageBranchContent = ({
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
index === currentBranch ? "block" : "hidden",
)}
key={branch.key}
{...props}
@@ -293,7 +288,7 @@ export const MessageBranchPage = ({
<ButtonGroupText
className={cn(
"border-none bg-transparent text-neutral-500 shadow-none dark:text-neutral-400",
className
className,
)}
{...props}
>
@@ -309,13 +304,13 @@ export const MessageResponse = memo(
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
className,
)}
plugins={{ code, mermaid, math, cjk }}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
(prevProps, nextProps) => prevProps.children === nextProps.children,
);
MessageResponse.displayName = "MessageResponse";
@@ -330,7 +325,7 @@ export const MessageToolbar = ({
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className
className,
)}
{...props}
>

View File

@@ -1,8 +1,8 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
const buttonGroupVariants = cva(
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
@@ -18,8 +18,8 @@ const buttonGroupVariants = cva(
defaultVariants: {
orientation: "horizontal",
},
}
)
},
);
function ButtonGroup({
className,
@@ -34,7 +34,7 @@ function ButtonGroup({
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
);
}
function ButtonGroupText({
@@ -42,19 +42,19 @@ function ButtonGroupText({
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "div"
const Comp = asChild ? Slot : "div";
return (
<Comp
className={cn(
"bg-neutral-100 shadow-xs flex items-center gap-2 rounded-md border border-neutral-200 px-4 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none dark:bg-neutral-800 dark:border-neutral-800",
className
"shadow-xs flex items-center gap-2 rounded-md border border-neutral-200 bg-neutral-100 px-4 text-sm font-medium dark:border-neutral-800 dark:bg-neutral-800 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className,
)}
{...props}
/>
)
);
}
function ButtonGroupSeparator({
@@ -67,12 +67,12 @@ function ButtonGroupSeparator({
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-neutral-200 relative !m-0 self-stretch data-[orientation=vertical]:h-auto dark:bg-neutral-800",
className
"relative !m-0 self-stretch bg-neutral-200 data-[orientation=vertical]:h-auto dark:bg-neutral-800",
className,
)}
{...props}
/>
)
);
}
export {
@@ -80,4 +80,4 @@ export {
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
};

View File

@@ -1,8 +1,8 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-neutral-300",
@@ -17,7 +17,8 @@ const buttonVariants = cva(
"border border-neutral-200 bg-white shadow-sm hover:bg-neutral-100 hover:text-neutral-900 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
secondary:
"bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-100/80 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80",
ghost: "hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
ghost:
"hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
link: "text-neutral-900 underline-offset-4 hover:underline dark:text-neutral-50",
},
size: {
@@ -31,27 +32,27 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants }
export { Button, buttonVariants };

View File

@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
@@ -11,7 +11,7 @@ const Separator = React.forwardRef<
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
@@ -20,12 +20,12 @@ const Separator = React.forwardRef<
className={cn(
"shrink-0 bg-neutral-200 dark:bg-neutral-800",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
className,
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator }
export { Separator };

View File

@@ -1,15 +1,15 @@
"use client"
"use client";
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
@@ -20,13 +20,13 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-neutral-900 px-3 py-1.5 text-xs text-neutral-50 animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin] dark:bg-neutral-50 dark:text-neutral-900",
className
"z-50 origin-[--radix-tooltip-content-transform-origin] overflow-hidden rounded-md bg-neutral-900 px-3 py-1.5 text-xs text-neutral-50 animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:bg-neutral-50 dark:text-neutral-900",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };