style(x): chat empty state, message bubbles, sidebar hover gap

- Chat empty state: icon-free heading, single suggestion list with
  leading icons and hover arrows, aligned to the composer column
- User message bubbles: rounded-2xl with flattened top-right corner,
  hover-only copy button under the bubble's bottom-right
- Sidebar: hairline gap between menu items so adjacent active/hover
  highlights don't merge
This commit is contained in:
Gagan 2026-07-09 18:42:47 +05:30
parent 6def1bf7d3
commit 5dd8c85ce4
4 changed files with 94 additions and 62 deletions

View file

@ -51,6 +51,7 @@ import {
import { import {
Message, Message,
MessageContent, MessageContent,
MessageCopyButton,
MessageResponse, MessageResponse,
} from '@/components/ai-elements/message'; } from '@/components/ai-elements/message';
import { import {
@ -5888,14 +5889,17 @@ function App() {
<ChatMessageAttachments attachments={item.attachments} /> <ChatMessageAttachments attachments={item.attachments} />
</MessageContent> </MessageContent>
{item.content && ( {item.content && (
<MessageContent> <div className="flex flex-col items-end">
<MessageResponse <MessageContent>
components={streamdownComponents} <MessageResponse
remarkPlugins={userMessageRemarkPlugins} components={streamdownComponents}
> remarkPlugins={userMessageRemarkPlugins}
{item.content} >
</MessageResponse> {item.content}
</MessageContent> </MessageResponse>
</MessageContent>
<MessageCopyButton text={item.content} className="mt-0.5" />
</div>
)} )}
</Message> </Message>
) )
@ -5903,26 +5907,29 @@ function App() {
const { message, files } = parseAttachedFiles(item.content) const { message, files } = parseAttachedFiles(item.content)
return ( return (
<Message key={item.id} from={item.role} data-message-id={item.id}> <Message key={item.id} from={item.role} data-message-id={item.id}>
<MessageContent> <div className="flex flex-col items-end">
{files.length > 0 && ( <MessageContent>
<div className="flex flex-wrap gap-1.5 mb-2"> {files.length > 0 && (
{files.map((filePath, index) => ( <div className="flex flex-wrap gap-1.5 mb-2">
<span {files.map((filePath, index) => (
key={index} <span
className="inline-flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full" key={index}
> className="inline-flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full"
@{wikiLabel(filePath)} >
</span> @{wikiLabel(filePath)}
))} </span>
</div> ))}
)} </div>
<MessageResponse )}
components={streamdownComponents} <MessageResponse
remarkPlugins={userMessageRemarkPlugins} components={streamdownComponents}
> remarkPlugins={userMessageRemarkPlugins}
{message} >
</MessageResponse> {message}
</MessageContent> </MessageResponse>
</MessageContent>
<MessageCopyButton text={message} className="mt-0.5" />
</div>
</Message> </Message>
) )
} }

View file

@ -14,8 +14,10 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { FileUIPart, UIMessage } from "ai"; import type { FileUIPart, UIMessage } from "ai";
import { import {
CheckIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
CopyIcon,
PaperclipIcon, PaperclipIcon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
@ -38,6 +40,37 @@ export const Message = ({ className, from, ...props }: MessageProps) => (
/> />
); );
/**
* Minimal copy-to-clipboard affordance for a message bubble. Invisible until
* the surrounding Message (`.group`) is hovered.
*/
export const MessageCopyButton = ({
text,
className,
}: {
text: string;
className?: string;
}) => {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
aria-label="Copy message"
onClick={() => {
void navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
}}
className={cn(
"shrink-0 rounded-md p-1.5 text-muted-foreground/60 opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100",
className
)}
>
{copied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
</button>
);
};
export type MessageContentProps = HTMLAttributes<HTMLDivElement>; export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({ export const MessageContent = ({
@ -49,7 +82,7 @@ export const MessageContent = ({
data-slot="message-content" data-slot="message-content"
className={cn( className={cn(
"is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm", "is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground", "group-[.is-user]:ml-auto group-[.is-user]:rounded-2xl group-[.is-user]:rounded-tr-md group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-2.5 group-[.is-user]:text-foreground",
"group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground", "group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground",
className className
)} )}

View file

@ -1,4 +1,4 @@
import { Bot, Mail, Sparkles, Telescope } from 'lucide-react' import { ArrowRight, BookOpen, Mail, Zap } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { ToolConnectionsCard } from '@/components/tool-connections-card' import { ToolConnectionsCard } from '@/components/tool-connections-card'
@ -12,8 +12,8 @@ interface ChatEmptyStateProps {
const SUGGESTED_ACTIONS: { icon: typeof Mail; title: string; sub: string; prompt: string }[] = [ const SUGGESTED_ACTIONS: { icon: typeof Mail; title: string; sub: string; prompt: string }[] = [
{ icon: Mail, title: 'Draft a reply', sub: 'to an email', prompt: "Let's draft a reply to [name]'s email" }, { icon: Mail, title: 'Draft a reply', sub: 'to an email', prompt: "Let's draft a reply to [name]'s email" },
{ icon: Bot, title: 'Set up a background agent', sub: 'that automates tasks', prompt: 'Set up a background agent that automates [task]' }, { icon: Zap, title: 'Set up a background agent', sub: 'that automates tasks', prompt: 'Set up a background agent that automates [task]' },
{ icon: Telescope, title: 'Research a topic', sub: 'create a local wiki for me', prompt: 'Research [topic] and create a local wiki for me' }, { icon: BookOpen, title: 'Research a topic', sub: 'create a local wiki for me', prompt: 'Research [topic] and create a local wiki for me' },
] ]
/** /**
@ -25,37 +25,29 @@ export function ChatEmptyState({
wide = false, wide = false,
}: ChatEmptyStateProps) { }: ChatEmptyStateProps) {
return ( return (
<div className={cn('mx-auto flex w-full flex-col gap-6 px-2 py-6', wide ? 'max-w-2xl' : 'max-w-md')}> <div className={cn('mx-auto flex w-full flex-col gap-5 py-6', wide ? 'max-w-4xl px-4' : 'max-w-md px-2')}>
<div className="flex items-center gap-3"> <div>
<div className="flex size-9 shrink-0 items-center justify-center rounded-[10px] border border-border bg-background text-foreground"> <div className="text-2xl font-semibold tracking-tight">What are we working on?</div>
<Sparkles className="size-[17px]" /> <div className="mt-1 text-[15px] text-muted-foreground">Ask anything, or start with a suggestion.</div>
</div>
<div>
<div className="text-base font-semibold tracking-tight">What are we working on?</div>
<div className="text-xs text-muted-foreground">Ask anything, or start with a suggestion.</div>
</div>
</div> </div>
<div> <div className="overflow-hidden rounded-xl border border-border">
<div className="px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground"> {SUGGESTED_ACTIONS.map((action, i) => (
Get started <button
</div> key={action.title}
<div className="flex flex-col gap-2"> type="button"
{SUGGESTED_ACTIONS.map((action) => ( onClick={() => onPickPrompt(action.prompt)}
<button className={cn(
key={action.title} 'group flex w-full items-center gap-1.5 px-3.5 py-3 text-left transition-colors hover:bg-accent/50',
type="button" i > 0 && 'border-t border-border/60',
onClick={() => onPickPrompt(action.prompt)} )}
className="flex items-start gap-2.5 rounded-lg border border-border bg-background px-3 py-2.5 text-left transition-colors hover:bg-accent" >
> <action.icon className="mr-2 size-4 shrink-0 text-foreground/80" strokeWidth={1.75} />
<action.icon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" /> <span className="shrink-0 text-sm font-medium text-foreground">{action.title}</span>
<div className="min-w-0 flex-1"> <span className="truncate text-[13px] text-muted-foreground">{action.sub}</span>
<div className="text-[12.8px] font-medium">{action.title}</div> <ArrowRight className="ml-auto size-3.5 shrink-0 text-muted-foreground/50 transition-colors group-hover:text-foreground" />
<div className="mt-0.5 text-[11.5px] text-muted-foreground">{action.sub}</div> </button>
</div> ))}
</button>
))}
</div>
</div> </div>
<ToolConnectionsCard /> <ToolConnectionsCard />

View file

@ -462,7 +462,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
<ul <ul
data-slot="sidebar-menu" data-slot="sidebar-menu"
data-sidebar="menu" data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)} className={cn("flex w-full min-w-0 flex-col gap-0.5", className)}
{...props} {...props}
/> />
) )