feat: add explore page and AI elements components
This commit is contained in:
parent
b0b33dfd9c
commit
033cfda6c5
196
src/app/explore/ExplorePage.tsx
Normal file
196
src/app/explore/ExplorePage.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { Search, Lock, Globe, Users, Compass } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { search } from "@/client/api";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import type { SearchParams } from "@/client/model";
|
||||
import type { ProjectSearchItem } from "@/client/model";
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
"#6366f1", "#8b5cf6", "#d946ef", "#ec4899", "#f43f5e",
|
||||
"#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6",
|
||||
"#06b6d4", "#3b82f6", "#2563eb", "#7c3aed", "#c026d3",
|
||||
];
|
||||
|
||||
function hashColor(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
export function ExplorePage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
const searchParams: SearchParams = {
|
||||
q: searchText.trim() || "a",
|
||||
type: "projects",
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
};
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["explore-projects", searchParams.q],
|
||||
queryFn: async () => {
|
||||
const res = await search(searchParams);
|
||||
return res.data?.data;
|
||||
},
|
||||
});
|
||||
|
||||
const projects: ProjectSearchItem[] = data?.projects?.items ?? [];
|
||||
const total = data?.projects?.total ?? 0;
|
||||
|
||||
const handleSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchText(e.target.value);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{ backgroundColor: "var(--surface-ground)" }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="shrink-0 px-6 py-6"
|
||||
style={{ backgroundColor: "var(--surface-ground)" }}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Compass className="w-7 h-7" style={{ color: "var(--accent)" }} />
|
||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-primary)" }}>
|
||||
Explore Projects
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>
|
||||
Discover public projects and communities
|
||||
</p>
|
||||
<div className="relative max-w-lg">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search projects by name or description..."
|
||||
value={searchText}
|
||||
onChange={handleSearch}
|
||||
className="pl-10 h-10"
|
||||
/>
|
||||
</div>
|
||||
{!isLoading && (
|
||||
<p className="text-xs mt-2" style={{ color: "var(--text-muted)" }}>
|
||||
{searchText.trim()
|
||||
? `${total} project${total !== 1 ? "s" : ""} found`
|
||||
: `Showing discoverable projects`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project grid */}
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-xl p-5 animate-pulse"
|
||||
style={{ backgroundColor: "var(--surface-elevated)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div
|
||||
className="w-12 h-12 rounded-2xl"
|
||||
style={{ backgroundColor: "var(--surface-ground)" }}
|
||||
/>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-24 rounded" style={{ backgroundColor: "var(--surface-ground)" }} />
|
||||
<div className="h-3 w-16 rounded" style={{ backgroundColor: "var(--surface-ground)" }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-3 w-full rounded mb-2" style={{ backgroundColor: "var(--surface-ground)" }} />
|
||||
<div className="h-3 w-2/3 rounded" style={{ backgroundColor: "var(--surface-ground)" }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<Compass className="w-12 h-12 mb-4" style={{ color: "var(--text-muted)" }} />
|
||||
<p className="text-lg font-medium" style={{ color: "var(--text-primary)" }}>
|
||||
No projects found
|
||||
</p>
|
||||
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Try a different search term
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{projects.map((project) => (
|
||||
<Link
|
||||
key={project.uid}
|
||||
to={`/${project.name}/repos`}
|
||||
className="block rounded-xl p-5 transition-all hover:-translate-y-0.5 group"
|
||||
style={{
|
||||
backgroundColor: "var(--surface-elevated)",
|
||||
border: "1px solid var(--border-subtle)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Avatar className="w-12 h-12 rounded-2xl shrink-0">
|
||||
<AvatarImage
|
||||
src={project.avatar_url || undefined}
|
||||
alt={project.display_name}
|
||||
className="object-cover"
|
||||
/>
|
||||
<AvatarFallback
|
||||
style={{
|
||||
backgroundColor: hashColor(project.display_name),
|
||||
color: "#ffffff",
|
||||
}}
|
||||
>
|
||||
<span className="text-[15px] font-semibold">
|
||||
{project.display_name[0]?.toUpperCase() || "?"}
|
||||
</span>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3
|
||||
className="font-semibold text-sm truncate group-hover:underline"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{project.display_name}
|
||||
</h3>
|
||||
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}>
|
||||
{project.name}
|
||||
</p>
|
||||
</div>
|
||||
{project.is_public ? (
|
||||
<Globe className="w-4 h-4 shrink-0" style={{ color: "var(--text-muted)" }} />
|
||||
) : (
|
||||
<Lock className="w-4 h-4 shrink-0" style={{ color: "var(--text-muted)" }} />
|
||||
)}
|
||||
</div>
|
||||
{project.description ? (
|
||||
<p
|
||||
className="text-xs line-clamp-2"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
{project.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs italic" style={{ color: "var(--text-muted)" }}>
|
||||
No description
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-3 pt-3" style={{ borderTop: "1px solid var(--border-subtle)" }}>
|
||||
<Users className="w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
|
||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
View project
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
src/components/ai-elements/chain-of-thought.tsx
Normal file
222
src/components/ai-elements/chain-of-thought.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { BrainIcon, ChevronDownIcon, DotIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useMemo } from "react";
|
||||
|
||||
interface ChainOfThoughtContextValue {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useChainOfThought = () => {
|
||||
const context = useContext(ChainOfThoughtContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"ChainOfThought components must be used within ChainOfThought"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ChainOfThoughtProps = ComponentProps<"div"> & {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const ChainOfThought = memo(
|
||||
({
|
||||
className,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
prop: open,
|
||||
});
|
||||
|
||||
const chainOfThoughtContext = useMemo(
|
||||
() => ({ isOpen, setIsOpen }),
|
||||
[isOpen, setIsOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
|
||||
<div className={cn("not-prose w-full space-y-4", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</ChainOfThoughtContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtHeaderProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
>;
|
||||
|
||||
export const ChainOfThoughtHeader = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
|
||||
const { isOpen, setIsOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<BrainIcon className="size-4" />
|
||||
<span className="flex-1 text-left">
|
||||
{children ?? "Chain of Thought"}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
||||
icon?: LucideIcon;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
status?: "complete" | "active" | "pending";
|
||||
};
|
||||
|
||||
const stepStatusStyles = {
|
||||
active: "text-foreground",
|
||||
complete: "text-muted-foreground",
|
||||
pending: "text-muted-foreground/50",
|
||||
};
|
||||
|
||||
export const ChainOfThoughtStep = memo(
|
||||
({
|
||||
className,
|
||||
icon: Icon = DotIcon,
|
||||
label,
|
||||
description,
|
||||
status = "complete",
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtStepProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 text-sm",
|
||||
stepStatusStyles[status],
|
||||
"fade-in-0 slide-in-from-top-2 animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative mt-0.5">
|
||||
<Icon className="size-4" />
|
||||
<div className="absolute top-7 bottom-0 left-1/2 -mx-px w-px bg-border" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-hidden">
|
||||
<div>{label}</div>
|
||||
{description && (
|
||||
<div className="text-muted-foreground text-xs">{description}</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
|
||||
|
||||
export const ChainOfThoughtSearchResults = memo(
|
||||
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
|
||||
<div
|
||||
className={cn("flex flex-wrap items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
|
||||
|
||||
export const ChainOfThoughtSearchResult = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
|
||||
<Badge
|
||||
className={cn("gap-1 px-2 py-0.5 font-normal text-xs", className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
);
|
||||
|
||||
export type ChainOfThoughtContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>;
|
||||
|
||||
export const ChainOfThoughtContent = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtContentProps) => {
|
||||
const { isOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen}>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-2 space-y-3",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export const ChainOfThoughtImage = memo(
|
||||
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
|
||||
<div className={cn("mt-2 space-y-2", className)} {...props}>
|
||||
<div className="relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3">
|
||||
{children}
|
||||
</div>
|
||||
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
ChainOfThought.displayName = "ChainOfThought";
|
||||
ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
|
||||
ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
|
||||
ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
|
||||
ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
|
||||
ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
|
||||
ChainOfThoughtImage.displayName = "ChainOfThoughtImage";
|
||||
213
src/components/ai-elements/model-selector.tsx
Normal file
213
src/components/ai-elements/model-selector.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
|
||||
|
||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||
<Dialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
|
||||
|
||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
title?: ReactNode;
|
||||
};
|
||||
|
||||
export const ModelSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Model Selector",
|
||||
...props
|
||||
}: ModelSelectorContentProps) => (
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
className={cn(
|
||||
"outline! border-none! p-0 outline-border! outline-solid!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
|
||||
|
||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
|
||||
|
||||
export const ModelSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
|
||||
|
||||
export const ModelSelectorList = (props: ModelSelectorListProps) => (
|
||||
<CommandList {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||
|
||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
|
||||
|
||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||
|
||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||
<CommandItem {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
|
||||
|
||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>;
|
||||
|
||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoProps = Omit<
|
||||
ComponentProps<"img">,
|
||||
"src" | "alt"
|
||||
> & {
|
||||
provider:
|
||||
| "moonshotai-cn"
|
||||
| "lucidquery"
|
||||
| "moonshotai"
|
||||
| "zai-coding-plan"
|
||||
| "alibaba"
|
||||
| "xai"
|
||||
| "vultr"
|
||||
| "nvidia"
|
||||
| "upstage"
|
||||
| "groq"
|
||||
| "github-copilot"
|
||||
| "mistral"
|
||||
| "vercel"
|
||||
| "nebius"
|
||||
| "deepseek"
|
||||
| "alibaba-cn"
|
||||
| "google-vertex-anthropic"
|
||||
| "venice"
|
||||
| "chutes"
|
||||
| "cortecs"
|
||||
| "github-models"
|
||||
| "togetherai"
|
||||
| "azure"
|
||||
| "baseten"
|
||||
| "huggingface"
|
||||
| "opencode"
|
||||
| "fastrouter"
|
||||
| "google"
|
||||
| "google-vertex"
|
||||
| "cloudflare-workers-ai"
|
||||
| "inception"
|
||||
| "wandb"
|
||||
| "openai"
|
||||
| "zhipuai-coding-plan"
|
||||
| "perplexity"
|
||||
| "openrouter"
|
||||
| "zenmux"
|
||||
| "v0"
|
||||
| "iflowcn"
|
||||
| "synthetic"
|
||||
| "deepinfra"
|
||||
| "zhipuai"
|
||||
| "submodel"
|
||||
| "zai"
|
||||
| "inference"
|
||||
| "requesty"
|
||||
| "morph"
|
||||
| "lmstudio"
|
||||
| "anthropic"
|
||||
| "aihubmix"
|
||||
| "fireworks-ai"
|
||||
| "modelscope"
|
||||
| "llama"
|
||||
| "scaleway"
|
||||
| "amazon-bedrock"
|
||||
| "cerebras"
|
||||
// oxlint-disable-next-line typescript-eslint(ban-types) -- intentional pattern for autocomplete-friendly string union
|
||||
| (string & {});
|
||||
};
|
||||
|
||||
export const ModelSelectorLogo = ({
|
||||
provider,
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={`${provider} logo`}
|
||||
className={cn("size-3 dark:invert", className)}
|
||||
height={12}
|
||||
src={`https://models.dev/logos/${provider}.svg`}
|
||||
width={12}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
|
||||
|
||||
export const ModelSelectorLogoGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorNameProps = ComponentProps<"span">;
|
||||
|
||||
export const ModelSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorNameProps) => (
|
||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||
);
|
||||
1463
src/components/ai-elements/prompt-input.tsx
Normal file
1463
src/components/ai-elements/prompt-input.tsx
Normal file
File diff suppressed because it is too large
Load Diff
226
src/components/ai-elements/reasoning.tsx
Normal file
226
src/components/ai-elements/reasoning.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import { code } from "@streamdown/code";
|
||||
import { math } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
interface ReasoningContextValue {
|
||||
isStreaming: boolean;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
duration: number | undefined;
|
||||
}
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
export const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
|
||||
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
|
||||
const isExplicitlyClosed = defaultOpen === false;
|
||||
|
||||
const [isOpen, setIsOpen] = useControllableState<boolean>({
|
||||
defaultProp: resolvedDefaultOpen,
|
||||
onChange: onOpenChange,
|
||||
prop: open,
|
||||
});
|
||||
const [duration, setDuration] = useControllableState<number | undefined>({
|
||||
defaultProp: undefined,
|
||||
prop: durationProp,
|
||||
});
|
||||
|
||||
const hasEverStreamedRef = useRef(isStreaming);
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
|
||||
// Track when streaming starts and compute duration
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
hasEverStreamedRef.current = true;
|
||||
if (startTimeRef.current === null) {
|
||||
startTimeRef.current = Date.now();
|
||||
}
|
||||
} else if (startTimeRef.current !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
|
||||
startTimeRef.current = null;
|
||||
}
|
||||
}, [isStreaming, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts (unless explicitly closed)
|
||||
useEffect(() => {
|
||||
if (isStreaming && !isOpen && !isExplicitlyClosed) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
|
||||
|
||||
// Auto-close when streaming ends (once only, and only if it ever streamed)
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasEverStreamedRef.current &&
|
||||
!isStreaming &&
|
||||
isOpen &&
|
||||
!hasAutoClosed
|
||||
) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosed(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
},
|
||||
[setIsOpen]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ duration, isOpen, isStreaming, setIsOpen }),
|
||||
[duration, isOpen, isStreaming, setIsOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider value={contextValue}>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
||||
};
|
||||
|
||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>Thought for a few seconds</p>;
|
||||
}
|
||||
return <p>Thought for {duration} seconds</p>;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
children,
|
||||
getThinkingMessage = defaultGetThinkingMessage,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
);
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||
ReasoningContent.displayName = "ReasoningContent";
|
||||
77
src/components/ai-elements/shimmer.tsx
Normal file
77
src/components/ai-elements/shimmer.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MotionProps } from "motion/react";
|
||||
import { motion } from "motion/react";
|
||||
import type { CSSProperties, ElementType, JSX } from "react";
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
type MotionHTMLProps = MotionProps & Record<string, unknown>;
|
||||
|
||||
// Cache motion components at module level to avoid creating during render
|
||||
const motionComponentCache = new Map<
|
||||
keyof JSX.IntrinsicElements,
|
||||
React.ComponentType<MotionHTMLProps>
|
||||
>();
|
||||
|
||||
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
|
||||
let component = motionComponentCache.get(element);
|
||||
if (!component) {
|
||||
component = motion.create(element);
|
||||
motionComponentCache.set(element, component);
|
||||
}
|
||||
return component;
|
||||
};
|
||||
|
||||
export interface TextShimmerProps {
|
||||
children: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
spread?: number;
|
||||
}
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = getMotionComponent(
|
||||
Component as keyof JSX.IntrinsicElements
|
||||
);
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread]
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
className
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
duration,
|
||||
ease: "linear",
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent);
|
||||
87
src/components/ai-elements/task.tsx
Normal file
87
src/components/ai-elements/task.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, SearchIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type TaskItemFileProps = ComponentProps<"div">;
|
||||
|
||||
export const TaskItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskItemFileProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskItemProps = ComponentProps<"div">;
|
||||
|
||||
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
|
||||
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Task = ({
|
||||
defaultOpen = true,
|
||||
className,
|
||||
...props
|
||||
}: TaskProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
);
|
||||
|
||||
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const TaskTrigger = ({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: TaskTriggerProps) => (
|
||||
<CollapsibleTrigger asChild className={cn("group", className)} {...props}>
|
||||
{children ?? (
|
||||
<div className="flex w-full cursor-pointer items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
|
||||
<SearchIcon className="size-4" />
|
||||
<p className="text-sm">{title}</p>
|
||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const TaskContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4">
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
496
src/components/ai-elements/test-results.tsx
Normal file
496
src/components/ai-elements/test-results.tsx
Normal file
@ -0,0 +1,496 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
ChevronRightIcon,
|
||||
CircleDotIcon,
|
||||
CircleIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
type TestStatus = "passed" | "failed" | "skipped" | "running";
|
||||
|
||||
interface TestResultsSummary {
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface TestResultsContextType {
|
||||
summary?: TestResultsSummary;
|
||||
}
|
||||
|
||||
const TestResultsContext = createContext<TestResultsContextType>({});
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
||||
export type TestResultsHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestResultsHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b px-4 py-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TestResultsDurationProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const TestResultsDuration = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsDurationProps) => {
|
||||
const { summary } = useContext(TestResultsContext);
|
||||
|
||||
if (!summary?.duration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn("text-muted-foreground text-sm", className)} {...props}>
|
||||
{children ?? formatDuration(summary.duration)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestResultsSummaryProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestResultsSummary = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsSummaryProps) => {
|
||||
const { summary } = useContext(TestResultsContext);
|
||||
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<Badge
|
||||
className="gap-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
variant="secondary"
|
||||
>
|
||||
<CheckCircle2Icon className="size-3" />
|
||||
{summary.passed} passed
|
||||
</Badge>
|
||||
{summary.failed > 0 && (
|
||||
<Badge
|
||||
className="gap-1 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
variant="secondary"
|
||||
>
|
||||
<XCircleIcon className="size-3" />
|
||||
{summary.failed} failed
|
||||
</Badge>
|
||||
)}
|
||||
{summary.skipped > 0 && (
|
||||
<Badge
|
||||
className="gap-1 bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
variant="secondary"
|
||||
>
|
||||
<CircleIcon className="size-3" />
|
||||
{summary.skipped} skipped
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestResultsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
summary?: TestResultsSummary;
|
||||
};
|
||||
|
||||
export const TestResults = ({
|
||||
summary,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsProps) => {
|
||||
const contextValue = useMemo(() => ({ summary }), [summary]);
|
||||
|
||||
return (
|
||||
<TestResultsContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("rounded-lg border bg-background", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ??
|
||||
(summary && (
|
||||
<TestResultsHeader>
|
||||
<TestResultsSummary />
|
||||
<TestResultsDuration />
|
||||
</TestResultsHeader>
|
||||
))}
|
||||
</div>
|
||||
</TestResultsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestResultsProgressProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestResultsProgress = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsProgressProps) => {
|
||||
const { summary } = useContext(TestResultsContext);
|
||||
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const passedPercent = (summary.passed / summary.total) * 100;
|
||||
const failedPercent = (summary.failed / summary.total) * 100;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className="flex h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="bg-green-500 transition-all"
|
||||
style={{ width: `${passedPercent}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-red-500 transition-all"
|
||||
style={{ width: `${failedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground text-xs">
|
||||
<span>
|
||||
{summary.passed}/{summary.total} tests passed
|
||||
</span>
|
||||
<span>{passedPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestResultsContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestResultsContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsContentProps) => (
|
||||
<div className={cn("space-y-2 p-4", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface TestSuiteContextType {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
}
|
||||
|
||||
const TestSuiteContext = createContext<TestSuiteContextType>({
|
||||
name: "",
|
||||
status: "passed",
|
||||
});
|
||||
|
||||
const statusStyles: Record<TestStatus, string> = {
|
||||
failed: "text-red-600 dark:text-red-400",
|
||||
passed: "text-green-600 dark:text-green-400",
|
||||
running: "text-blue-600 dark:text-blue-400",
|
||||
skipped: "text-yellow-600 dark:text-yellow-400",
|
||||
};
|
||||
|
||||
const statusIcons: Record<TestStatus, React.ReactNode> = {
|
||||
failed: <XCircleIcon className="size-4" />,
|
||||
passed: <CheckCircle2Icon className="size-4" />,
|
||||
running: <CircleDotIcon className="size-4 animate-pulse" />,
|
||||
skipped: <CircleIcon className="size-4" />,
|
||||
};
|
||||
|
||||
const TestStatusIcon = ({ status }: { status: TestStatus }) => (
|
||||
<span className={cn("shrink-0", statusStyles[status])}>
|
||||
{statusIcons[status]}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type TestSuiteProps = ComponentProps<typeof Collapsible> & {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
};
|
||||
|
||||
export const TestSuite = ({
|
||||
name,
|
||||
status,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestSuiteProps) => {
|
||||
const contextValue = useMemo(() => ({ name, status }), [name, status]);
|
||||
|
||||
return (
|
||||
<TestSuiteContext.Provider value={contextValue}>
|
||||
<Collapsible className={cn("rounded-lg border", className)} {...props}>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</TestSuiteContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestSuiteNameProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const TestSuiteName = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestSuiteNameProps) => {
|
||||
const { name, status } = useContext(TestSuiteContext);
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
||||
<TestStatusIcon status={status} />
|
||||
<span className="font-medium text-sm">{children ?? name}</span>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestSuiteStatsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
passed?: number;
|
||||
failed?: number;
|
||||
skipped?: number;
|
||||
};
|
||||
|
||||
export const TestSuiteStats = ({
|
||||
passed = 0,
|
||||
failed = 0,
|
||||
skipped = 0,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestSuiteStatsProps) => (
|
||||
<div
|
||||
className={cn("ml-auto flex items-center gap-2 text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{passed > 0 && (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
{passed} passed
|
||||
</span>
|
||||
)}
|
||||
{failed > 0 && (
|
||||
<span className="text-red-600 dark:text-red-400">
|
||||
{failed} failed
|
||||
</span>
|
||||
)}
|
||||
{skipped > 0 && (
|
||||
<span className="text-yellow-600 dark:text-yellow-400">
|
||||
{skipped} skipped
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TestSuiteContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const TestSuiteContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestSuiteContentProps) => (
|
||||
<CollapsibleContent className={cn("border-t", className)} {...props}>
|
||||
<div className="divide-y">{children}</div>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
|
||||
interface TestContextType {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const TestContext = createContext<TestContextType>({
|
||||
name: "",
|
||||
status: "passed",
|
||||
});
|
||||
|
||||
export type TestNameProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const TestName = ({ className, children, ...props }: TestNameProps) => {
|
||||
const { name } = useContext(TestContext);
|
||||
|
||||
return (
|
||||
<span className={cn("flex-1", className)} {...props}>
|
||||
{children ?? name}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestDurationProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const TestDuration = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestDurationProps) => {
|
||||
const { duration } = useContext(TestContext);
|
||||
|
||||
if (duration === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-muted-foreground text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? `${duration}ms`}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestStatusProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const TestStatus = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestStatusProps) => {
|
||||
const { status } = useContext(TestContext);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("shrink-0", statusStyles[status], className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? statusIcons[status]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestProps = HTMLAttributes<HTMLDivElement> & {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export const Test = ({
|
||||
name,
|
||||
status,
|
||||
duration,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestProps) => {
|
||||
const contextValue = useMemo(
|
||||
() => ({ duration, name, status }),
|
||||
[duration, name, status]
|
||||
);
|
||||
|
||||
return (
|
||||
<TestContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("flex items-center gap-2 px-4 py-2 text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<TestStatus />
|
||||
<TestName />
|
||||
{duration !== undefined && <TestDuration />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TestContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestErrorProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestError = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestErrorProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 rounded-md bg-red-50 p-3 dark:bg-red-900/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TestErrorMessageProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const TestErrorMessage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestErrorMessageProps) => (
|
||||
<p
|
||||
className={cn(
|
||||
"font-medium text-red-700 text-sm dark:text-red-400",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
export type TestErrorStackProps = HTMLAttributes<HTMLPreElement>;
|
||||
|
||||
export const TestErrorStack = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestErrorStackProps) => (
|
||||
<pre
|
||||
className={cn(
|
||||
"mt-2 overflow-auto font-mono text-red-600 text-xs dark:text-red-400",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user