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

View file

@ -14,8 +14,10 @@ import {
import { cn } from "@/lib/utils";
import type { FileUIPart, UIMessage } from "ai";
import {
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
PaperclipIcon,
XIcon,
} 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 const MessageContent = ({
@ -49,7 +82,7 @@ export const MessageContent = ({
data-slot="message-content"
className={cn(
"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",
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 { ToolConnectionsCard } from '@/components/tool-connections-card'
@ -12,8 +12,8 @@ interface ChatEmptyStateProps {
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: Bot, 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: Zap, title: 'Set up a background agent', sub: 'that automates tasks', prompt: 'Set up a background agent that automates [task]' },
{ 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,
}: ChatEmptyStateProps) {
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="flex items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-[10px] border border-border bg-background text-foreground">
<Sparkles className="size-[17px]" />
</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 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>
<div className="text-2xl font-semibold tracking-tight">What are we working on?</div>
<div className="mt-1 text-[15px] text-muted-foreground">Ask anything, or start with a suggestion.</div>
</div>
<div>
<div className="px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
Get started
</div>
<div className="flex flex-col gap-2">
{SUGGESTED_ACTIONS.map((action) => (
<button
key={action.title}
type="button"
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="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="text-[12.8px] font-medium">{action.title}</div>
<div className="mt-0.5 text-[11.5px] text-muted-foreground">{action.sub}</div>
</div>
</button>
))}
</div>
<div className="overflow-hidden rounded-xl border border-border">
{SUGGESTED_ACTIONS.map((action, i) => (
<button
key={action.title}
type="button"
onClick={() => onPickPrompt(action.prompt)}
className={cn(
'group flex w-full items-center gap-1.5 px-3.5 py-3 text-left transition-colors hover:bg-accent/50',
i > 0 && 'border-t border-border/60',
)}
>
<action.icon className="mr-2 size-4 shrink-0 text-foreground/80" strokeWidth={1.75} />
<span className="shrink-0 text-sm font-medium text-foreground">{action.title}</span>
<span className="truncate text-[13px] text-muted-foreground">{action.sub}</span>
<ArrowRight className="ml-auto size-3.5 shrink-0 text-muted-foreground/50 transition-colors group-hover:text-foreground" />
</button>
))}
</div>
<ToolConnectionsCard />

View file

@ -462,7 +462,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
<ul
data-slot="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}
/>
)