feat(webui): add initial webui with websocket chat flow

This commit is contained in:
Xubin Ren
2026-04-18 18:51:53 +00:00
parent 6bfb75ed03
commit 9ed3031a42
76 changed files with 7088 additions and 38 deletions
+109
View File
@@ -0,0 +1,109 @@
import { MoreHorizontal, Trash2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import { relativeTime } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types";
interface ChatListProps {
sessions: ChatSummary[];
activeKey: string | null;
onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
loading?: boolean;
}
function titleFor(s: ChatSummary): string {
const p = s.preview?.trim();
if (p) return p.length > 48 ? `${p.slice(0, 45)}` : p;
return `Chat ${s.chatId.slice(0, 6)}`;
}
export function ChatList({
sessions,
activeKey,
onSelect,
onRequestDelete,
loading,
}: ChatListProps) {
if (loading && sessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] text-muted-foreground">Loading</div>
);
}
if (sessions.length === 0) {
return (
<div className="px-3 py-6 text-xs text-muted-foreground">
No sessions yet.
</div>
);
}
return (
<ScrollArea className="h-full">
<ul className="space-y-0.5 px-2 py-1">
{sessions.map((s) => {
const active = s.key === activeKey;
const title = titleFor(s);
return (
<li key={s.key}>
<div
className={cn(
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-[12.5px] transition-colors",
active
? "bg-sidebar-accent/80 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--border)/0.4)]"
: "text-sidebar-foreground/88 hover:bg-sidebar-accent/45",
)}
>
<button
type="button"
onClick={() => onSelect(s.key)}
className="flex min-w-0 flex-1 flex-col items-start text-left"
>
<span className="w-full truncate font-medium leading-5">{title}</span>
<span className="text-[10.5px] text-muted-foreground/80">
{relativeTime(s.updatedAt ?? s.createdAt) || "—"}
</span>
</button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
"focus-visible:opacity-100",
active && "opacity-100",
)}
aria-label={`Chat actions for ${title}`}
>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0);
}}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
</ScrollArea>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Composer } from "@/components/Composer";
import { MessageList } from "@/components/MessageList";
import { useClient } from "@/providers/ClientProvider";
import { useNanobotStream } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import type { ChatSummary } from "@/lib/types";
interface ChatPaneProps {
session: ChatSummary | null;
/** Provision a new chat and mark it active. Returns the new chat_id or null. */
onNewChat: () => Promise<string | null>;
}
/**
* The chat surface: persisted history on top, live stream below, composer
* pinned at the bottom. When no session is active we render a centered
* welcome card with a fully-functional composer — typing a first message
* quietly provisions a new chat and routes the message through.
*/
export function ChatPane({ session, onNewChat }: ChatPaneProps) {
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
const { messages: historical, loading } = useSessionHistory(historyKey);
const { client } = useClient();
const [booting, setBooting] = useState(false);
const pendingFirstRef = useRef<string | null>(null);
const initial = useMemo(() => historical, [historical]);
const { messages, isStreaming, send, setMessages } = useNanobotStream(
chatId,
initial,
);
useEffect(() => {
if (!loading && chatId) setMessages(historical);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, chatId, historical]);
// Once a session becomes active, flush any first-message stashed from the
// welcome composer so the user's keystroke "just sends".
useEffect(() => {
if (!chatId) return;
const pending = pendingFirstRef.current;
if (!pending) return;
pendingFirstRef.current = null;
client.sendMessage(chatId, pending);
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: "user",
content: pending,
createdAt: Date.now(),
},
]);
setBooting(false);
}, [chatId, client, setMessages]);
const handleWelcomeSend = useCallback(
async (content: string) => {
if (booting) return;
setBooting(true);
pendingFirstRef.current = content;
const newId = await onNewChat();
if (!newId) {
// Creation failed — release the lock so the user can retry.
pendingFirstRef.current = null;
setBooting(false);
}
},
[booting, onNewChat],
);
if (!session) {
return (
<section className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-1 flex-col items-center justify-center gap-8 px-4 pb-6">
<div className="flex flex-col items-center gap-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<picture>
<source
srcSet="/brand/nanobot_logo.webp"
type="image/webp"
/>
<img
src="/brand/nanobot_logo.png"
alt="nanobot"
className="h-12 w-auto select-none drop-shadow-sm"
draggable={false}
/>
</picture>
<h1 className="text-xl font-medium tracking-tight text-foreground/90">
What's on your mind?
</h1>
<p className="max-w-md text-center text-sm text-muted-foreground">
Your conversations are persisted locally under the nanobot
workspace. Start typing and I'll open a new chat.
</p>
</div>
<div className="w-full animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<Composer
compact
disabled={booting}
onSend={handleWelcomeSend}
placeholder={
booting ? "Opening a new chat…" : "Type your message…"
}
/>
</div>
</div>
</section>
);
}
return (
<section className="relative flex min-h-0 flex-1 flex-col">
<MessageList messages={messages} isStreaming={isStreaming} />
<Composer
onSend={send}
disabled={!chatId}
placeholder="Type your message…"
/>
</section>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
oneDark,
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
import { cn } from "@/lib/utils";
interface CodeBlockProps {
language?: string;
code: string;
className?: string;
}
export function CodeBlock({ language, code, className }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const onCopy = () => {
if (!navigator.clipboard) return;
navigator.clipboard.writeText(code).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1_500);
});
};
const isDark =
typeof window !== "undefined"
? document.documentElement.classList.contains("dark")
: true;
return (
<div className={cn("overflow-hidden rounded-lg", className)}>
<div className="flex items-center justify-between bg-zinc-900 px-4 py-1.5 text-xs font-medium text-zinc-200">
<span className="lowercase">{language || "code"}</span>
<button
type="button"
onClick={onCopy}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-zinc-300 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
aria-label="Copy code"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copied ? "Copied" : "Copy"}</span>
</button>
</div>
<SyntaxHighlighter
language={language}
style={isDark ? oneDark : oneLight}
customStyle={{
margin: 0,
padding: "1rem",
background: "var(--tw-prose-pre-bg, #0a0a0a)",
fontSize: "0.8125rem",
lineHeight: 1.55,
}}
PreTag="pre"
wrapLongLines
>
{code}
</SyntaxHighlighter>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ComposerProps {
onSend: (content: string) => void;
disabled?: boolean;
placeholder?: string;
/** Visually collapse the outer padding when embedded inside a welcome screen. */
compact?: boolean;
}
/**
* Rounded, shadowed composer with an embedded send button — modeled after the
* agent-chat-ui input: a single surface that looks like one interactive unit
* rather than a textarea + button pair.
*/
export function Composer({
onSend,
disabled,
placeholder = "Type your message…",
compact = false,
}: ComposerProps) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Autofocus on mount — coming back to a chat, switching sessions, or
// opening the welcome screen should always land the caret in the box.
useEffect(() => {
if (disabled) return;
const el = textareaRef.current;
if (!el) return;
// Defer so layout settles first (important during enter animations).
const id = requestAnimationFrame(() => el.focus());
return () => cancelAnimationFrame(id);
}, [disabled]);
const submit = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue("");
requestAnimationFrame(() => {
const el = textareaRef.current;
if (el) {
el.style.height = "auto";
el.focus();
}
});
}, [disabled, onSend, value]);
const onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
submit();
}
};
const onInput: React.FormEventHandler<HTMLTextAreaElement> = (e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
className={cn(
"w-full",
compact ? "px-0" : "bg-background/95 px-4 pb-4 pt-2 backdrop-blur",
)}
>
<div
className={cn(
"relative mx-auto flex w-full max-w-[64rem] flex-col overflow-hidden rounded-3xl",
"border bg-muted/60 shadow-sm transition-all duration-200",
"focus-within:bg-muted focus-within:shadow-md focus-within:ring-1 focus-within:ring-foreground/10",
disabled && "opacity-60",
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onInput={onInput}
onKeyDown={onKeyDown}
rows={1}
placeholder={placeholder}
disabled={disabled}
aria-label="Message input"
className={cn(
"min-h-[56px] w-full resize-none bg-transparent px-5 pt-4 pb-2 text-sm",
"placeholder:text-muted-foreground",
"focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
)}
/>
<div className="flex items-center justify-between gap-2 px-3 pb-2">
<span className="hidden select-none text-[11px] text-muted-foreground/70 sm:inline">
Enter to send · Shift+Enter for newline
</span>
<span className="sm:hidden" aria-hidden />
<Button
type="submit"
size="icon"
disabled={disabled || !value.trim()}
aria-label="Send message"
className={cn(
"h-9 w-9 rounded-full shadow-sm transition-transform",
value.trim() && !disabled && "hover:scale-[1.03] active:scale-95",
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
</div>
</div>
</form>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
import type { ConnectionStatus } from "@/lib/types";
const COPY: Record<ConnectionStatus, { label: string; color: string }> = {
idle: { label: "Idle", color: "bg-card/40 text-muted-foreground" },
connecting: {
label: "Connecting…",
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
},
open: {
label: "Connected",
color: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
},
reconnecting: {
label: "Reconnecting…",
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
},
closed: {
label: "Disconnected",
color: "bg-card/40 text-muted-foreground",
},
error: {
label: "Connection error",
color: "bg-destructive/10 text-destructive",
},
};
export function ConnectionBadge() {
const { client } = useClient();
const [status, setStatus] = useState<ConnectionStatus>(client.status);
useEffect(() => client.onStatus(setStatus), [client]);
const meta = COPY[status];
const pulsing =
status === "connecting" ||
status === "reconnecting" ||
status === "error";
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-border/60 px-2 py-1 text-[11px] font-medium transition-colors",
meta.color,
)}
aria-live="polite"
>
<span className="relative flex h-1.5 w-1.5" aria-hidden>
{pulsing && (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75" />
)}
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
</span>
{meta.label}
</span>
);
}
+46
View File
@@ -0,0 +1,46 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface DeleteConfirmProps {
open: boolean;
title: string;
onCancel: () => void;
onConfirm: () => void;
}
export function DeleteConfirm({
open,
title,
onCancel,
onConfirm,
}: DeleteConfirmProps) {
return (
<AlertDialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {title}?</AlertDialogTitle>
<AlertDialogDescription>
The session file will be removed from disk. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { MessageSquarePlus } from "lucide-react";
import { Button } from "@/components/ui/button";
export function EmptyState({
onNewChat,
}: {
onNewChat: () => void;
}) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 text-center">
<MessageSquarePlus
className="h-10 w-10 text-muted-foreground"
aria-hidden
/>
<div className="space-y-1">
<p className="text-lg font-medium">No chats yet</p>
<p className="max-w-sm text-sm text-muted-foreground">
Start a conversation your sessions are stored locally on the nanobot
workspace and stay available across reloads.
</p>
</div>
<Button onClick={onNewChat}>New chat</Button>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { Suspense, lazy } from "react";
import { cn } from "@/lib/utils";
interface MarkdownTextProps {
children: string;
className?: string;
}
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
const LazyMarkdownRenderer = lazy(loadMarkdownRenderer);
export function preloadMarkdownText(): void {
void loadMarkdownRenderer();
}
/**
* Lightweight markdown renderer mirroring agent-chat-ui: GFM + math via
* ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to
* ``CodeBlock`` for copy-to-clipboard and syntax highlighting.
*/
export function MarkdownText({ children, className }: MarkdownTextProps) {
return (
<Suspense
fallback={
<div
className={cn(
"whitespace-pre-wrap break-words leading-relaxed text-foreground/92",
className,
)}
>
{children}
</div>
}
>
<LazyMarkdownRenderer className={className}>{children}</LazyMarkdownRenderer>
</Suspense>
);
}
@@ -0,0 +1,86 @@
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { CodeBlock } from "@/components/CodeBlock";
import { cn } from "@/lib/utils";
import "katex/dist/katex.min.css";
interface MarkdownTextRendererProps {
children: string;
className?: string;
}
/**
* Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a
* separate chunk so the app shell can paint sooner on refresh.
*/
export default function MarkdownTextRenderer({
children,
className,
}: MarkdownTextRendererProps) {
return (
<div
className={cn(
"markdown-content prose prose-sm max-w-none dark:prose-invert",
"prose-headings:mt-4 prose-headings:mb-2 prose-headings:font-semibold",
"prose-h1:text-lg prose-h2:text-base prose-h3:text-[0.95rem] prose-h4:text-sm",
"prose-p:my-2 prose-p:leading-relaxed",
"prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5",
"prose-blockquote:my-3 prose-blockquote:border-l-2 prose-blockquote:font-normal",
"prose-blockquote:not-italic prose-blockquote:text-foreground/80",
"prose-a:text-primary prose-a:underline-offset-2 hover:prose-a:opacity-80",
"prose-hr:my-6",
"prose-pre:my-0 prose-pre:bg-transparent prose-pre:p-0",
"prose-code:before:content-none prose-code:after:content-none prose-code:font-normal",
"prose-table:my-3 prose-th:text-left prose-th:font-medium",
className,
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
code({ className: cls, children: kids, ...props }) {
const match = /language-(\w+)/.exec(cls || "");
if (!match) {
return (
<code
className={cn(
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
cls,
)}
{...props}
>
{kids}
</code>
);
}
const code = String(kids).replace(/\n$/, "");
return <CodeBlock language={match[1]} code={code} className="my-3" />;
},
pre({ children: markdownChildren }) {
return <>{markdownChildren}</>;
},
a({ href, children: markdownChildren, ...props }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-primary underline underline-offset-2 hover:opacity-80"
{...props}
>
{markdownChildren}
</a>
);
},
}}
>
{children}
</ReactMarkdown>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
import { useState } from "react";
import { ChevronRight, Wrench } from "lucide-react";
import { MarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
interface MessageBubbleProps {
message: UIMessage;
}
/**
* Render a single message. Following agent-chat-ui: user turns are a rounded
* "pill" right-aligned with a muted fill; assistant turns render as bare
* markdown so prose/code read like a document rather than a chat bubble.
* Each turn fades+slides in for a touch of motion polish.
*
* Trace rows (tool-call hints, progress breadcrumbs) render as a subdued
* collapsible group so intermediate steps never masquerade as replies.
*/
export function MessageBubble({ message }: MessageBubbleProps) {
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
if (message.kind === "trace") {
return <TraceGroup message={message} animClass={baseAnim} />;
}
if (message.role === "user") {
return (
<div
className={cn(
"group ml-auto flex max-w-[min(85%,36rem)] items-center gap-2",
baseAnim,
)}
>
<p
className={cn(
"ml-auto w-fit rounded-[18px] border border-border/60 bg-secondary/70 px-4 py-2",
"text-right text-sm whitespace-pre-wrap break-words",
"shadow-[0_10px_24px_-18px_rgba(0,0,0,0.55)]",
)}
>
{message.content}
</p>
</div>
);
}
const empty = message.content.trim().length === 0;
return (
<div className={cn("w-full text-sm leading-relaxed", baseAnim)}>
{empty && message.isStreaming ? (
<TypingDots />
) : (
<>
<MarkdownText>{message.content}</MarkdownText>
{message.isStreaming && <StreamCursor />}
</>
)}
</div>
);
}
/** Blinking cursor appended at the end of streaming text. */
function StreamCursor() {
return (
<span
aria-label="streaming"
className={cn(
"ml-0.5 inline-block h-[1em] w-[3px] translate-y-[2px] align-middle",
"rounded-sm bg-foreground/70 animate-pulse",
)}
/>
);
}
/** Pre-token-arrival placeholder: three bouncing dots. */
function TypingDots() {
return (
<span
aria-label="Assistant is typing"
className="inline-flex items-center gap-1 py-1"
>
<Dot delay="0ms" />
<Dot delay="150ms" />
<Dot delay="300ms" />
</span>
);
}
function Dot({ delay }: { delay: string }) {
return (
<span
style={{ animationDelay: delay }}
className={cn(
"inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60",
"animate-bounce",
)}
/>
);
}
interface TraceGroupProps {
message: UIMessage;
animClass: string;
}
/**
* Collapsible group of tool-call / progress breadcrumbs. Defaults to
* expanded for discoverability; a single click on the header folds the
* group down to a one-line summary so it never dominates the thread.
*/
function TraceGroup({ message, animClass }: TraceGroupProps) {
const lines = message.traces ?? [message.content];
const count = lines.length;
const [open, setOpen] = useState(true);
return (
<div className={cn("w-full", animClass)}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
"group flex w-full items-center gap-2 rounded-md px-2 py-1.5",
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
)}
aria-expanded={open}
>
<Wrench className="h-3.5 w-3.5" aria-hidden />
<span className="font-medium">
{count === 1 ? "Using a tool" : `Used ${count} tools`}
</span>
<ChevronRight
aria-hidden
className={cn(
"ml-auto h-3.5 w-3.5 transition-transform duration-200",
open && "rotate-90",
)}
/>
</button>
{open && (
<ul
className={cn(
"mt-1 space-y-0.5 border-l border-muted-foreground/20 pl-3",
"animate-in fade-in-0 slide-in-from-top-1 duration-200",
)}
>
{lines.map((line, i) => (
<li
key={i}
className="whitespace-pre-wrap break-words font-mono text-[11.5px] leading-relaxed text-muted-foreground/90"
>
{line}
</li>
))}
</ul>
)}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowDown } from "lucide-react";
import { MessageBubble } from "@/components/MessageBubble";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
interface MessageListProps {
messages: UIMessage[];
isStreaming: boolean;
}
const NEAR_BOTTOM_PX = 48;
/**
* Scrollable message log. Auto-sticks to the bottom as new content arrives,
* but only when the user was already at the bottom — preserving scroll
* position when they've scrolled up to read earlier turns. A floating
* "scroll to bottom" button appears whenever we're detached from the bottom.
*/
export function MessageList({ messages, isStreaming }: MessageListProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [atBottom, setAtBottom] = useState(true);
const scrollToBottom = useCallback((smooth = false) => {
const el = scrollRef.current;
if (!el) return;
el.scrollTo({
top: el.scrollHeight,
behavior: smooth ? "smooth" : "auto",
});
}, []);
// Keep the viewport pinned to the bottom as long as the user hasn't
// scrolled up. During streaming we do instant jumps (smooth scrolling each
// token fights the incoming animations); on settled updates we animate.
useEffect(() => {
if (!atBottom) return;
scrollToBottom(!isStreaming);
}, [messages, isStreaming, atBottom, scrollToBottom]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const onScroll = () => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
setAtBottom(distance < NEAR_BOTTOM_PX);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
if (messages.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Say hi to get started.
</div>
);
}
return (
<div className="relative flex min-h-0 flex-1 overflow-hidden">
<div
ref={scrollRef}
className={cn(
"h-full overflow-y-auto scroll-smooth",
"[&::-webkit-scrollbar]:w-1.5",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
"[&::-webkit-scrollbar-track]:bg-transparent",
)}
>
<div className="mx-auto flex w-full max-w-[64rem] flex-col gap-6 px-4 pt-4 pb-8">
{messages.map((m) => (
<MessageBubble key={m.id} message={m} />
))}
</div>
</div>
{/* Top fade so messages slide under the header gracefully. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
/>
{/* Bottom fade so messages fade out behind the composer. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-background to-transparent"
/>
{!atBottom && (
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true)}
className={cn(
"absolute bottom-2 left-1/2 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
aria-label="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</Button>
)}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { Moon, PanelLeftClose, Plus, RefreshCcw, Sun } from "lucide-react";
import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type { ChatSummary } from "@/lib/types";
interface SidebarProps {
sessions: ChatSummary[];
activeKey: string | null;
loading: boolean;
theme: "light" | "dark";
onToggleTheme: () => void;
onNewChat: () => void;
onSelect: (key: string) => void;
onRefresh: () => void;
onRequestDelete: (key: string, label: string) => void;
onCollapse: () => void;
}
export function Sidebar(props: SidebarProps) {
return (
<aside className="flex h-full w-full flex-col border-r border-sidebar-border/70 bg-sidebar text-sidebar-foreground">
<div className="flex items-center justify-between px-2 py-2">
<Button
variant="ghost"
size="icon"
aria-label="Collapse sidebar"
onClick={props.onCollapse}
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Toggle theme"
onClick={props.onToggleTheme}
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
{props.theme === "dark" ? (
<Sun className="h-3.5 w-3.5" />
) : (
<Moon className="h-3.5 w-3.5" />
)}
</Button>
</div>
<div className="px-2 pb-2.5">
<Button
onClick={props.onNewChat}
className="h-8.5 w-full justify-start gap-2 rounded-lg border border-sidebar-border/80 bg-card/25 px-3 text-[13px] font-medium text-sidebar-foreground shadow-none hover:bg-sidebar-accent/80"
variant="outline"
>
<Plus className="h-3.5 w-3.5" />
New chat
</Button>
</div>
<Separator className="bg-sidebar-border/70" />
<div className="flex items-center justify-between px-2.5 py-2 text-[11px] font-medium text-muted-foreground">
<span>Recent</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
onClick={props.onRefresh}
aria-label="Refresh sessions"
>
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex-1 overflow-hidden">
<ChatList
sessions={props.sessions}
activeKey={props.activeKey}
loading={props.loading}
onSelect={props.onSelect}
onRequestDelete={props.onRequestDelete}
/>
</div>
<Separator className="bg-sidebar-border/70" />
<div className="flex items-center justify-between px-2.5 py-2 text-xs">
<ConnectionBadge />
</div>
</aside>
);
}
@@ -0,0 +1,144 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ThreadComposerProps {
onSend: (content: string) => void;
disabled?: boolean;
placeholder?: string;
modelLabel?: string | null;
variant?: "thread" | "hero";
}
export function ThreadComposer({
onSend,
disabled,
placeholder = "Type your message…",
modelLabel = null,
variant = "thread",
}: ThreadComposerProps) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isHero = variant === "hero";
useEffect(() => {
if (disabled) return;
const el = textareaRef.current;
if (!el) return;
const id = requestAnimationFrame(() => el.focus());
return () => cancelAnimationFrame(id);
}, [disabled]);
const submit = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue("");
requestAnimationFrame(() => {
const el = textareaRef.current;
if (el) {
el.style.height = "auto";
el.focus();
}
});
}, [disabled, onSend, value]);
const onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
submit();
}
};
const onInput: React.FormEventHandler<HTMLTextAreaElement> = (e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
className={cn("w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
>
<div
className={cn(
"relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200",
isHero
? "max-w-[40rem] rounded-[24px] border border-border/75 bg-card/72 shadow-[0_10px_30px_rgba(0,0,0,0.10)]"
: "max-w-[49.5rem] rounded-[16px] border border-border/70 bg-card/55",
"focus-within:bg-card/70 focus-within:ring-1 focus-within:ring-foreground/8",
disabled && "opacity-60",
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onInput={onInput}
onKeyDown={onKeyDown}
rows={1}
placeholder={placeholder}
disabled={disabled}
aria-label="Message input"
className={cn(
"w-full resize-none bg-transparent",
isHero
? "min-h-[96px] px-4 pb-2 pt-4 text-[15px] leading-6"
: "min-h-[50px] px-4 pb-1.5 pt-3 text-sm",
"placeholder:text-muted-foreground",
"focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
)}
/>
<div
className={cn(
"flex items-center justify-between gap-2",
isHero ? "px-3.5 pb-3.5" : "px-3 pb-2",
)}
>
<div className="flex min-w-0 items-center gap-2">
{modelLabel ? (
<span
title={modelLabel}
className={cn(
"inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1",
"border-foreground/10 bg-foreground/[0.035] font-medium text-foreground/80",
isHero ? "text-[11px]" : "text-[10.5px]",
)}
>
<span
aria-hidden
className="h-1.5 w-1.5 flex-none rounded-full bg-emerald-500/80"
/>
<span className="truncate">{modelLabel}</span>
</span>
) : null}
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
Enter to send · Shift+Enter for newline
</span>
</div>
<span className="sm:hidden" aria-hidden />
<Button
type="submit"
size="icon"
disabled={disabled || !value.trim()}
aria-label="Send message"
className={cn(
"rounded-full border border-border/70 bg-secondary/85 text-secondary-foreground shadow-none transition-transform hover:bg-accent",
isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5",
value.trim() && !disabled && "hover:scale-[1.03] active:scale-95",
)}
>
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
</Button>
</div>
</div>
</form>
);
}
@@ -0,0 +1,52 @@
import { PanelLeftOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ThreadHeaderProps {
title: string;
onToggleSidebar: () => void;
onGoHome: () => void;
hideSidebarToggleOnDesktop?: boolean;
}
export function ThreadHeader({
title,
onToggleSidebar,
onGoHome,
hideSidebarToggleOnDesktop = false,
}: ThreadHeaderProps) {
return (
<div className="relative z-10 flex items-center justify-between gap-3 px-3 py-2">
<div className="relative flex min-w-0 items-center gap-2">
<Button
variant="ghost"
size="icon"
aria-label="Toggle sidebar"
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleOnDesktop && "lg:pointer-events-none lg:opacity-0",
)}
>
<PanelLeftOpen className="h-3.5 w-3.5" />
</Button>
<button
type="button"
onClick={onGoHome}
className="flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent/35 hover:text-foreground"
>
<img
src="/brand/nanobot_icon.png"
alt=""
className="h-4 w-4 rounded-[5px] opacity-85"
aria-hidden
/>
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
</button>
</div>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
</div>
);
}
@@ -0,0 +1,16 @@
import { MessageBubble } from "@/components/MessageBubble";
import type { UIMessage } from "@/lib/types";
interface ThreadMessagesProps {
messages: UIMessage[];
}
export function ThreadMessages({ messages }: ThreadMessagesProps) {
return (
<div className="flex w-full flex-col gap-5">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import { useNanobotStream } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import type { ChatSummary, UIMessage } from "@/lib/types";
import { useClient } from "@/providers/ClientProvider";
interface ThreadShellProps {
session: ChatSummary | null;
title: string;
onToggleSidebar: () => void;
onGoHome: () => void;
onNewChat: () => Promise<string | null>;
hideSidebarToggleOnDesktop?: boolean;
}
function toModelBadgeLabel(modelName: string | null): string | null {
if (!modelName) return null;
const trimmed = modelName.trim();
if (!trimmed) return null;
const leaf = trimmed.split("/").pop() ?? trimmed;
return leaf || trimmed;
}
export function ThreadShell({
session,
title,
onToggleSidebar,
onGoHome,
onNewChat,
hideSidebarToggleOnDesktop = false,
}: ThreadShellProps) {
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
const { messages: historical, loading } = useSessionHistory(historyKey);
const { client, modelName } = useClient();
const [booting, setBooting] = useState(false);
const pendingFirstRef = useRef<string | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
const initial = useMemo(() => {
if (!chatId) return historical;
return messageCacheRef.current.get(chatId) ?? historical;
}, [chatId, historical]);
const { messages, isStreaming, send, setMessages } = useNanobotStream(
chatId,
initial,
);
const showHeroComposer = messages.length === 0 && !loading;
useEffect(() => {
if (!chatId || loading) return;
const cached = messageCacheRef.current.get(chatId);
// When the user switches away and back, keep the local in-memory thread
// state (including not-yet-persisted messages) instead of replacing it with
// whatever the history endpoint currently knows about.
setMessages(cached && cached.length > 0 ? cached : historical);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, chatId, historical]);
useEffect(() => {
if (chatId) return;
setMessages(historical);
}, [chatId, historical, setMessages]);
useEffect(() => {
if (!chatId) return;
messageCacheRef.current.set(chatId, messages);
}, [chatId, messages]);
useEffect(() => {
if (!chatId) return;
const pending = pendingFirstRef.current;
if (!pending) return;
pendingFirstRef.current = null;
client.sendMessage(chatId, pending);
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: "user",
content: pending,
createdAt: Date.now(),
},
]);
setBooting(false);
}, [chatId, client, setMessages]);
const handleWelcomeSend = useCallback(
async (content: string) => {
if (booting) return;
setBooting(true);
pendingFirstRef.current = content;
const newId = await onNewChat();
if (!newId) {
pendingFirstRef.current = null;
setBooting(false);
}
},
[booting, onNewChat],
);
const emptyState = loading ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading conversation
</div>
) : (
<div className="flex w-full max-w-[40rem] flex-col gap-2 text-left animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<div className="inline-flex items-center gap-2 text-[11px] font-medium text-muted-foreground">
<img
src="/brand/nanobot_icon.png"
alt=""
aria-hidden
draggable={false}
className="h-4 w-4 rounded-sm opacity-90"
/>
<span className="text-foreground/82">nanobot</span>
</div>
<p className="max-w-[28rem] text-[13px] leading-6 text-muted-foreground">
Ask questions, continue local work, or start a new thread.
</p>
</div>
);
return (
<section className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
onGoHome={onGoHome}
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
/>
<ThreadViewport
messages={messages}
isStreaming={isStreaming}
emptyState={emptyState}
composer={
session ? (
<ThreadComposer
onSend={send}
disabled={!chatId}
placeholder={showHeroComposer ? "What's on your mind?" : "Type your message…"}
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
/>
) : (
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
placeholder={booting ? "Opening a new chat…" : "What's on your mind?"}
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
/>
)
}
/>
</section>
);
}
@@ -0,0 +1,114 @@
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { ArrowDown } from "lucide-react";
import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
interface ThreadViewportProps {
messages: UIMessage[];
isStreaming: boolean;
composer: ReactNode;
emptyState?: ReactNode;
}
const NEAR_BOTTOM_PX = 48;
export function ThreadViewport({
messages,
isStreaming,
composer,
emptyState,
}: ThreadViewportProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [atBottom, setAtBottom] = useState(true);
const hasMessages = messages.length > 0;
const scrollToBottom = useCallback((smooth = false) => {
const el = scrollRef.current;
if (!el) return;
el.scrollTo({
top: el.scrollHeight,
behavior: smooth ? "smooth" : "auto",
});
}, []);
useEffect(() => {
if (!atBottom) return;
scrollToBottom(!isStreaming);
}, [messages, isStreaming, atBottom, scrollToBottom]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const onScroll = () => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
setAtBottom(distance < NEAR_BOTTOM_PX);
};
onScroll();
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
return (
<div className="relative flex min-h-0 flex-1 overflow-hidden">
<div
ref={scrollRef}
className={cn(
"absolute inset-0 overflow-y-auto scroll-smooth scrollbar-thin",
"[&::-webkit-scrollbar]:w-1.5",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
"[&::-webkit-scrollbar-track]:bg-transparent",
)}
>
{hasMessages ? (
<div className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
<div className="flex-1 px-4 pb-20 pt-4">
<ThreadMessages messages={messages} />
</div>
<div className="sticky bottom-0 z-10 mt-auto">
<div className="px-4 pb-3">
{composer}
</div>
</div>
</div>
) : (
<div className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col px-4">
<div className="flex w-full flex-1 justify-center pb-16 pt-14 md:pt-[3.5rem]">
<div className="flex w-full max-w-[40rem] flex-col gap-5">
{emptyState}
<div className="w-full">{composer}</div>
</div>
</div>
</div>
)}
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
/>
{!atBottom && (
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true)}
className={cn(
"absolute bottom-28 left-1/2 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
aria-label="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</Button>
)}
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props}
/>
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted text-xs font-medium",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarFallback, AvatarImage };
+56
View File
@@ -0,0 +1,56 @@
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";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+116
View File
@@ -0,0 +1,116 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+177
View File
@@ -0,0 +1,177 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+109
View File
@@ -0,0 +1,109 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const Sheet = DialogPrimitive.Root;
const SheetTrigger = DialogPrimitive.Trigger;
const SheetClose = DialogPrimitive.Close;
const SheetPortal = DialogPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out",
{
variants: {
side: {
top: cn(
"inset-x-0 top-0 border-b",
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
),
bottom: cn(
"inset-x-0 bottom-0 border-t",
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
),
left: cn(
"inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
),
right: cn(
"inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
),
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
sheetVariants({ side }),
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"duration-300",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = DialogPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = DialogPrimitive.Title.displayName;
export { Sheet, SheetTrigger, SheetClose, SheetPortal, SheetOverlay, SheetContent, SheetHeader, SheetTitle };
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };