Обзор
Компоненты
- Accordion
- Alert
- Alert Dialog
- Autocomplete
- Auth Surface
- Avatar
- Badge
- Browse Catalog Dialog
- Button
- Card
- Checkbox
- Checkbox Group
- Collapsible
- Combobox
- Command
- Connector Setup Dialog
- Cookie Banner
- Dialog
- Directory Card
- Directory Detail
- Directory Skeleton
- DrawerНовое
- Token Parts Input
- Empty
- Field
- Fieldset
- File Preview Modal
- File Preview Skeleton
- Form
- Frame
- Group
- Icon
- Input
- Input Group
- Kbd
- Label
- Legal Shell
- Menu
- Mermaid Diagram
- Mind Map Diagram
- Not Found Screen
- Onboarding Frame
- Popover
- PDF Thumbnail
- Personalization Landing
- Preview Card
- Pricing Page
- Progress
- Radio Group
- Ring Spinner
- Scroll Area
- Select
- Separator
- Settings Page
- Settings Skills
- Settings Connectors
- Settings Capabilities
- Settings Usage
- Settings Account
- Settings Billing
- Sheet
- Sidebar
- Skeleton
- Skill Create Dialog
- Slider
- Spinner
- Stat
- Switch
- Table
- Tabs
- Textarea
- Toast
- Toggle
- Tooltip
AI-компоненты
- Компоненты AI
- Chat Conversation
- Chat Message
- Chat Response
- Chat Suggestion
- Chat Prompt Input
- Slash Highlighted Textarea
- Chat Search Dialog
- Chat Skill Doc
- Chat Connector Detail
- Chat Attachments
- Chat File Card
- Chat Token Chip
- Chat Code Block
- Chat Image
- Chat Inline Citation
- Chat Sources
- Chat Web Search
- Chat Research
- Chat Source
- Chat Actions
- Chat Context
- Chat Loader
- Chat Compaction
- Chat Timeline
- Chat Snippet
- Chat Terminal
- Chat Stack Trace
- Chat Test Results
- Chat File Tree
- Chat Environment Variables
- Chat Audio Player
- Chat Transcription
- Chat Speech Input
- Chat Mic Selector
- Chat Voice Selector
- Chat Agent
- Chat Persona
- Chat Connection
- Chat Connector Suggestion
- Chat Queue
- Chat Checkpoint
- Chat Confirmation
- Chat Artifact
- Chat JSX Preview
- Chat Schema Display
- Chat Package Info
- Chat Commit
- Chat Plan
- Chat Open In Chat
- Chat Sandbox
- Chat Model Selector
- Chat Canvas
- Chat Node
- Chat Edge
Ресурсы
Sidebar
Левый сайдбар приложения — шапка с брендом, навигация, список недавних чатов и футер пользователя; сворачивается в рейл.
Oracul
Избранное
Недавние
"use client";
import {
IconBriefcase,
IconDotsVertical,
IconLogout,
IconPencil,
IconPin,
IconPinnedOff,
IconPlus,
IconSearch,
IconSelector,
IconSettings,
IconTrash,
} from "@tabler/icons-react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
ChatSearchDialog,
type ChatSearchResult,
} from "@/components/ui/chat-search-dialog";
import { Input } from "@/components/ui/input";
import {
Menu,
MenuItem,
MenuPopup,
MenuSeparator,
MenuTrigger,
} from "@/components/ui/menu";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
type Chat = { id: string; title: string; pinned: boolean };
type DropOver = { id: string; position: "before" | "after" } | null;
const INITIAL: Chat[] = [
{ id: "1", title: "Обрабатываю запрос на создание…", pinned: true },
{ id: "2", title: "Я помогу найти информацию о р…", pinned: false },
{ id: "3", title: "Skill loaded: documents-ru…", pinned: false },
{ id: "4", title: "Создание HTML-кода для сайта…", pinned: false },
];
const GROUP_LABEL = "font-normal text-muted-foreground text-xs";
// Row action menu (Переименовать / Закрепить|Открепить / Удалить) — 1:1 with the app.
function ChatActionMenuItems({
pinned,
onRename,
onTogglePin,
onDelete,
}: {
pinned: boolean;
onRename: () => void;
onTogglePin: () => void;
onDelete: () => void;
}) {
return (
<>
<MenuItem onClick={onRename}>
<IconPencil className="!size-4 [stroke-width:2px]" />
Переименовать
</MenuItem>
<MenuItem onClick={onTogglePin}>
{pinned ? (
<>
<IconPinnedOff className="!size-4 [stroke-width:2px]" />
Открепить
</>
) : (
<>
<IconPin className="!size-4 [stroke-width:2px]" />
Закрепить
</>
)}
</MenuItem>
<MenuSeparator />
<MenuItem className="text-destructive" onClick={onDelete}>
<IconTrash className="!size-4 [stroke-width:2px]" />
Удалить
</MenuItem>
</>
);
}
function ChatRow({
chat,
active,
renaming,
dragging,
dropOver,
onOpen,
onPointerDown,
onStartRename,
onCommitRename,
onCancelRename,
onTogglePin,
onDelete,
}: {
chat: Chat;
active: boolean;
renaming: boolean;
dragging: boolean;
dropOver: "before" | "after" | null;
onOpen: () => void;
onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => void;
onStartRename: () => void;
onCommitRename: (title: string) => void;
onCancelRename: () => void;
onTogglePin: () => void;
onDelete: () => void;
}) {
if (renaming) {
return (
<SidebarMenuItem>
<Input
autoFocus
className="h-8 text-sm"
defaultValue={chat.title}
onBlur={(e) => onCommitRename(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onCommitRename(e.currentTarget.value);
if (e.key === "Escape") onCancelRename();
}}
type="text"
/>
</SidebarMenuItem>
);
}
return (
<SidebarMenuItem
className={cn(
"relative",
dragging && "opacity-40",
dropOver === "before" &&
"before:absolute before:-top-px before:right-2 before:left-2 before:h-0.5 before:rounded-full before:bg-primary",
dropOver === "after" &&
"after:absolute after:right-2 after:-bottom-px after:left-2 after:h-0.5 after:rounded-full after:bg-primary",
)}
data-chat-id={chat.id}
>
<SidebarMenuButton
className="!pe-8 cursor-pointer select-none font-[450] active:cursor-grabbing"
isActive={active}
onClick={onOpen}
onDoubleClick={onStartRename}
onPointerDown={onPointerDown}
tooltip={chat.title}
>
{chat.pinned ? (
<IconPin className="!size-3 shrink-0 text-foreground" stroke={1} />
) : null}
<span className="truncate">{chat.title}</span>
</SidebarMenuButton>
<Menu>
<MenuTrigger
render={
<button
aria-label="Действия с чатом"
className="absolute top-1.5 right-1 flex size-6 items-center justify-center rounded-md text-sidebar-foreground opacity-0 transition-opacity hover:bg-sidebar-accent group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[popup-open]:opacity-100 group-data-[collapsible=icon]:hidden [&>svg]:size-4"
type="button"
/>
}
>
<IconDotsVertical fill="currentColor" stroke={1.5} />
</MenuTrigger>
<MenuPopup align="end" className="min-w-40" side="bottom">
<ChatActionMenuItems
onDelete={onDelete}
onRename={onStartRename}
onTogglePin={onTogglePin}
pinned={chat.pinned}
/>
</MenuPopup>
</Menu>
</SidebarMenuItem>
);
}
export default function Particle() {
const [chats, setChats] = React.useState<Chat[]>(INITIAL);
const [activeId, setActiveId] = React.useState("1");
const [renamingId, setRenamingId] = React.useState<string | null>(null);
const [searchOpen, setSearchOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState("");
// pointer drag-to-pin / reorder (1:1 with app/page.tsx handleRowPointerDown)
const [draggingId, setDraggingId] = React.useState<string | null>(null);
const [dragPos, setDragPos] = React.useState<{ x: number; y: number } | null>(
null,
);
const [dropOver, setDropOver] = React.useState<DropOver>(null);
const [dropTarget, setDropTarget] = React.useState<
"pinned" | "recent" | null
>(null);
const dragState = React.useRef<{
id: string;
startX: number;
startY: number;
started: boolean;
lastX: number;
lastY: number;
} | null>(null);
const pinnedDropRef = React.useRef<HTMLDivElement | null>(null);
const recentDropRef = React.useRef<HTMLDivElement | null>(null);
const pinned = chats.filter((c) => c.pinned);
const recent = chats.filter((c) => !c.pinned);
const draggingChat = chats.find((c) => c.id === draggingId) ?? null;
const commitRename = (id: string, title: string) => {
const next = title.trim();
setChats((cs) =>
cs.map((c) => (c.id === id ? { ...c, title: next || c.title } : c)),
);
setRenamingId(null);
};
const togglePin = (id: string) =>
setChats((cs) =>
cs.map((c) => (c.id === id ? { ...c, pinned: !c.pinned } : c)),
);
const setPinned = (id: string, value: boolean) =>
setChats((cs) =>
cs.map((c) => (c.id === id ? { ...c, pinned: value } : c)),
);
const remove = (id: string) =>
setChats((cs) => cs.filter((c) => c.id !== id));
const reorder = (
sourceId: string,
targetId: string,
position: "before" | "after",
) =>
setChats((cs) => {
const src = cs.find((c) => c.id === sourceId);
const target = cs.find((c) => c.id === targetId);
if (!src || !target) return cs;
const rest = cs.filter((c) => c.id !== sourceId);
const idx = rest.findIndex((c) => c.id === targetId);
const insertAt = position === "before" ? idx : idx + 1;
rest.splice(insertAt, 0, { ...src, pinned: target.pinned });
return rest;
});
const isInside = (el: HTMLElement | null, x: number, y: number) => {
if (!el) return false;
const r = el.getBoundingClientRect();
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
};
const findRowAtPoint = (x: number, y: number, sourceId: string): DropOver => {
const el = document.elementFromPoint(x, y) as HTMLElement | null;
const row = el?.closest<HTMLElement>("[data-chat-id]");
const id = row?.dataset.chatId;
if (!row || !id || id === sourceId) return null;
const r = row.getBoundingClientRect();
return { id, position: y < r.top + r.height / 2 ? "before" : "after" };
};
const handleRowPointerDown = (
e: React.PointerEvent<HTMLButtonElement>,
chat: Chat,
) => {
if (e.button !== 0) return;
dragState.current = {
id: chat.id,
startX: e.clientX,
startY: e.clientY,
started: false,
lastX: e.clientX,
lastY: e.clientY,
};
const onMove = (ev: PointerEvent) => {
const s = dragState.current;
if (!s) return;
s.lastX = ev.clientX;
s.lastY = ev.clientY;
if (!s.started) {
if (Math.hypot(ev.clientX - s.startX, ev.clientY - s.startY) < 5)
return;
s.started = true;
setDraggingId(s.id);
document.body.style.userSelect = "none";
}
setDragPos({ x: ev.clientX, y: ev.clientY });
const overRow = findRowAtPoint(ev.clientX, ev.clientY, s.id);
if (overRow) {
setDropOver(overRow);
setDropTarget(null);
return;
}
setDropOver(null);
if (isInside(pinnedDropRef.current, ev.clientX, ev.clientY)) {
setDropTarget("pinned");
} else if (isInside(recentDropRef.current, ev.clientX, ev.clientY)) {
setDropTarget("recent");
} else {
setDropTarget(null);
}
};
const onUp = () => {
const s = dragState.current;
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
document.body.style.userSelect = "";
if (s?.started) {
const overRow = findRowAtPoint(s.lastX, s.lastY, s.id);
if (overRow) {
reorder(s.id, overRow.id, overRow.position);
} else if (isInside(pinnedDropRef.current, s.lastX, s.lastY)) {
setPinned(s.id, true);
} else if (isInside(recentDropRef.current, s.lastX, s.lastY)) {
setPinned(s.id, false);
}
}
dragState.current = null;
setDraggingId(null);
setDragPos(null);
setDropOver(null);
setDropTarget(null);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const row = (chat: Chat) => (
<ChatRow
active={activeId === chat.id}
chat={chat}
dragging={draggingId === chat.id}
dropOver={dropOver?.id === chat.id ? dropOver.position : null}
key={chat.id}
onCancelRename={() => setRenamingId(null)}
onCommitRename={(title) => commitRename(chat.id, title)}
onDelete={() => remove(chat.id)}
onOpen={() => {
if (dragState.current?.started) return;
setActiveId(chat.id);
}}
onPointerDown={(e) => handleRowPointerDown(e, chat)}
onStartRename={() => setRenamingId(chat.id)}
onTogglePin={() => togglePin(chat.id)}
renaming={renamingId === chat.id}
/>
);
const results: ChatSearchResult[] = chats
.filter((c) =>
searchQuery.trim()
? c.title.toLowerCase().includes(searchQuery.trim().toLowerCase())
: true,
)
.map((c) => ({ id: c.id, title: c.title }));
return (
<>
{/* Fills the preview tile (meta zeroes the viewport padding); transform
wrapper contains the sidebar's fixed container, h-full overrides h-svh. */}
<div className="relative h-112.5 w-full overflow-hidden rounded-xl [transform:translateZ(0)]">
<SidebarProvider className="h-full min-h-0">
<Sidebar className="h-full" collapsible="icon">
<SidebarHeader className="gap-5.5">
<div className="flex h-7 w-full items-center ps-2 pe-0 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:ps-0">
<span className="font-medium font-serif text-foreground text-lg tracking-tight group-data-[collapsible=icon]:hidden">
Oracul
</span>
<SidebarTrigger className="ms-auto opacity-70 hover:opacity-100 group-data-[collapsible=icon]:ms-0" />
</div>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton className="ps-1.5" tooltip="Новый чат">
<span className="relative flex size-5 shrink-0 items-center justify-center">
<span
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-1/2 size-6 -translate-x-1/2 -translate-y-1/2 rounded-full bg-accent"
/>
<IconPlus
className="!size-5 relative text-foreground"
stroke={1.25}
/>
</span>
<span>Новый чат</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarMenu className="px-2 pt-0.5">
<SidebarMenuItem>
<SidebarMenuButton
className="ps-1.5"
onClick={() => setSearchOpen(true)}
tooltip="Поиск"
>
<span className="flex size-5 shrink-0 items-center justify-center">
<IconSearch
className="!size-5 text-foreground"
stroke={1.25}
/>
</span>
<span>Поиск</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
className="ps-1.5"
tooltip="Персонализация"
>
<span className="flex size-5 shrink-0 items-center justify-center">
<IconBriefcase
className="!size-5 text-foreground"
stroke={1.25}
/>
</span>
<span>Персонализация</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{pinned.length > 0 ? (
<SidebarGroup
className="group-data-[collapsible=icon]:hidden"
ref={pinnedDropRef}
>
<SidebarGroupLabel className={GROUP_LABEL}>
Избранное
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>{pinned.map(row)}</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
) : draggingId ? (
<div className="overflow-hidden px-2 group-data-[collapsible=icon]:hidden">
<div className="pt-2">
<div className="mb-1.5 px-1 text-muted-foreground text-xs">
Избранное
</div>
<div
className={cn(
"flex min-h-16 items-center justify-center gap-2 rounded-lg border-2 border-dashed px-3 py-4 text-center font-medium text-sm transition-all",
dropTarget === "pinned"
? "scale-[1.02] border-primary bg-primary/10 text-foreground shadow-sm"
: "border-foreground/40 bg-accent/60 text-foreground/80",
)}
ref={pinnedDropRef}
>
<span className="whitespace-pre-line leading-tight">
{"Перетащите чат сюда,\nчтобы закрепить"}
</span>
</div>
</div>
</div>
) : null}
{recent.length > 0 ? (
<SidebarGroup
className="group-data-[collapsible=icon]:hidden"
ref={recentDropRef}
>
<SidebarGroupLabel className={GROUP_LABEL}>
Недавние
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="[&_[data-slot=sidebar-menu-button]:not([data-active=true])]:text-foreground">
{recent.map(row)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
) : null}
</SidebarContent>
<SidebarFooter className="border-sidebar-border border-t p-0">
<Menu>
<MenuTrigger
render={
<button
className="group/profile flex h-16 w-full items-center gap-3 overflow-hidden ps-1.5 pe-2 text-left outline-none transition-colors hover:bg-accent focus-visible:bg-accent data-[popup-open]:bg-accent"
type="button"
/>
}
>
<Avatar className="size-9 shrink-0">
<AvatarFallback className="bg-foreground text-background text-sm">
NI
</AvatarFallback>
</Avatar>
<span className="flex min-w-0 flex-1 flex-col text-left group-data-[collapsible=icon]:hidden">
<span className="truncate font-semibold text-foreground text-sm leading-5">
Nike
</span>
<span className="truncate text-muted-foreground text-xs leading-4">
nike@oracul.cloud
</span>
</span>
<IconSelector
className="size-4 shrink-0 text-muted-foreground group-data-[collapsible=icon]:hidden"
stroke={1.5}
/>
</MenuTrigger>
<MenuPopup
align="start"
className="min-w-52"
side="top"
sideOffset={8}
>
<div className="flex items-center gap-2 px-2 py-1.5">
<Avatar className="size-8">
<AvatarFallback className="bg-foreground text-background text-xs">
NI
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium text-foreground text-sm">
Nike
</span>
<span className="truncate text-2xs text-muted-foreground">
nike@oracul.cloud
</span>
</div>
</div>
<MenuSeparator />
<MenuItem>
<IconSettings stroke={1.5} />
Настройки
</MenuItem>
<MenuSeparator />
<MenuItem>
<IconLogout stroke={1.5} />
Выйти
</MenuItem>
</MenuPopup>
</Menu>
</SidebarFooter>
</Sidebar>
<SidebarInset className="hidden place-items-center bg-background text-muted-foreground text-sm sm:grid">
Добрый вечер, Nike
</SidebarInset>
</SidebarProvider>
<ChatSearchDialog
onOpenChange={setSearchOpen}
onQueryChange={setSearchQuery}
onSelect={(r) => {
setActiveId(r.id);
setSearchOpen(false);
}}
open={searchOpen}
placeholder="Поиск по чатам…"
query={searchQuery}
results={results}
/>
</div>
{/* Drag overlay — a chip following the cursor (1:1 with the app). Sibling
of the transform wrapper so `fixed` stays viewport-relative. */}
{draggingId && draggingChat && dragPos ? (
<div
className="pointer-events-none fixed z-50 rounded-md border bg-card px-3 py-1.5 font-medium text-foreground text-sm shadow-lg"
style={{ left: dragPos.x + 12, top: dragPos.y + 12 }}
>
{draggingChat.title}
</div>
) : null}
</>
);
}
Установка
pnpm dlx shadcn@latest add @oracul/sidebar
Использование
Оберни приложение в SidebarProvider и собери Sidebar из частей. Для рейла
со сворачиванием по иконкам используй collapsible="icon" (в демо выше —
collapsible="none", чтобы поместиться в превью).
import {
SidebarProvider,
Sidebar,
SidebarHeader,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarFooter,
SidebarInset,
SidebarTrigger,
} from "@/components/ui/sidebar";
<SidebarProvider>
<Sidebar collapsible="icon">
<SidebarHeader>…бренд + SidebarTrigger…</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton>Новый чат</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Недавние</SidebarGroupLabel>
{/* список чатов */}
</SidebarGroup>
</SidebarContent>
<SidebarFooter>{/* пользователь + меню */}</SidebarFooter>
</Sidebar>
<SidebarInset>{/* контент приложения */}</SidebarInset>
</SidebarProvider>;Подкомпоненты
SidebarProvider (состояние + ⌘B), Sidebar (collapsible: "offcanvas" | "icon" | "none"), SidebarHeader / SidebarContent / SidebarFooter,
SidebarGroup / SidebarGroupLabel / SidebarGroupContent / SidebarGroupAction,
SidebarMenu / SidebarMenuItem / SidebarMenuButton / SidebarMenuAction /
SidebarMenuBadge / SidebarMenuSkeleton, SidebarMenuSub*, SidebarTrigger,
SidebarRail, SidebarInset, SidebarInput, SidebarSeparator.
На этой странице