mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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:
parent
6def1bf7d3
commit
5dd8c85ce4
4 changed files with 94 additions and 62 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue