v3: AI SDK pivot — pi removed, Next.js 16 + AI SDK 7 + AI Elements teacher app

The teaching agent no longer runs on pi. Stack:
- ToolLoopAgent (ai@7) + OpenRouter (OPENROUTER_API_KEY), model via LH_MODEL,
  default anthropic/claude-sonnet-4.5 with reasoning tokens
- quiz/ask_user_question = client-side tools (no server execute): the call
  lands as a typed tool part in input-available state; the card grades via
  src/core/question-core.ts; addToolOutput sends the verdict; the teacher's
  reaction continues the same assistant message
- resume-mid-quiz is free: the pending tool part persists in the transcript
- persistence: one JSON per chat (data/chats/), full UIMessage transcripts,
  saved onFinish (also on abort)
- UI: AI Elements (owned copy-paste components, pruned to the five in use)
  + button-based QuizCard/AnsweredCard (ask answers never graded)
- pi fully removed: TUI, extensions, Fastify+WS server, bridge, custom
  protocol all deleted (recoverable from git history)
- tests: question-core units carry over; chat-flow integration drives
  handleChatRequest with MockLanguageModelV4 (48 tests, zero tokens);
  render smoke tests guard the cards (renderToStaticMarkup)

Carried over unchanged: teacher system prompt, question-core, schema/
ledger/derive/extract pipeline, data model.
This commit is contained in:
Amos
2026-08-22 02:18:27 +03:00
parent e127a9bfbd
commit a72c902d30
81 changed files with 9643 additions and 10860 deletions
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "ai";
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};
const getMessageText = (message: UIMessage): string =>
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
export type ConversationDownloadProps = Omit<
ComponentProps<typeof Button>,
"onClick"
> & {
messages: UIMessage[];
filename?: string;
formatMessage?: (message: UIMessage, index: number) => string;
};
const defaultFormatMessage = (message: UIMessage): string => {
const roleLabel =
message.role.charAt(0).toUpperCase() + message.role.slice(1);
return `**${roleLabel}:** ${getMessageText(message)}`;
};
export const messagesToMarkdown = (
messages: UIMessage[],
formatMessage: (
message: UIMessage,
index: number
) => string = defaultFormatMessage
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
export const ConversationDownload = ({
messages,
filename = "conversation.md",
formatMessage = defaultFormatMessage,
className,
children,
...props
}: ConversationDownloadProps) => {
const handleDownload = useCallback(() => {
const markdown = messagesToMarkdown(messages, formatMessage);
const blob = new Blob([markdown], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}, [messages, filename, formatMessage]);
return (
<Button
className={cn(
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleDownload}
size="icon"
type="button"
variant="outline"
{...props}
>
{children ?? <DownloadIcon className="size-4" />}
</Button>
);
};
+363
View File
@@ -0,0 +1,363 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ButtonGroup,
ButtonGroupText,
} from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
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 type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { Streamdown, type PluginConfig } from "streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon-sm",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
interface MessageBranchContextType {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
}
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null
);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error(
"MessageBranch components must be used within MessageBranch"
);
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = useCallback(
(newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
},
[onBranchChange]
);
const goToPrevious = useCallback(() => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const goToNext = useCallback(() => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const contextValue = useMemo<MessageBranchContextType>(
() => ({
branches,
currentBranch,
goToNext,
goToPrevious,
setBranches,
totalBranches: branches.length,
}),
[branches, currentBranch, goToNext, goToPrevious]
);
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({
children,
...props
}: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = useMemo(
() => (Array.isArray(children) ? children : [children]),
[children]
);
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
export const MessageBranchSelector = ({
className,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className={cn(
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
className
)}
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
// Type-only version skew: @streamdown/code bundles shiki@3 types while
// streamdown@2.5 expects shiki@4 types. Both work at runtime (each ships its
// own highlighter); the cast papers over the .d.ts mismatch.
const streamdownPlugins = { cjk, code, math, mermaid } as unknown as PluginConfig;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
plugins={streamdownPlugins}
{...props}
/>
),
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
nextProps.isAnimating === prevProps.isAnimating
);
MessageResponse.displayName = "MessageResponse";
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
className,
children,
...props
}: MessageToolbarProps) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className
)}
{...props}
>
{children}
</div>
);
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
"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, type PluginConfig } 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;
};
// See message.tsx — same streamdown plugin type skew.
const streamdownPlugins = { cjk, code, math, mermaid } as unknown as PluginConfig;
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
View 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);
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { cn } from "@/lib/utils";
import type { QuestionAnswerData } from "@/src/core/question-core";
/**
* Static rendering of an answered question (tool part in `output-available`).
*
* Quiz answers are GRADED: ✓/✗ marks, the correct answers, and the
* explanation. Ask answers are NOT graded — a plain record of what was said.
* (The v1 bug this pins: ask answers must never get quiz grading UI.)
*/
export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
if (answer.dismissed) {
return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
<span className="font-medium">{answer.questionText}</span> — skipped
</div>
);
}
const isQuiz = answer.kind === "quiz";
const selectedIdx = new Set(answer.answers.map((a) => a.index));
const correctIdx = new Set(answer.correctIndices ?? []);
return (
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="answered-card">
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{isQuiz ? "Quiz" : "Question"}
{isQuiz &&
(answer.dontKnow ? (
<span className="rounded bg-muted px-1.5 py-0.5">didn't know</span>
) : answer.correct ? (
<span className="rounded bg-green-500/15 px-1.5 py-0.5 text-green-500">correct</span>
) : (
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">incorrect</span>
))}
</div>
<div className="mb-3 font-medium">{answer.questionText}</div>
{answer.options && answer.options.length > 0 && (
<div className="flex flex-col gap-1.5 text-sm">
{answer.options.map((opt) => {
const sel = selectedIdx.has(opt.index);
const key = isQuiz && correctIdx.has(opt.index);
return (
<div
key={opt.index}
className={cn(
"flex items-start gap-2 rounded-lg border px-3 py-1.5",
sel && key && "border-green-500/60 bg-green-500/10",
sel && !key && isQuiz && "border-red-500/60 bg-red-500/10",
sel && !isQuiz && "border-primary bg-accent",
!sel && key && "border-green-500/40",
)}
>
{isQuiz && (
<span className="w-4 shrink-0">
{sel && key && "✓"}
{sel && !key && "✗"}
{!sel && key && "✓"}
</span>
)}
<span className={cn(!sel && "text-muted-foreground")}>{opt.label}</span>
</div>
);
})}
</div>
)}
{answer.other && <div className="mt-2 text-sm">“{answer.other}”</div>}
{answer.text && <div className="mt-2 whitespace-pre-wrap text-sm">“{answer.text}”</div>}
{answer.dontKnow && isQuiz && <div className="mt-2 text-sm text-muted-foreground">Answered “I don't know”.</div>}
{isQuiz && answer.explanation && (
<div className="mt-3 rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">Why: </span>
{answer.explanation}
</div>
)}
{answer.note && <div className="mt-2 text-xs text-muted-foreground">Note: {answer.note}</div>}
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message";
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning";
import {
PromptInput,
PromptInputBody,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
type PromptInputMessage,
} from "@/components/ai-elements/prompt-input";
import { Shimmer } from "@/components/ai-elements/shimmer";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { QuizCard } from "@/components/quiz-card";
import { AnsweredCard } from "@/components/answered-card";
import type { TeacherUIMessage } from "@/src/agent/ui-message";
import type { QuestionAnswerData } from "@/src/core/question-core";
import { findPendingToolCall } from "@/src/core/pending";
/**
* The teaching session. All chat state lives in useChat; the server persists
* the canonical transcript after every turn. A pending quiz/ask is just an
* unanswered tool part — so reload/resume mid-question works for free.
*/
export function Chat({
chatId,
title,
initialMessages,
initialPrompt,
}: {
chatId: string;
title: string;
initialMessages: TeacherUIMessage[];
initialPrompt?: string;
}) {
const router = useRouter();
const { messages, sendMessage, addToolOutput, status, error, stop } =
useChat<TeacherUIMessage>({
id: chatId,
messages: initialMessages,
transport: new DefaultChatTransport({ api: "/api/chat", body: { chatId } }),
// After the learner answers (addToolOutput), automatically send the
// continuation request so the teacher reacts to the answer.
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
});
// New chat with a topic: send the first message exactly once.
const sentInitial = useRef(false);
useEffect(() => {
if (!sentInitial.current && initialPrompt && initialMessages.length === 0) {
sentInitial.current = true;
void sendMessage({ text: initialPrompt });
}
}, [initialPrompt, initialMessages.length, sendMessage]);
const pending = findPendingToolCall(messages);
const busy = status === "submitted" || status === "streaming";
const submitAnswer = (toolCallId: string, tool: "quiz" | "ask_user_question", output: QuestionAnswerData) => {
void addToolOutput({ tool, toolCallId, output });
};
const onComposerSubmit = (msg: PromptInputMessage) => {
if (!msg.text.trim() || busy || pending) return;
void sendMessage({ text: msg.text });
};
return (
<div className="flex h-dvh flex-col">
<header className="flex items-center gap-3 border-b px-4 py-3">
<Button variant="ghost" size="sm" onClick={() => router.push("/")}>
← Sessions
</Button>
<div className="min-w-0 flex-1 truncate font-medium">{title}</div>
{busy && (
<Button variant="outline" size="sm" onClick={() => stop()}>
Stop
</Button>
)}
</header>
<Conversation className="flex-1">
<ConversationContent className="mx-auto w-full max-w-3xl gap-4 px-4 py-6">
{messages.length === 0 && !busy && (
<ConversationEmptyState
title="New session"
description="The teacher will open by probing what you already know."
/>
)}
{messages.map((m, mi) => (
<Message key={m.id} from={m.role}>
<MessageContent>
{m.role === "user" ? (
<div className="whitespace-pre-wrap">{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}</div>
) : (
m.parts.map((part, pi) => {
const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1;
switch (part.type) {
case "text":
return <MessageResponse key={pi}>{part.text}</MessageResponse>;
case "reasoning":
return (
<Reasoning key={pi} isStreaming={status === "streaming" && isLastPart} className="mb-2">
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
case "step-start":
return pi > 0 ? <Separator key={pi} className="my-3" /> : null;
case "tool-quiz":
case "tool-ask_user_question": {
const tool = part.type === "tool-quiz" ? ("quiz" as const) : ("ask_user_question" as const);
if (part.state === "output-available") {
return <AnsweredCard key={pi} answer={part.output as QuestionAnswerData} />;
}
if (part.state === "input-available") {
return (
<QuizCard
key={pi}
toolName={tool}
toolCallId={part.toolCallId}
input={part.input}
onSubmit={(output) => submitAnswer(part.toolCallId, tool, output)}
/>
);
}
// input-streaming: the question is still being generated
return (
<div key={pi} className="rounded-xl border bg-card p-4">
<Shimmer className="text-sm">Preparing a question…</Shimmer>
</div>
);
}
default:
return null;
}
})
)}
</MessageContent>
</Message>
))}
{status === "submitted" && <Shimmer className="text-sm">Thinking…</Shimmer>}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
Something went wrong: {error.message}
</div>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<div className="border-t px-4 py-3">
<div className="mx-auto w-full max-w-3xl">
{pending && (
<div className="mb-2 text-center text-xs text-muted-foreground">
Answer the question above to continue.
</div>
)}
<PromptInput onSubmit={onComposerSubmit}>
<PromptInputBody>
<PromptInputTextarea
placeholder={pending ? "Answer the question above first…" : "Ask, answer, or steer the lesson…"}
disabled={!!pending}
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools />
<PromptInputSubmit status={status} disabled={busy || !!pending} />
</PromptInputFooter>
</PromptInput>
</div>
</div>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { ChatSummary } from "@/src/store/chats";
export function Home({ chats }: { chats: ChatSummary[] }) {
const router = useRouter();
const [topic, setTopic] = useState("");
const [busy, setBusy] = useState(false);
const start = async () => {
const t = topic.trim();
if (!t || busy) return;
setBusy(true);
try {
const res = await fetch("/api/chats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: t }),
});
const { id } = (await res.json()) as { id: string };
router.push(`/chat/${id}?prompt=${encodeURIComponent(t)}`);
} catch {
setBusy(false);
}
};
return (
<main className="mx-auto flex min-h-dvh w-full max-w-2xl flex-col px-4 py-10">
<h1 className="mb-1 text-2xl font-semibold">Learning Harness</h1>
<p className="mb-8 text-sm text-muted-foreground">
A teacher with a memory. Start a session on anything you want to understand.
</p>
<form
className="mb-10 flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void start();
}}
>
<Input
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="What do you want to learn?"
autoFocus
/>
<Button type="submit" disabled={!topic.trim() || busy}>
{busy ? "Starting…" : "Start"}
</Button>
</form>
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-muted-foreground">Sessions</h2>
{chats.length === 0 ? (
<p className="text-sm text-muted-foreground">No sessions yet.</p>
) : (
<div className="flex flex-col gap-2">
{chats.map((c) => (
<button
key={c.id}
type="button"
onClick={() => router.push(`/chat/${c.id}`)}
className="rounded-lg border px-4 py-3 text-left transition-colors hover:bg-accent"
>
<div className="truncate font-medium">{c.title}</div>
<div className="text-xs text-muted-foreground">
{c.messageCount} messages · {new Date(c.updatedAt).toLocaleString()}
</div>
</button>
))}
</div>
)}
</main>
);
}
+219
View File
@@ -0,0 +1,219 @@
"use client";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { AskToolInput, QuizToolInput } from "@/src/agent/tools";
import {
buildAnswer,
coerceCorrectAnswer,
normalizeOptions,
resolveCorrect,
shuffleOptions,
type AnswerRequest,
type QuestionAnswerData,
type QuestionData,
type QuestionMode,
} from "@/src/core/question-core";
/**
* The pending interaction card — renders when a quiz/ask tool part is in
* `input-available` state. Owns selection state (single source of truth —
* buttons, not native inputs), grades via question-core, and submits the
* structured answer record as the tool output.
*
* Quiz: graded (single/multi-select + "I don't know").
* Ask: not graded (single/multi-select + "Other" free text, or text mode).
*/
export interface QuizCardProps {
toolName: "quiz" | "ask_user_question";
toolCallId: string;
input: QuizToolInput | AskToolInput;
onSubmit: (output: QuestionAnswerData) => void;
}
export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProps) {
const kind = toolName === "quiz" ? ("quiz" as const) : ("ask" as const);
// Build the canonical QuestionData once per question. Quiz options are
// shuffled so position never leaks correctness; the display order is
// snapshotted into the answer record at submit time.
const qd = useMemo<QuestionData>(() => {
const quizInput = kind === "quiz" ? (input as QuizToolInput) : null;
const askInput = kind === "ask" ? (input as AskToolInput) : null;
const multi = input.multiSelect === true;
const mode: QuestionMode =
kind === "quiz"
? multi
? "multi-select"
: "single-select"
: (askInput?.mode ?? (askInput?.options?.length ? (multi ? "multi-select" : "single-select") : "text"));
const options = normalizeOptions((quizInput?.options ?? askInput?.options ?? []) as { label: string; value?: string }[]);
const ordered = kind === "quiz" && options.length > 0 ? shuffleOptions(options) : options;
const q: QuestionData = {
id: toolCallId,
kind,
question: input.question,
context: input.details,
mode,
options: ordered,
createdAt: new Date().toISOString(),
};
if (quizInput) {
const resolved = resolveCorrect(coerceCorrectAnswer(quizInput.correctAnswer), ordered);
if (resolved.error) console.error("[quiz] correctAnswer did not resolve:", resolved.error);
q.correctIndices = resolved.indices;
q.explanation = quizInput.explanation;
}
return q;
}, [toolCallId, kind, input]);
const [selected, setSelected] = useState<Set<number>>(new Set()); // 0-based indices
const [dontKnow, setDontKnow] = useState(false);
const [showOther, setShowOther] = useState(false);
const [other, setOther] = useState("");
const [text, setText] = useState("");
const [note, setNote] = useState("");
const toggle = (idx: number) => {
setDontKnow(false);
setShowOther(false);
setSelected((prev) => {
if (qd.mode === "multi-select") {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx);
else next.add(idx);
return next;
}
return new Set([idx]);
});
};
const pickDontKnow = () => {
setSelected(new Set());
setShowOther(false);
setDontKnow((v) => !v);
};
const pickOther = () => {
setSelected(new Set());
setDontKnow(false);
setShowOther(true);
};
const canSubmit =
qd.mode === "text"
? text.trim().length > 0
: dontKnow || (showOther && other.trim().length > 0) || selected.size > 0;
const submit = () => {
if (!canSubmit) return;
const req: AnswerRequest = {
dontKnow,
values: [...selected].map((i) => qd.options[i]!.value),
other: showOther ? other : undefined,
text: qd.mode === "text" ? text : undefined,
note: note.trim() || undefined,
};
onSubmit(buildAnswer(qd, req));
};
return (
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="quiz-card">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{kind === "quiz" ? "Quiz" : "Question"}
{qd.mode === "multi-select" && " · select all that apply"}
</div>
<div className="mb-3 font-medium">{qd.question}</div>
{qd.context && <div className="mb-3 text-sm text-muted-foreground">{qd.context}</div>}
{qd.mode !== "text" && (
<div className="flex flex-col gap-2" role={qd.mode === "multi-select" ? "group" : "radiogroup"}>
{qd.options.map((opt, i) => {
const isSel = selected.has(i);
return (
<button
key={opt.value}
type="button"
role={qd.mode === "multi-select" ? "checkbox" : "radio"}
aria-checked={isSel}
onClick={() => toggle(i)}
className={cn(
"flex items-start gap-3 rounded-lg border px-3 py-2 text-left text-sm transition-colors",
isSel ? "border-primary bg-accent" : "hover:bg-accent/50",
)}
>
<span
className={cn(
"mt-0.5 flex size-4 shrink-0 items-center justify-center border",
qd.mode === "multi-select" ? "rounded-sm" : "rounded-full",
isSel ? "border-primary bg-primary text-primary-foreground" : "border-muted-foreground/40",
)}
>
{isSel && <span className={cn(qd.mode === "multi-select" ? "text-[10px]" : "size-1.5 rounded-full bg-current")}>{qd.mode === "multi-select" ? "✓" : ""}</span>}
</span>
<span>
{opt.label}
{opt.description && <span className="block text-xs text-muted-foreground">{opt.description}</span>}
</span>
</button>
);
})}
{kind === "quiz" && (
<button
type="button"
onClick={pickDontKnow}
aria-pressed={dontKnow}
className={cn(
"rounded-lg border border-dashed px-3 py-2 text-left text-sm text-muted-foreground transition-colors",
dontKnow && "border-primary text-foreground",
)}
>
I don't know
</button>
)}
{kind === "ask" && (
<button
type="button"
onClick={pickOther}
aria-pressed={showOther}
className={cn(
"rounded-lg border border-dashed px-3 py-2 text-left text-sm text-muted-foreground transition-colors",
showOther && "border-primary text-foreground",
)}
>
Other…
</button>
)}
</div>
)}
{(qd.mode === "text" || showOther) && (
<textarea
className="mt-2 w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
rows={qd.mode === "text" ? 4 : 2}
placeholder={qd.mode === "text" ? "Type your answer…" : "Type your own answer…"}
value={qd.mode === "text" ? text : other}
onChange={(e) => (qd.mode === "text" ? setText(e.target.value) : setOther(e.target.value))}
/>
)}
<input
type="text"
className="mt-2 w-full rounded-lg border bg-background px-3 py-1.5 text-sm outline-none focus:ring-1 focus:ring-ring"
placeholder="Add a note (optional) — anything you're unsure about"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
<div className="mt-3 flex justify-end">
<Button size="sm" onClick={submit} disabled={!canSubmit}>
Submit answer
</Button>
</div>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
className={cn(
"flex items-center gap-2 rounded-md border bg-muted px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative m-0! self-stretch bg-input data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none 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]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
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 }
+33
View File
@@ -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, CollapsibleTrigger, CollapsibleContent }
+184
View File
@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+158
View File
@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
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(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
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(
"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 duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 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:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<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("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+257
View File
@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+44
View File
@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
+170
View File
@@ -0,0 +1,170 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
// Error state.
"has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
"block-end":
"order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+21
View File
@@ -0,0 +1,21 @@
import * 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 transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground 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 }
+190
View File
@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
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=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+16
View File
@@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+57
View File
@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
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(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
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, TooltipTrigger, TooltipContent, TooltipProvider }