Add assistant-ui data agent frontend
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
MessageState,
|
||||
ThreadAssistantMessagePart,
|
||||
ToolCallMessagePart,
|
||||
ToolCallMessagePartStatus,
|
||||
} from "@assistant-ui/react";
|
||||
import { useAuiState } from "@assistant-ui/react";
|
||||
import {
|
||||
BrainIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ClockIcon,
|
||||
FileTextIcon,
|
||||
PanelRightCloseIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ActivityContextValue = {
|
||||
open: boolean;
|
||||
selectedId: string | null;
|
||||
openItem: (id: string) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
const ActivityContext = createContext<ActivityContextValue | null>(null);
|
||||
|
||||
export function ActivityProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const openItem = useCallback((id: string) => {
|
||||
setSelectedId(id);
|
||||
setOpen(true);
|
||||
}, []);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
open,
|
||||
selectedId,
|
||||
openItem,
|
||||
close,
|
||||
}),
|
||||
[open, selectedId, openItem, close],
|
||||
);
|
||||
|
||||
return (
|
||||
<ActivityContext.Provider value={value}>
|
||||
{children}
|
||||
</ActivityContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useActivityPanel() {
|
||||
const value = useContext(ActivityContext);
|
||||
if (!value) {
|
||||
throw new Error("useActivityPanel must be used within ActivityProvider");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
type ActivityItem = {
|
||||
id: string;
|
||||
kind: "reasoning" | "tool";
|
||||
title: string;
|
||||
summary: string;
|
||||
status: ToolCallMessagePartStatus["type"];
|
||||
argsText?: string;
|
||||
result?: unknown;
|
||||
resultSummary?: string;
|
||||
rawResult?: string;
|
||||
fileLinks?: string[];
|
||||
};
|
||||
|
||||
export function ActivityPanel() {
|
||||
const { open, selectedId, openItem, close } = useActivityPanel();
|
||||
const messages = useAuiState((s) => s.thread.messages);
|
||||
const items = useMemo(() => collectActivityItems(messages), [messages]);
|
||||
const prevCountRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length > prevCountRef.current) {
|
||||
openItem(items.at(-1)?.id ?? items[0]?.id ?? "");
|
||||
}
|
||||
prevCountRef.current = items.length;
|
||||
}, [items, openItem]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<aside className="fixed inset-y-0 right-0 z-30 flex w-[min(23rem,calc(100vw-1rem))] flex-col border-l bg-background shadow-xl lg:static lg:z-auto lg:h-full lg:w-92 lg:shrink-0 lg:shadow-none">
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold text-base">活动</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{items.length > 0 ? `${items.length} 个步骤` : "暂无链路"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={close}
|
||||
aria-label="关闭活动面板"
|
||||
>
|
||||
<PanelRightCloseIcon className="size-4" />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
当前对话还没有工具链路。
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
<ActivityPanelItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
open={selectedId === item.id}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen) openItem(item.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityPanelItem({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
item: ActivityItem;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const Icon = item.kind === "tool" ? WrenchIcon : BrainIcon;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
className="rounded-lg border bg-card text-card-foreground"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-start gap-3 px-3 py-3 text-left">
|
||||
<ActivityStatusIcon status={item.status} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium text-sm">{item.title}</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-muted-foreground text-xs">
|
||||
{item.summary}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"mt-0.5 size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
!open && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="flex flex-col gap-3 border-t px-3 py-3 text-xs">
|
||||
{item.argsText ? (
|
||||
<ActivityPre title="输入参数" value={item.argsText} />
|
||||
) : null}
|
||||
{item.resultSummary ? (
|
||||
<ActivityResultSummary value={item.resultSummary} />
|
||||
) : null}
|
||||
{item.fileLinks?.length ? (
|
||||
<ActivityFileLinks paths={item.fileLinks} />
|
||||
) : null}
|
||||
{item.rawResult ? (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex items-center gap-1 font-medium text-muted-foreground transition-colors hover:text-foreground">
|
||||
<ChevronDownIcon className="size-3" />
|
||||
原始结果
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ActivityPre title="" value={item.rawResult} className="mt-2" />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
{item.kind === "reasoning" ? (
|
||||
<p className="text-muted-foreground">
|
||||
这里展示的是思考状态摘要,不展示模型完整隐藏思考过程。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityStatusIcon({ status }: { status: ActivityItem["status"] }) {
|
||||
if (status === "running") {
|
||||
return <ClockIcon className="mt-0.5 size-4 shrink-0 animate-pulse" />;
|
||||
}
|
||||
if (status === "incomplete") {
|
||||
return <XCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />;
|
||||
}
|
||||
return (
|
||||
<CheckCircle2Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityPre({
|
||||
title,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{title ? (
|
||||
<p className="mb-1 font-medium text-muted-foreground">{title}</p>
|
||||
) : null}
|
||||
<pre className="max-h-80 overflow-auto rounded-md bg-muted px-3 py-2 whitespace-pre-wrap">
|
||||
{value}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityResultSummary({ value }: { value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2">
|
||||
<div className="mb-1 flex items-center gap-1.5 font-medium text-muted-foreground">
|
||||
<FileTextIcon className="size-3.5" />
|
||||
结果摘要
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function collectActivityItems(messages: readonly MessageState[]) {
|
||||
const items: ActivityItem[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role !== "assistant") continue;
|
||||
const parts = message.content as readonly ThreadAssistantMessagePart[];
|
||||
for (const [index, part] of parts.entries()) {
|
||||
const id = `${message.id}:${index}`;
|
||||
if (part.type === "reasoning") {
|
||||
const status = getPartStatus(message, part);
|
||||
items.push({
|
||||
id,
|
||||
kind: "reasoning",
|
||||
title: status === "running" ? "思考中" : "思考完成",
|
||||
summary:
|
||||
part.text.trim() ||
|
||||
(status === "running"
|
||||
? "模型正在整理下一步行动。"
|
||||
: "思考并执行完成。"),
|
||||
status,
|
||||
});
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
items.push({
|
||||
id,
|
||||
kind: "tool",
|
||||
title: part.toolName,
|
||||
summary: summarizeTool(message, part),
|
||||
status: getPartStatus(message, part),
|
||||
argsText: part.argsText,
|
||||
result: decodeJsonString(part.result),
|
||||
resultSummary: summarizeToolResult(decodeJsonString(part.result)),
|
||||
rawResult:
|
||||
part.result === undefined
|
||||
? undefined
|
||||
: formatValue(decodeJsonString(part.result)),
|
||||
fileLinks: extractLocalPaths(
|
||||
[
|
||||
part.argsText,
|
||||
part.result === undefined
|
||||
? undefined
|
||||
: formatValue(decodeJsonString(part.result)),
|
||||
].filter(Boolean) as string[],
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function ActivityFileLinks({ paths }: { paths: string[] }) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2">
|
||||
<div className="mb-1 flex items-center gap-1.5 font-medium text-muted-foreground">
|
||||
<FileTextIcon className="size-3.5" />
|
||||
相关文件
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{paths.map((filePath) => (
|
||||
<a
|
||||
key={filePath}
|
||||
href={`/api/claw/files?path=${encodeURIComponent(filePath)}`}
|
||||
className="truncate text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={filePath}
|
||||
>
|
||||
{basename(filePath)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPartStatus(
|
||||
message: Extract<MessageState, { role: "assistant" }>,
|
||||
part: ThreadAssistantMessagePart,
|
||||
): ToolCallMessagePartStatus["type"] {
|
||||
if (message.status.type === "incomplete") return "incomplete";
|
||||
if (part.type === "tool-call" && part.result === undefined) return "running";
|
||||
if (message.status.type === "running") return "running";
|
||||
return "complete";
|
||||
}
|
||||
|
||||
function summarizeTool(
|
||||
message: Extract<MessageState, { role: "assistant" }>,
|
||||
part: ToolCallMessagePart,
|
||||
) {
|
||||
const status = getPartStatus(message, part);
|
||||
if (status === "running") return "调用工具中";
|
||||
if (status === "incomplete") return "工具调用未完成";
|
||||
if (part.result === undefined) return "已提交工具参数";
|
||||
return "工具调用完成";
|
||||
}
|
||||
|
||||
function decodeJsonString(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function formatValue(value: unknown) {
|
||||
const decoded = decodeJsonString(value);
|
||||
return typeof decoded === "string"
|
||||
? decoded
|
||||
: JSON.stringify(decoded, null, 2);
|
||||
}
|
||||
|
||||
function summarizeToolResult(value: unknown) {
|
||||
const decoded = decodeJsonString(value);
|
||||
if (decoded === undefined) return undefined;
|
||||
if (typeof decoded === "string") return trimText(decoded, 600);
|
||||
if (!decoded || typeof decoded !== "object") return String(decoded);
|
||||
|
||||
const record = decoded as Record<string, unknown>;
|
||||
const parts: string[] = [];
|
||||
if (typeof record.tool === "string") parts.push(`工具: ${record.tool}`);
|
||||
if (typeof record.ok === "boolean")
|
||||
parts.push(record.ok ? "状态: 成功" : "状态: 失败");
|
||||
if (typeof record.error === "string") parts.push(`错误: ${record.error}`);
|
||||
if (typeof record.content === "string")
|
||||
parts.push(trimText(record.content, 600));
|
||||
if (!parts.length) parts.push(trimText(JSON.stringify(record, null, 2), 600));
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function trimText(text: string, maxLength: number) {
|
||||
const normalized = text.trim();
|
||||
if (normalized.length <= maxLength) return normalized;
|
||||
return `${normalized.slice(0, maxLength)}...`;
|
||||
}
|
||||
|
||||
function extractLocalPaths(values: string[]) {
|
||||
const paths = new Set<string>();
|
||||
for (const value of values) {
|
||||
for (const match of value.matchAll(/\/Users\/[^\s"',)]+/g)) {
|
||||
paths.add(match[0]);
|
||||
}
|
||||
}
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
function basename(filePath: string) {
|
||||
return filePath.split("/").filter(Boolean).at(-1) ?? filePath;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AttachmentPrimitive,
|
||||
ComposerPrimitive,
|
||||
MessagePrimitive,
|
||||
useAui,
|
||||
useAuiState,
|
||||
} from "@assistant-ui/react";
|
||||
import { FileText, PlusIcon, XIcon } from "lucide-react";
|
||||
import { type FC, type PropsWithChildren, useEffect, useState } from "react";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const useFileSrc = (file: File | undefined) => {
|
||||
const [src, setSrc] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
setSrc(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setSrc(objectUrl);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
return src;
|
||||
};
|
||||
|
||||
const useAttachmentSrc = () => {
|
||||
const { file, src } = useAuiState(
|
||||
useShallow((s): { file?: File; src?: string } => {
|
||||
if (s.attachment.type !== "image") return {};
|
||||
if (s.attachment.file) return { file: s.attachment.file };
|
||||
const src = s.attachment.content?.filter((c) => c.type === "image")[0]
|
||||
?.image;
|
||||
if (!src) return {};
|
||||
return { src };
|
||||
}),
|
||||
);
|
||||
|
||||
return useFileSrc(file) ?? src;
|
||||
};
|
||||
|
||||
type AttachmentPreviewProps = {
|
||||
src: string;
|
||||
};
|
||||
|
||||
const AttachmentPreview: FC<AttachmentPreviewProps> = ({ src }) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt="Attachment preview"
|
||||
className={cn(
|
||||
"block h-auto max-h-[80vh] w-auto max-w-full object-contain",
|
||||
isLoaded
|
||||
? "aui-attachment-preview-image-loaded"
|
||||
: "aui-attachment-preview-image-loading invisible",
|
||||
)}
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const AttachmentPreviewDialog: FC<PropsWithChildren> = ({ children }) => {
|
||||
const src = useAttachmentSrc();
|
||||
|
||||
if (!src) return children;
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
className="aui-attachment-preview-trigger cursor-pointer transition-colors hover:bg-accent/50"
|
||||
asChild
|
||||
>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="aui-attachment-preview-dialog-content p-2 sm:max-w-3xl [&>button]:rounded-full [&>button]:bg-foreground/60 [&>button]:p-1 [&>button]:opacity-100 [&>button]:ring-0! [&_svg]:text-background [&>button]:hover:[&_svg]:text-destructive">
|
||||
<DialogTitle className="aui-sr-only sr-only">
|
||||
Image Attachment Preview
|
||||
</DialogTitle>
|
||||
<div className="aui-attachment-preview relative mx-auto flex max-h-[80dvh] w-full items-center justify-center overflow-hidden bg-background">
|
||||
<AttachmentPreview src={src} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const AttachmentThumb: FC = () => {
|
||||
const src = useAttachmentSrc();
|
||||
|
||||
return (
|
||||
<Avatar className="aui-attachment-tile-avatar h-full w-full rounded-none">
|
||||
<AvatarImage
|
||||
src={src}
|
||||
alt="Attachment preview"
|
||||
className="aui-attachment-tile-image object-cover"
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<FileText className="aui-attachment-tile-fallback-icon size-8 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
|
||||
const AttachmentUI: FC = () => {
|
||||
const aui = useAui();
|
||||
const isComposer = aui.attachment.source !== "message";
|
||||
|
||||
const isImage = useAuiState((s) => s.attachment.type === "image");
|
||||
const typeLabel = useAuiState((s) => {
|
||||
const type = s.attachment.type;
|
||||
switch (type) {
|
||||
case "image":
|
||||
return "Image";
|
||||
case "document":
|
||||
return "Document";
|
||||
case "file":
|
||||
return "File";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<AttachmentPrimitive.Root
|
||||
className={cn(
|
||||
"aui-attachment-root relative",
|
||||
isImage && "aui-attachment-root-composer only:*:first:size-24",
|
||||
)}
|
||||
>
|
||||
<AttachmentPreviewDialog>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[calc(var(--composer-radius)-var(--composer-padding))] border bg-muted transition-opacity hover:opacity-75"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${typeLabel} attachment`}
|
||||
>
|
||||
<AttachmentThumb />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
</AttachmentPreviewDialog>
|
||||
{isComposer && <AttachmentRemove />}
|
||||
</AttachmentPrimitive.Root>
|
||||
<TooltipContent side="top">
|
||||
<AttachmentPrimitive.Name />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const AttachmentRemove: FC = () => {
|
||||
return (
|
||||
<AttachmentPrimitive.Remove asChild>
|
||||
<TooltipIconButton
|
||||
tooltip="Remove file"
|
||||
className="aui-attachment-tile-remove absolute top-1.5 right-1.5 size-3.5 rounded-full bg-white text-muted-foreground opacity-100 shadow-sm hover:bg-white! [&_svg]:text-black hover:[&_svg]:text-destructive"
|
||||
side="top"
|
||||
>
|
||||
<XIcon className="aui-attachment-remove-icon size-3 dark:stroke-[2.5px]" />
|
||||
</TooltipIconButton>
|
||||
</AttachmentPrimitive.Remove>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserMessageAttachments: FC = () => {
|
||||
return (
|
||||
<div className="aui-user-message-attachments-end col-span-full col-start-1 row-start-1 flex w-full flex-row justify-end gap-2">
|
||||
<MessagePrimitive.Attachments>
|
||||
{() => <AttachmentUI />}
|
||||
</MessagePrimitive.Attachments>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComposerAttachments: FC = () => {
|
||||
return (
|
||||
<div className="aui-composer-attachments flex w-full flex-row items-center gap-2 overflow-x-auto empty:hidden">
|
||||
<ComposerPrimitive.Attachments>
|
||||
{() => <AttachmentUI />}
|
||||
</ComposerPrimitive.Attachments>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComposerAddAttachment: FC = () => {
|
||||
return (
|
||||
<ComposerPrimitive.AddAttachment asChild>
|
||||
<TooltipIconButton
|
||||
tooltip="Add Attachment"
|
||||
side="bottom"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="aui-composer-add-attachment size-8 rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
||||
aria-label="Add Attachment"
|
||||
>
|
||||
<PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]" />
|
||||
</TooltipIconButton>
|
||||
</ComposerPrimitive.AddAttachment>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import "@assistant-ui/react-markdown/styles/dot.css";
|
||||
|
||||
import {
|
||||
type CodeHeaderProps,
|
||||
MarkdownTextPrimitive,
|
||||
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
|
||||
useIsMarkdownCodeBlock,
|
||||
} from "@assistant-ui/react-markdown";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { type FC, memo, useState } from "react";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MarkdownTextImpl = () => {
|
||||
return (
|
||||
<MarkdownTextPrimitive
|
||||
remarkPlugins={[remarkGfm]}
|
||||
className="aui-md"
|
||||
components={defaultComponents}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const MarkdownText = memo(MarkdownTextImpl);
|
||||
|
||||
const CodeHeader: FC<CodeHeaderProps> = ({ language, code }) => {
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard();
|
||||
const onCopy = () => {
|
||||
if (!code || isCopied) return;
|
||||
copyToClipboard(code);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="aui-code-header-root mt-2.5 flex items-center justify-between rounded-t-lg border border-border/50 border-b-0 bg-muted/50 px-3 py-1.5 text-xs">
|
||||
<span className="aui-code-header-language font-medium text-muted-foreground lowercase">
|
||||
{language}
|
||||
</span>
|
||||
<TooltipIconButton tooltip="Copy" onClick={onCopy}>
|
||||
{!isCopied && <CopyIcon />}
|
||||
{isCopied && <CheckIcon />}
|
||||
</TooltipIconButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useCopyToClipboard = ({
|
||||
copiedDuration = 3000,
|
||||
}: {
|
||||
copiedDuration?: number;
|
||||
} = {}) => {
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false);
|
||||
|
||||
const copyToClipboard = (value: string) => {
|
||||
if (!value) return;
|
||||
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), copiedDuration);
|
||||
});
|
||||
};
|
||||
|
||||
return { isCopied, copyToClipboard };
|
||||
};
|
||||
|
||||
const defaultComponents = memoizeMarkdownComponents({
|
||||
h1: ({ className, ...props }) => (
|
||||
<h1
|
||||
className={cn(
|
||||
"aui-md-h1 mb-2 scroll-m-20 font-semibold text-base first:mt-0 last:mb-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h2: ({ className, ...props }) => (
|
||||
<h2
|
||||
className={cn(
|
||||
"aui-md-h2 mt-3 mb-1.5 scroll-m-20 font-semibold text-sm first:mt-0 last:mb-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h3: ({ className, ...props }) => (
|
||||
<h3
|
||||
className={cn(
|
||||
"aui-md-h3 mt-2.5 mb-1 scroll-m-20 font-semibold text-sm first:mt-0 last:mb-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h4: ({ className, ...props }) => (
|
||||
<h4
|
||||
className={cn(
|
||||
"aui-md-h4 mt-2 mb-1 scroll-m-20 font-medium text-sm first:mt-0 last:mb-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h5: ({ className, ...props }) => (
|
||||
<h5
|
||||
className={cn(
|
||||
"aui-md-h5 mt-2 mb-1 font-medium text-sm first:mt-0 last:mb-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h6: ({ className, ...props }) => (
|
||||
<h6
|
||||
className={cn(
|
||||
"aui-md-h6 mt-2 mb-1 font-medium text-sm first:mt-0 last:mb-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
p: ({ className, ...props }) => (
|
||||
<p
|
||||
className={cn(
|
||||
"aui-md-p my-2.5 leading-normal first:mt-0 last:mb-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
a: ({ className, ...props }) => (
|
||||
<a
|
||||
className={cn(
|
||||
"aui-md-a text-primary underline underline-offset-2 hover:text-primary/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
blockquote: ({ className, ...props }) => (
|
||||
<blockquote
|
||||
className={cn(
|
||||
"aui-md-blockquote my-2.5 border-muted-foreground/30 border-l-2 pl-3 text-muted-foreground italic",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ className, ...props }) => (
|
||||
<ul
|
||||
className={cn(
|
||||
"aui-md-ul my-2 ml-4 list-disc marker:text-muted-foreground [&>li]:mt-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ol: ({ className, ...props }) => (
|
||||
<ol
|
||||
className={cn(
|
||||
"aui-md-ol my-2 ml-4 list-decimal marker:text-muted-foreground [&>li]:mt-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
hr: ({ className, ...props }) => (
|
||||
<hr
|
||||
className={cn("aui-md-hr my-2 border-muted-foreground/20", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
table: ({ className, ...props }) => (
|
||||
<table
|
||||
className={cn(
|
||||
"aui-md-table my-2 w-full border-separate border-spacing-0 overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
th: ({ className, ...props }) => (
|
||||
<th
|
||||
className={cn(
|
||||
"aui-md-th bg-muted px-2 py-1 text-left font-medium first:rounded-tl-lg last:rounded-tr-lg [[align=center]]:text-center [[align=right]]:text-right",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
td: ({ className, ...props }) => (
|
||||
<td
|
||||
className={cn(
|
||||
"aui-md-td border-muted-foreground/20 border-b border-l px-2 py-1 text-left last:border-r [[align=center]]:text-center [[align=right]]:text-right",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
tr: ({ className, ...props }) => (
|
||||
<tr
|
||||
className={cn(
|
||||
"aui-md-tr m-0 border-b p-0 first:border-t [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
li: ({ className, ...props }) => (
|
||||
<li className={cn("aui-md-li leading-normal", className)} {...props} />
|
||||
),
|
||||
sup: ({ className, ...props }) => (
|
||||
<sup
|
||||
className={cn("aui-md-sup [&>a]:text-xs [&>a]:no-underline", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
pre: ({ className, ...props }) => (
|
||||
<pre
|
||||
className={cn(
|
||||
"aui-md-pre overflow-x-auto rounded-t-none rounded-b-lg border border-border/50 border-t-0 bg-muted/30 p-3 text-xs leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
code: function Code({ className, ...props }) {
|
||||
const isCodeBlock = useIsMarkdownCodeBlock();
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
!isCodeBlock &&
|
||||
"aui-md-inline-code rounded-md border border-border/50 bg-muted/50 px-1.5 py-0.5 font-mono text-[0.85em]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
CodeHeader,
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ReasoningGroupComponent,
|
||||
type ReasoningMessagePartComponent,
|
||||
useAuiState,
|
||||
useScrollLock,
|
||||
} from "@assistant-ui/react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ANIMATION_DURATION = 200;
|
||||
|
||||
const reasoningVariants = cva("aui-reasoning-root mb-4 w-full", {
|
||||
variants: {
|
||||
variant: {
|
||||
outline: "rounded-lg border px-3 py-2",
|
||||
ghost: "",
|
||||
muted: "rounded-lg bg-muted/50 px-3 py-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "outline",
|
||||
},
|
||||
});
|
||||
|
||||
export type ReasoningRootProps = Omit<
|
||||
React.ComponentProps<typeof Collapsible>,
|
||||
"open" | "onOpenChange"
|
||||
> &
|
||||
VariantProps<typeof reasoningVariants> & {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
defaultOpen?: boolean;
|
||||
};
|
||||
|
||||
function ReasoningRoot({
|
||||
className,
|
||||
variant,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
defaultOpen = false,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningRootProps) {
|
||||
const collapsibleRef = useRef<HTMLDivElement>(null);
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
||||
const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
lockScroll();
|
||||
}
|
||||
if (!isControlled) {
|
||||
setUncontrolledOpen(open);
|
||||
}
|
||||
controlledOnOpenChange?.(open);
|
||||
},
|
||||
[lockScroll, isControlled, controlledOnOpenChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
ref={collapsibleRef}
|
||||
data-slot="reasoning-root"
|
||||
data-variant={variant}
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
className={cn(
|
||||
"group/reasoning-root",
|
||||
reasoningVariants({ variant, className }),
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--animation-duration": `${ANIMATION_DURATION}ms`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasoningFade({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="reasoning-fade"
|
||||
className={cn(
|
||||
"aui-reasoning-fade pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8",
|
||||
"bg-[linear-gradient(to_top,var(--color-background),transparent)]",
|
||||
"group-data-[variant=muted]/reasoning-root:bg-[linear-gradient(to_top,hsl(var(--muted)/0.5),transparent)]",
|
||||
"fade-in-0 animate-in",
|
||||
"group-data-[state=open]/collapsible-content:animate-out",
|
||||
"group-data-[state=open]/collapsible-content:fade-out-0",
|
||||
"group-data-[state=open]/collapsible-content:delay-[calc(var(--animation-duration)*0.75)]",
|
||||
"group-data-[state=open]/collapsible-content:fill-mode-forwards",
|
||||
"duration-(--animation-duration)",
|
||||
"group-data-[state=open]/collapsible-content:duration-(--animation-duration)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasoningTrigger({
|
||||
active,
|
||||
duration,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsibleTrigger> & {
|
||||
active?: boolean;
|
||||
duration?: number;
|
||||
}) {
|
||||
const durationText = duration ? ` (${duration}s)` : "";
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
data-slot="reasoning-trigger"
|
||||
className={cn(
|
||||
"aui-reasoning-trigger group/trigger flex max-w-[75%] items-center gap-2 py-1 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<BrainIcon
|
||||
data-slot="reasoning-trigger-icon"
|
||||
className="aui-reasoning-trigger-icon size-4 shrink-0"
|
||||
/>
|
||||
<span
|
||||
data-slot="reasoning-trigger-label"
|
||||
className="aui-reasoning-trigger-label-wrapper relative inline-block leading-none"
|
||||
>
|
||||
<span>Reasoning{durationText}</span>
|
||||
{active ? (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="reasoning-trigger-shimmer"
|
||||
className="aui-reasoning-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none"
|
||||
>
|
||||
Reasoning{durationText}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
data-slot="reasoning-trigger-chevron"
|
||||
className={cn(
|
||||
"aui-reasoning-trigger-chevron mt-0.5 size-4 shrink-0",
|
||||
"transition-transform duration-(--animation-duration) ease-out",
|
||||
"group-data-[state=closed]/trigger:-rotate-90",
|
||||
"group-data-[state=open]/trigger:rotate-0",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasoningContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsibleContent
|
||||
data-slot="reasoning-content"
|
||||
className={cn(
|
||||
"aui-reasoning-content relative overflow-hidden text-muted-foreground text-sm outline-none",
|
||||
"group/collapsible-content ease-out",
|
||||
"data-[state=closed]:animate-collapsible-up",
|
||||
"data-[state=open]:animate-collapsible-down",
|
||||
"data-[state=closed]:fill-mode-forwards",
|
||||
"data-[state=closed]:pointer-events-none",
|
||||
"data-[state=open]:duration-(--animation-duration)",
|
||||
"data-[state=closed]:duration-(--animation-duration)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ReasoningFade />
|
||||
</CollapsibleContent>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasoningText({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="reasoning-text"
|
||||
className={cn(
|
||||
"aui-reasoning-text relative z-0 max-h-64 space-y-4 overflow-y-auto pt-2 pb-2 pl-6 leading-relaxed",
|
||||
"transform-gpu transition-[transform,opacity]",
|
||||
"group-data-[state=open]/collapsible-content:animate-in",
|
||||
"group-data-[state=closed]/collapsible-content:animate-out",
|
||||
"group-data-[state=open]/collapsible-content:fade-in-0",
|
||||
"group-data-[state=closed]/collapsible-content:fade-out-0",
|
||||
"group-data-[state=open]/collapsible-content:slide-in-from-top-4",
|
||||
"group-data-[state=closed]/collapsible-content:slide-out-to-top-4",
|
||||
"group-data-[state=open]/collapsible-content:duration-(--animation-duration)",
|
||||
"group-data-[state=closed]/collapsible-content:duration-(--animation-duration)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const ReasoningImpl: ReasoningMessagePartComponent = () => <MarkdownText />;
|
||||
|
||||
const ReasoningGroupImpl: ReasoningGroupComponent = ({
|
||||
children,
|
||||
startIndex,
|
||||
endIndex,
|
||||
}) => {
|
||||
const isReasoningStreaming = useAuiState((s) => {
|
||||
if (s.message.status?.type !== "running") return false;
|
||||
const lastIndex = s.message.parts.length - 1;
|
||||
if (lastIndex < 0) return false;
|
||||
const lastType = s.message.parts[lastIndex]?.type;
|
||||
if (lastType !== "reasoning") return false;
|
||||
return lastIndex >= startIndex && lastIndex <= endIndex;
|
||||
});
|
||||
|
||||
return (
|
||||
<ReasoningRoot defaultOpen={isReasoningStreaming}>
|
||||
<ReasoningTrigger active={isReasoningStreaming} />
|
||||
<ReasoningContent aria-busy={isReasoningStreaming}>
|
||||
<ReasoningText>{children}</ReasoningText>
|
||||
</ReasoningContent>
|
||||
</ReasoningRoot>
|
||||
);
|
||||
};
|
||||
|
||||
const Reasoning = memo(
|
||||
ReasoningImpl,
|
||||
) as unknown as ReasoningMessagePartComponent & {
|
||||
Root: typeof ReasoningRoot;
|
||||
Trigger: typeof ReasoningTrigger;
|
||||
Content: typeof ReasoningContent;
|
||||
Text: typeof ReasoningText;
|
||||
Fade: typeof ReasoningFade;
|
||||
};
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
Reasoning.Root = ReasoningRoot;
|
||||
Reasoning.Trigger = ReasoningTrigger;
|
||||
Reasoning.Content = ReasoningContent;
|
||||
Reasoning.Text = ReasoningText;
|
||||
Reasoning.Fade = ReasoningFade;
|
||||
|
||||
const ReasoningGroup = memo(ReasoningGroupImpl);
|
||||
ReasoningGroup.displayName = "ReasoningGroup";
|
||||
|
||||
export {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningFade,
|
||||
ReasoningGroup,
|
||||
ReasoningRoot,
|
||||
ReasoningText,
|
||||
ReasoningTrigger,
|
||||
reasoningVariants,
|
||||
};
|
||||
@@ -0,0 +1,420 @@
|
||||
import {
|
||||
bindExternalStoreMessage,
|
||||
type ExportedMessageRepository,
|
||||
type ThreadMessage,
|
||||
} from "@assistant-ui/core";
|
||||
import {
|
||||
AuiIf,
|
||||
ThreadListItemMorePrimitive,
|
||||
ThreadListItemPrimitive,
|
||||
ThreadListPrimitive,
|
||||
} from "@assistant-ui/react";
|
||||
import type { UIMessage } from "ai";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
HistoryIcon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useClawSessionReplay } from "@/lib/claw-session-replay";
|
||||
|
||||
type ClawSession = {
|
||||
session_id: string;
|
||||
turns: number;
|
||||
tool_calls: number;
|
||||
preview: string;
|
||||
modified_at: number;
|
||||
};
|
||||
|
||||
type ClawStoredMessage = {
|
||||
role?: string;
|
||||
content?: string;
|
||||
name?: string;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: ClawStoredToolCall[];
|
||||
metadata?: {
|
||||
elapsed_ms?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type ClawStoredSession = {
|
||||
session_id: string;
|
||||
messages?: ClawStoredMessage[];
|
||||
};
|
||||
|
||||
type ClawStoredToolCall = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
arguments?: unknown;
|
||||
function?: {
|
||||
name?: string;
|
||||
arguments?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export const ThreadList: FC = () => {
|
||||
return (
|
||||
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex flex-col gap-1">
|
||||
<ThreadListNew />
|
||||
<AuiIf condition={({ threads }) => threads.isLoading}>
|
||||
<ThreadListSkeleton />
|
||||
</AuiIf>
|
||||
<AuiIf condition={({ threads }) => !threads.isLoading}>
|
||||
<ThreadListPrimitive.Items>
|
||||
{() => <ThreadListItem />}
|
||||
</ThreadListPrimitive.Items>
|
||||
</AuiIf>
|
||||
<ClawSessionList />
|
||||
</ThreadListPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadListNew: FC = () => {
|
||||
return (
|
||||
<div
|
||||
onClickCapture={() => {
|
||||
window.localStorage.removeItem("claw.activeSessionId");
|
||||
window.dispatchEvent(new Event("claw-active-session-cleared"));
|
||||
}}
|
||||
>
|
||||
<ThreadListPrimitive.New asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="aui-thread-list-new h-9 w-full justify-start gap-2 rounded-lg px-3 text-sm hover:bg-muted data-active:bg-muted"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
New Thread
|
||||
</Button>
|
||||
</ThreadListPrimitive.New>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadListSkeleton: FC = () => {
|
||||
const skeletonKeys = ["one", "two", "three", "four", "five"];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{skeletonKeys.map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
role="status"
|
||||
aria-label="Loading threads"
|
||||
className="aui-thread-list-skeleton-wrapper flex h-9 items-center px-3"
|
||||
>
|
||||
<Skeleton className="aui-thread-list-skeleton h-4 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadListItem: FC = () => {
|
||||
return (
|
||||
<ThreadListItemPrimitive.Root className="aui-thread-list-item group flex h-9 items-center gap-2 rounded-lg transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none data-active:bg-muted">
|
||||
<ThreadListItemPrimitive.Trigger className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm">
|
||||
<ThreadListItemPrimitive.Title fallback="New Chat" />
|
||||
</ThreadListItemPrimitive.Trigger>
|
||||
<ThreadListItemMore />
|
||||
</ThreadListItemPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadListItemMore: FC = () => {
|
||||
return (
|
||||
<ThreadListItemMorePrimitive.Root>
|
||||
<ThreadListItemMorePrimitive.Trigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="aui-thread-list-item-more mr-2 size-7 p-0 opacity-0 transition-opacity group-hover:opacity-100 data-[state=open]:bg-accent data-[state=open]:opacity-100 group-data-active:opacity-100"
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More options</span>
|
||||
</Button>
|
||||
</ThreadListItemMorePrimitive.Trigger>
|
||||
<ThreadListItemMorePrimitive.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="aui-thread-list-item-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
>
|
||||
<ThreadListItemPrimitive.Archive asChild>
|
||||
<ThreadListItemMorePrimitive.Item className="aui-thread-list-item-more-item flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
|
||||
<ArchiveIcon className="size-4" />
|
||||
Archive
|
||||
</ThreadListItemMorePrimitive.Item>
|
||||
</ThreadListItemPrimitive.Archive>
|
||||
</ThreadListItemMorePrimitive.Content>
|
||||
</ThreadListItemMorePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ClawSessionList: FC = () => {
|
||||
const { replaySession } = useClawSessionReplay();
|
||||
const [sessions, setSessions] = useState<ClawSession[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSessionId(window.localStorage.getItem("claw.activeSessionId"));
|
||||
const clearActiveSession = () => setActiveSessionId(null);
|
||||
window.addEventListener("claw-active-session-cleared", clearActiveSession);
|
||||
fetch("/api/claw/sessions")
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.then((payload) => {
|
||||
if (Array.isArray(payload))
|
||||
setSessions(dedupeSessions(payload).slice(0, 8));
|
||||
})
|
||||
.catch(() => setSessions([]));
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"claw-active-session-cleared",
|
||||
clearActiveSession,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!sessions.length) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2 px-3">
|
||||
<div className="flex items-center gap-2 font-medium text-muted-foreground text-xs">
|
||||
<HistoryIcon className="size-3.5" />
|
||||
Claw 历史会话
|
||||
</div>
|
||||
{activeSessionId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground text-xs hover:text-foreground"
|
||||
onClick={() => {
|
||||
window.localStorage.removeItem("claw.activeSessionId");
|
||||
setActiveSessionId(null);
|
||||
}}
|
||||
>
|
||||
取消续接
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{sessions.map((session) => {
|
||||
const active = activeSessionId === session.session_id;
|
||||
return (
|
||||
<button
|
||||
key={`claw-session-${session.session_id}-${session.modified_at}`}
|
||||
type="button"
|
||||
className="rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted data-[active=true]:bg-muted"
|
||||
data-active={active}
|
||||
onClick={async () => {
|
||||
window.localStorage.setItem(
|
||||
"claw.activeSessionId",
|
||||
session.session_id,
|
||||
);
|
||||
setActiveSessionId(session.session_id);
|
||||
setLoadingSessionId(session.session_id);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/claw/sessions/${session.session_id}`,
|
||||
);
|
||||
if (!response.ok) return;
|
||||
const payload = (await response.json()) as ClawStoredSession;
|
||||
replaySession(
|
||||
session.session_id,
|
||||
toReplayRepository(payload, session.session_id),
|
||||
);
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="block truncate">
|
||||
{session.preview || session.session_id}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-muted-foreground text-xs">
|
||||
{active ? "下一条消息将续接此会话 · " : ""}
|
||||
{loadingSessionId === session.session_id ? "回放中 · " : ""}
|
||||
{session.turns} 轮 · {session.tool_calls} 次工具
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function dedupeSessions(payload: unknown[]) {
|
||||
const byId = new Map<string, ClawSession>();
|
||||
for (const item of payload) {
|
||||
if (!isClawSession(item)) continue;
|
||||
const previous = byId.get(item.session_id);
|
||||
if (!previous || item.modified_at >= previous.modified_at) {
|
||||
byId.set(item.session_id, item);
|
||||
}
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => b.modified_at - a.modified_at);
|
||||
}
|
||||
|
||||
function isClawSession(value: unknown): value is ClawSession {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const item = value as Partial<ClawSession>;
|
||||
return (
|
||||
typeof item.session_id === "string" && typeof item.modified_at === "number"
|
||||
);
|
||||
}
|
||||
|
||||
function toReplayRepository(
|
||||
session: ClawStoredSession,
|
||||
fallbackSessionId: string,
|
||||
): ExportedMessageRepository {
|
||||
const messages: ExportedMessageRepository["messages"] = [];
|
||||
const toolResults = collectToolResults(session.messages ?? []);
|
||||
let previousId: string | null = null;
|
||||
for (const [index, message] of (session.messages ?? []).entries()) {
|
||||
if (message.role === "tool") continue;
|
||||
if (message.role !== "user" && message.role !== "assistant") continue;
|
||||
const content = cleanStoredContent(message.content ?? "");
|
||||
if (!content) continue;
|
||||
if (content.trimStart().startsWith("<system-reminder>")) continue;
|
||||
const id = `${fallbackSessionId}-${index}`;
|
||||
const toolParts =
|
||||
message.role === "assistant"
|
||||
? toToolParts(message.tool_calls ?? [], toolResults)
|
||||
: [];
|
||||
const elapsedMs =
|
||||
typeof message.metadata?.elapsed_ms === "number"
|
||||
? message.metadata.elapsed_ms
|
||||
: undefined;
|
||||
const reasoningParts =
|
||||
message.role === "assistant" &&
|
||||
(toolParts.length > 0 || elapsedMs !== undefined)
|
||||
? [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: formatHistoricalReasoning(toolParts.length, elapsedMs),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const parts = (
|
||||
message.role === "assistant" &&
|
||||
(reasoningParts.length || toolParts.length)
|
||||
? [...reasoningParts, ...toolParts, { type: "text", text: content }]
|
||||
: [{ type: "text", text: content }]
|
||||
) as UIMessage["parts"];
|
||||
const uiMessage: UIMessage = {
|
||||
id,
|
||||
role: message.role,
|
||||
parts,
|
||||
...(message.role === "assistant"
|
||||
? { metadata: { sessionId: session.session_id ?? fallbackSessionId } }
|
||||
: {}),
|
||||
} as UIMessage;
|
||||
const threadMessage = {
|
||||
id,
|
||||
role: message.role,
|
||||
createdAt: new Date(),
|
||||
content: toThreadMessageContent(parts),
|
||||
...(message.role === "assistant"
|
||||
? {
|
||||
status: { type: "complete", reason: "stop" },
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: { sessionId: session.session_id ?? fallbackSessionId },
|
||||
},
|
||||
}
|
||||
: { metadata: { custom: {} } }),
|
||||
} as ThreadMessage;
|
||||
bindExternalStoreMessage(threadMessage, uiMessage);
|
||||
messages.push({ message: threadMessage, parentId: previousId });
|
||||
previousId = id;
|
||||
}
|
||||
return {
|
||||
headId: previousId,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
function cleanStoredContent(content: string) {
|
||||
const markers = ["\n\n[当前会话目录]", "\n[当前会话目录]"];
|
||||
for (const marker of markers) {
|
||||
if (content.includes(marker)) return content.split(marker, 1)[0].trim();
|
||||
}
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
function collectToolResults(messages: readonly ClawStoredMessage[]) {
|
||||
const results = new Map<string, string>();
|
||||
for (const message of messages) {
|
||||
if (message.role === "tool" && message.tool_call_id) {
|
||||
results.set(message.tool_call_id, message.content ?? "");
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function toToolParts(
|
||||
toolCalls: readonly ClawStoredToolCall[],
|
||||
toolResults: Map<string, string>,
|
||||
) {
|
||||
return toolCalls.flatMap((call) => {
|
||||
const toolCallId = call.id;
|
||||
const toolName = call.function?.name ?? call.name;
|
||||
if (!toolCallId || !toolName) return [];
|
||||
const input = parseToolInput(call.function?.arguments ?? call.arguments);
|
||||
const output = toolResults.get(toolCallId);
|
||||
return [
|
||||
{
|
||||
type: "dynamic-tool",
|
||||
toolName,
|
||||
toolCallId,
|
||||
state: output === undefined ? "input-available" : "output-available",
|
||||
input,
|
||||
...(output === undefined ? {} : { output }),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function formatHistoricalReasoning(toolCount: number, elapsedMs?: number) {
|
||||
const duration =
|
||||
elapsedMs === undefined ? "" : `,用时 ${formatDuration(elapsedMs)}`;
|
||||
if (toolCount <= 0) return `历史记录:思考完成${duration}。`;
|
||||
return `历史记录:本轮调用了 ${toolCount} 个工具${duration}。`;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function toThreadMessageContent(parts: UIMessage["parts"]) {
|
||||
return parts.map((part) => {
|
||||
if (part.type === "text" || part.type === "reasoning") return part;
|
||||
if (part.type === "dynamic-tool") {
|
||||
return {
|
||||
type: "tool-call",
|
||||
toolName: part.toolName,
|
||||
toolCallId: part.toolCallId,
|
||||
args: part.input ?? {},
|
||||
argsText: JSON.stringify(part.input ?? {}),
|
||||
...(part.state === "output-available" ? { result: part.output } : {}),
|
||||
};
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
function parseToolInput(value: unknown) {
|
||||
if (typeof value !== "string") return value ?? {};
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
BarChart3Icon,
|
||||
BotIcon,
|
||||
ChevronDownIcon,
|
||||
LogOutIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ClawAccount } from "@/app/claw-account-gate";
|
||||
import { ThreadList } from "@/components/assistant-ui/thread-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
type ThreadListSidebarProps = React.ComponentProps<typeof Sidebar> & {
|
||||
account: ClawAccount;
|
||||
onLogout: () => void;
|
||||
};
|
||||
|
||||
type ClawState = {
|
||||
model?: string;
|
||||
session_directory?: string;
|
||||
active_session_id?: string | null;
|
||||
};
|
||||
|
||||
type ClawSessionSummary = {
|
||||
session_id: string;
|
||||
turns: number;
|
||||
tool_calls: number;
|
||||
modified_at: number;
|
||||
model?: string;
|
||||
usage?: UsageSummary;
|
||||
};
|
||||
|
||||
type UsageSummary = {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
|
||||
type ClawStoredSession = {
|
||||
session_id: string;
|
||||
usage?: UsageSummary;
|
||||
tool_calls?: number;
|
||||
turns?: number;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export function ThreadListSidebar({
|
||||
account,
|
||||
onLogout,
|
||||
...props
|
||||
}: ThreadListSidebarProps) {
|
||||
return (
|
||||
<Sidebar {...props}>
|
||||
<SidebarHeader className="aui-sidebar-header mb-2 border-b">
|
||||
<div className="aui-sidebar-header-content flex items-center justify-between">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg">
|
||||
<div className="aui-sidebar-header-icon-wrapper flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<BotIcon className="aui-sidebar-header-icon size-4" />
|
||||
</div>
|
||||
<div className="aui-sidebar-header-heading mr-6 flex flex-col gap-0.5 leading-none">
|
||||
<span className="aui-sidebar-header-title font-semibold">
|
||||
Claw Data Agent
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
数据开发工作台
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="aui-sidebar-content px-2">
|
||||
<ThreadList />
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
<SidebarFooter className="aui-sidebar-footer border-t">
|
||||
<AccountMenu account={account} onLogout={onLogout} />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountMenu({
|
||||
account,
|
||||
onLogout,
|
||||
}: {
|
||||
account: ClawAccount;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [state, setState] = useState<ClawState | null>(null);
|
||||
const [sessions, setSessions] = useState<ClawSessionSummary[]>([]);
|
||||
const [activeSession, setActiveSession] = useState<ClawStoredSession | null>(
|
||||
null,
|
||||
);
|
||||
const [modelUsageOpen, setModelUsageOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
Promise.all([
|
||||
fetch("/api/claw/state", { cache: "no-store" }).then((res) =>
|
||||
res.ok ? res.json() : null,
|
||||
),
|
||||
fetch("/api/claw/sessions", { cache: "no-store" }).then((res) =>
|
||||
res.ok ? res.json() : [],
|
||||
),
|
||||
])
|
||||
.then(async ([statePayload, sessionsPayload]) => {
|
||||
const nextState = statePayload as ClawState | null;
|
||||
const nextSessions = Array.isArray(sessionsPayload)
|
||||
? (sessionsPayload as ClawSessionSummary[])
|
||||
: [];
|
||||
setState(nextState);
|
||||
setSessions(nextSessions);
|
||||
const activeSessionId =
|
||||
window.localStorage.getItem("claw.activeSessionId") ??
|
||||
nextState?.active_session_id;
|
||||
if (!activeSessionId) {
|
||||
setActiveSession(null);
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/claw/sessions/${activeSessionId}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
setActiveSession(response.ok ? await response.json() : null);
|
||||
})
|
||||
.catch(() => {
|
||||
setState(null);
|
||||
setSessions([]);
|
||||
setActiveSession(null);
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
async function logout() {
|
||||
await fetch("/api/claw/auth/logout", { method: "POST" });
|
||||
window.localStorage.removeItem("claw.activeSessionId");
|
||||
onLogout();
|
||||
}
|
||||
|
||||
const totalToolCalls = sessions.reduce(
|
||||
(sum, session) => sum + (session.tool_calls ?? 0),
|
||||
0,
|
||||
);
|
||||
const modelUsage = aggregateUsageByModel(sessions);
|
||||
const modelUsageTotalTokens = modelUsage.reduce(
|
||||
(sum, item) => sum + item.totalTokens,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||
<PopoverPrimitive.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors hover:bg-sidebar-accent"
|
||||
>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<UserIcon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-sm">
|
||||
{account.username}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">账号与统计</div>
|
||||
</div>
|
||||
</button>
|
||||
</PopoverPrimitive.Trigger>
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="z-50 w-80 rounded-lg border bg-popover p-3 text-popover-foreground shadow-md outline-none"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<UserIcon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{account.username}</div>
|
||||
<div className="truncate text-muted-foreground text-xs">
|
||||
{state?.model ?? "模型配置读取中"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<AccountStat label="会话" value={sessions.length} />
|
||||
<AccountStat label="工具" value={totalToolCalls} />
|
||||
<AccountStat
|
||||
label="Token"
|
||||
value={activeSession?.usage?.total_tokens ?? "暂无"}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 rounded-md border bg-muted/30 p-2 text-xs">
|
||||
<div className="mb-1 flex items-center gap-1.5 font-medium">
|
||||
<BarChart3Icon className="size-3.5" />
|
||||
当前会话
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-muted-foreground">
|
||||
<span>输入 token</span>
|
||||
<span className="text-right">
|
||||
{activeSession?.usage?.input_tokens ?? "暂无"}
|
||||
</span>
|
||||
<span>输出 token</span>
|
||||
<span className="text-right">
|
||||
{activeSession?.usage?.output_tokens ?? "暂无"}
|
||||
</span>
|
||||
<span>工具调用</span>
|
||||
<span className="text-right">
|
||||
{activeSession?.tool_calls ?? "暂无"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-md border bg-muted/30 p-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-2 text-left"
|
||||
onClick={() => setModelUsageOpen((value) => !value)}
|
||||
>
|
||||
<span className="font-medium">按模型统计</span>
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
{modelUsage.length
|
||||
? `${modelUsage.length} 个模型 · ${formatCompactNumber(modelUsageTotalTokens)} tokens`
|
||||
: "暂无"}
|
||||
<ChevronDownIcon
|
||||
className={`size-3.5 transition-transform ${
|
||||
modelUsageOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
{modelUsageOpen && modelUsage.length ? (
|
||||
<div className="mt-2 flex max-h-48 flex-col gap-2 overflow-y-auto pr-1">
|
||||
{modelUsage.map((item) => (
|
||||
<div
|
||||
key={item.model}
|
||||
className="grid grid-cols-[1fr_auto] gap-2"
|
||||
>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{item.model}
|
||||
</span>
|
||||
<span>{formatCompactNumber(item.totalTokens)} tokens</span>
|
||||
<span className="text-muted-foreground">
|
||||
{item.sessions} 会话 · {item.toolCalls} 工具
|
||||
</span>
|
||||
<span className="text-right text-muted-foreground">
|
||||
in {formatCompactNumber(item.inputTokens)} / out{" "}
|
||||
{formatCompactNumber(item.outputTokens)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{modelUsageOpen && !modelUsage.length ? (
|
||||
<div className="text-muted-foreground">暂无可统计会话</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mt-3 w-full justify-start gap-2 text-muted-foreground"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOutIcon className="size-4" />
|
||||
退出登录
|
||||
</Button>
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
</PopoverPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function aggregateUsageByModel(sessions: ClawSessionSummary[]) {
|
||||
const byModel = new Map<
|
||||
string,
|
||||
{
|
||||
model: string;
|
||||
sessions: number;
|
||||
toolCalls: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
>();
|
||||
for (const session of sessions) {
|
||||
const model = session.model || "unknown";
|
||||
const current = byModel.get(model) ?? {
|
||||
model,
|
||||
sessions: 0,
|
||||
toolCalls: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
};
|
||||
current.sessions += 1;
|
||||
current.toolCalls += session.tool_calls ?? 0;
|
||||
current.inputTokens += session.usage?.input_tokens ?? 0;
|
||||
current.outputTokens += session.usage?.output_tokens ?? 0;
|
||||
current.totalTokens +=
|
||||
session.usage?.total_tokens ??
|
||||
(session.usage?.input_tokens ?? 0) + (session.usage?.output_tokens ?? 0);
|
||||
byModel.set(model, current);
|
||||
}
|
||||
return [...byModel.values()].sort((a, b) => b.totalTokens - a.totalTokens);
|
||||
}
|
||||
|
||||
function formatCompactNumber(value: number) {
|
||||
return new Intl.NumberFormat("en", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function AccountStat({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 px-2 py-1.5">
|
||||
<div className="font-medium text-sm">{value}</div>
|
||||
<div className="text-muted-foreground text-xs">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ToolCallMessagePartComponent,
|
||||
type ToolCallMessagePartStatus,
|
||||
useScrollLock,
|
||||
} from "@assistant-ui/react";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
LoaderIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ANIMATION_DURATION = 200;
|
||||
|
||||
export type ToolFallbackRootProps = Omit<
|
||||
React.ComponentProps<typeof Collapsible>,
|
||||
"open" | "onOpenChange"
|
||||
> & {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
defaultOpen?: boolean;
|
||||
};
|
||||
|
||||
function ToolFallbackRoot({
|
||||
className,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
defaultOpen = false,
|
||||
children,
|
||||
...props
|
||||
}: ToolFallbackRootProps) {
|
||||
const collapsibleRef = useRef<HTMLDivElement>(null);
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
||||
const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
lockScroll();
|
||||
}
|
||||
if (!isControlled) {
|
||||
setUncontrolledOpen(open);
|
||||
}
|
||||
controlledOnOpenChange?.(open);
|
||||
},
|
||||
[lockScroll, isControlled, controlledOnOpenChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
ref={collapsibleRef}
|
||||
data-slot="tool-fallback-root"
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
className={cn(
|
||||
"aui-tool-fallback-root group/tool-fallback-root w-full rounded-lg border py-3",
|
||||
className,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--animation-duration": `${ANIMATION_DURATION}ms`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
type ToolStatus = ToolCallMessagePartStatus["type"];
|
||||
|
||||
const statusIconMap: Record<ToolStatus, React.ElementType> = {
|
||||
running: LoaderIcon,
|
||||
complete: CheckIcon,
|
||||
incomplete: XCircleIcon,
|
||||
"requires-action": AlertCircleIcon,
|
||||
};
|
||||
|
||||
function ToolFallbackTrigger({
|
||||
toolName,
|
||||
status,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsibleTrigger> & {
|
||||
toolName: string;
|
||||
status?: ToolCallMessagePartStatus;
|
||||
}) {
|
||||
const statusType = status?.type ?? "complete";
|
||||
const isRunning = statusType === "running";
|
||||
const isCancelled =
|
||||
status?.type === "incomplete" && status.reason === "cancelled";
|
||||
|
||||
const Icon = statusIconMap[statusType];
|
||||
const label = isCancelled ? "Cancelled tool" : "Used tool";
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
data-slot="tool-fallback-trigger"
|
||||
className={cn(
|
||||
"aui-tool-fallback-trigger group/trigger flex w-full items-center gap-2 px-4 text-sm transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon
|
||||
data-slot="tool-fallback-trigger-icon"
|
||||
className={cn(
|
||||
"aui-tool-fallback-trigger-icon size-4 shrink-0",
|
||||
isCancelled && "text-muted-foreground",
|
||||
isRunning && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
data-slot="tool-fallback-trigger-label"
|
||||
className={cn(
|
||||
"aui-tool-fallback-trigger-label-wrapper relative inline-block grow text-left leading-none",
|
||||
isCancelled && "text-muted-foreground line-through",
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{label}: <b>{toolName}</b>
|
||||
</span>
|
||||
{isRunning && (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="tool-fallback-trigger-shimmer"
|
||||
className="aui-tool-fallback-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none"
|
||||
>
|
||||
{label}: <b>{toolName}</b>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
data-slot="tool-fallback-trigger-chevron"
|
||||
className={cn(
|
||||
"aui-tool-fallback-trigger-chevron size-4 shrink-0",
|
||||
"transition-transform duration-(--animation-duration) ease-out",
|
||||
"group-data-[state=closed]/trigger:-rotate-90",
|
||||
"group-data-[state=open]/trigger:rotate-0",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolFallbackContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsibleContent
|
||||
data-slot="tool-fallback-content"
|
||||
className={cn(
|
||||
"aui-tool-fallback-content relative overflow-hidden text-sm outline-none",
|
||||
"group/collapsible-content ease-out",
|
||||
"data-[state=closed]:animate-collapsible-up",
|
||||
"data-[state=open]:animate-collapsible-down",
|
||||
"data-[state=closed]:fill-mode-forwards",
|
||||
"data-[state=closed]:pointer-events-none",
|
||||
"data-[state=open]:duration-(--animation-duration)",
|
||||
"data-[state=closed]:duration-(--animation-duration)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mt-3 flex flex-col gap-2 border-t pt-2">{children}</div>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolFallbackArgs({
|
||||
argsText,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
argsText?: string;
|
||||
}) {
|
||||
if (!argsText) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="tool-fallback-args"
|
||||
className={cn("aui-tool-fallback-args px-4", className)}
|
||||
{...props}
|
||||
>
|
||||
<pre className="aui-tool-fallback-args-value whitespace-pre-wrap">
|
||||
{argsText}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolFallbackResult({
|
||||
result,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
result?: unknown;
|
||||
}) {
|
||||
if (result === undefined) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="tool-fallback-result"
|
||||
className={cn(
|
||||
"aui-tool-fallback-result border-t border-dashed px-4 pt-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<p className="aui-tool-fallback-result-header font-semibold">Result:</p>
|
||||
<pre className="aui-tool-fallback-result-content whitespace-pre-wrap">
|
||||
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolFallbackError({
|
||||
status,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
status?: ToolCallMessagePartStatus;
|
||||
}) {
|
||||
if (status?.type !== "incomplete") return null;
|
||||
|
||||
const error = status.error;
|
||||
const errorText = error
|
||||
? typeof error === "string"
|
||||
? error
|
||||
: JSON.stringify(error)
|
||||
: null;
|
||||
|
||||
if (!errorText) return null;
|
||||
|
||||
const isCancelled = status.reason === "cancelled";
|
||||
const headerText = isCancelled ? "Cancelled reason:" : "Error:";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="tool-fallback-error"
|
||||
className={cn("aui-tool-fallback-error px-4", className)}
|
||||
{...props}
|
||||
>
|
||||
<p className="aui-tool-fallback-error-header font-semibold text-muted-foreground">
|
||||
{headerText}
|
||||
</p>
|
||||
<p className="aui-tool-fallback-error-reason text-muted-foreground">
|
||||
{errorText}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ToolFallbackImpl: ToolCallMessagePartComponent = ({
|
||||
toolName,
|
||||
argsText,
|
||||
result,
|
||||
status,
|
||||
}) => {
|
||||
const isCancelled =
|
||||
status?.type === "incomplete" && status.reason === "cancelled";
|
||||
|
||||
return (
|
||||
<ToolFallbackRoot
|
||||
className={cn(isCancelled && "border-muted-foreground/30 bg-muted/30")}
|
||||
>
|
||||
<ToolFallbackTrigger toolName={toolName} status={status} />
|
||||
<ToolFallbackContent>
|
||||
<ToolFallbackError status={status} />
|
||||
<ToolFallbackArgs
|
||||
argsText={argsText}
|
||||
className={cn(isCancelled && "opacity-60")}
|
||||
/>
|
||||
{!isCancelled && <ToolFallbackResult result={result} />}
|
||||
</ToolFallbackContent>
|
||||
</ToolFallbackRoot>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolFallback = memo(
|
||||
ToolFallbackImpl,
|
||||
) as unknown as ToolCallMessagePartComponent & {
|
||||
Root: typeof ToolFallbackRoot;
|
||||
Trigger: typeof ToolFallbackTrigger;
|
||||
Content: typeof ToolFallbackContent;
|
||||
Args: typeof ToolFallbackArgs;
|
||||
Result: typeof ToolFallbackResult;
|
||||
Error: typeof ToolFallbackError;
|
||||
};
|
||||
|
||||
ToolFallback.displayName = "ToolFallback";
|
||||
ToolFallback.Root = ToolFallbackRoot;
|
||||
ToolFallback.Trigger = ToolFallbackTrigger;
|
||||
ToolFallback.Content = ToolFallbackContent;
|
||||
ToolFallback.Args = ToolFallbackArgs;
|
||||
ToolFallback.Result = ToolFallbackResult;
|
||||
ToolFallback.Error = ToolFallbackError;
|
||||
|
||||
export {
|
||||
ToolFallback,
|
||||
ToolFallbackArgs,
|
||||
ToolFallbackContent,
|
||||
ToolFallbackError,
|
||||
ToolFallbackResult,
|
||||
ToolFallbackRoot,
|
||||
ToolFallbackTrigger,
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { Slot } from "radix-ui";
|
||||
import { type ComponentPropsWithRef, forwardRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & {
|
||||
tooltip: string;
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
};
|
||||
|
||||
export const TooltipIconButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
TooltipIconButtonProps
|
||||
>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
{...rest}
|
||||
className={cn("aui-button-icon size-6 p-1", className)}
|
||||
ref={ref}
|
||||
>
|
||||
<Slot.Slottable>{children}</Slot.Slottable>
|
||||
<span className="aui-sr-only sr-only">{tooltip}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side}>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
TooltipIconButton.displayName = "TooltipIconButton";
|
||||
@@ -0,0 +1,7 @@
|
||||
export function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg";
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 select-none overflow-hidden rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground text-sm group-data-[size=sm]/avatar:text-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex select-none items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground text-sm ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarBadge,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarImage,
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-muted-foreground text-sm sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { XIcon } from "lucide-react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg outline-none duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("font-semibold text-lg leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
};
|
||||
@@ -0,0 +1,724 @@
|
||||
"use client";
|
||||
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { PanelLeftIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background",
|
||||
"md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("h-8 w-full bg-background shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 font-medium text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 font-medium text-sidebar-foreground text-xs tabular-nums",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-accent", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-foreground px-3 py-1.5 text-background text-xs data-[state=closed]:animate-out",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||
Reference in New Issue
Block a user