feat: CodePilot - desktop GUI for Claude Code

Full-featured desktop application built with Next.js + Electron:
- Streaming chat with Claude Code via Agent SDK
- Session management with SQLite persistence
- Project file tree and file preview
- MCP server and custom skills management
- Permission controls with Code/Plan/Ask modes
- Dark/light theme, token tracking
- macOS DMG packaging with electron-builder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-06 21:04:05 +08:00
1 parent 6776cc6297
commit ff1fb38735
149 files changed
+31117 -122

No files matched your search

+149
View File
@@ -0,0 +1,149 @@
"use client";
import type { LucideIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { XIcon } from "lucide-react";
export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div
className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
className
)}
{...props}
/>
);
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactHeader = ({
className,
...props
}: ArtifactHeaderProps) => (
<div
className={cn(
"flex items-center justify-between border-b bg-muted/50 px-4 py-3",
className
)}
{...props}
/>
);
export type ArtifactCloseProps = ComponentProps<typeof Button>;
export const ArtifactClose = ({
className,
children,
size = "sm",
variant = "ghost",
...props
}: ArtifactCloseProps) => (
<Button
className={cn(
"size-8 p-0 text-muted-foreground hover:text-foreground",
className
)}
size={size}
type="button"
variant={variant}
{...props}
>
{children ?? <XIcon className="size-4" />}
<span className="sr-only">Close</span>
</Button>
);
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
<p
className={cn("font-medium text-foreground text-sm", className)}
{...props}
/>
);
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
export const ArtifactDescription = ({
className,
...props
}: ArtifactDescriptionProps) => (
<p className={cn("text-muted-foreground text-sm", className)} {...props} />
);
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactActions = ({
className,
...props
}: ArtifactActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props} />
);
export type ArtifactActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
icon?: LucideIcon;
};
export const ArtifactAction = ({
tooltip,
label,
icon: Icon,
children,
className,
size = "sm",
variant = "ghost",
...props
}: ArtifactActionProps) => {
const button = (
<Button
className={cn(
"size-8 p-0 text-muted-foreground hover:text-foreground",
className
)}
size={size}
type="button"
variant={variant}
{...props}
>
{Icon ? <Icon className="size-4" /> : 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;
};
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactContent = ({
className,
...props
}: ArtifactContentProps) => (
<div className={cn("flex-1 overflow-auto p-4", className)} {...props} />
);
@@ -0,0 +1,223 @@
"use client";
import type { LucideIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
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 { BrainIcon, ChevronDownIcon, DotIcon } from "lucide-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";
+555
View File
@@ -0,0 +1,555 @@
"use client";
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
import type {
BundledLanguage,
BundledTheme,
HighlighterGeneric,
ThemedToken,
} from "shiki";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { createHighlighter } from "shiki";
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
// eslint-disable-next-line no-bitwise -- shiki bitflag check
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
// eslint-disable-next-line no-bitwise -- shiki bitflag check
// oxlint-disable-next-line eslint(no-bitwise)
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
const isUnderline = (fontStyle: number | undefined) =>
// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
// oxlint-disable-next-line eslint(no-bitwise)
fontStyle && fontStyle & 4;
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
interface KeyedToken {
token: ThemedToken;
key: string;
}
interface KeyedLine {
tokens: KeyedToken[];
key: string;
}
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
lines.map((line, lineIdx) => ({
key: `line-${lineIdx}`,
tokens: line.map((token, tokenIdx) => ({
key: `line-${lineIdx}-${tokenIdx}`,
token,
})),
}));
// Token rendering component
const TokenSpan = ({ token }: { token: ThemedToken }) => (
<span
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
style={
{
backgroundColor: token.bgColor,
color: token.color,
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
...token.htmlStyle,
} as CSSProperties
}
>
{token.content}
</span>
);
// Line rendering component
const LineSpan = ({
keyedLine,
showLineNumbers,
}: {
keyedLine: KeyedLine;
showLineNumbers: boolean;
}) => (
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
{keyedLine.tokens.length === 0
? "\n"
: keyedLine.tokens.map(({ token, key }) => (
<TokenSpan key={key} token={token} />
))}
</span>
);
// Types
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
};
interface TokenizedCode {
tokens: ThemedToken[][];
fg: string;
bg: string;
}
interface CodeBlockContextType {
code: string;
}
// Context
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
// Highlighter cache (singleton per language)
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
>();
// Token cache
const tokensCache = new Map<string, TokenizedCode>();
// Subscribers for async token updates
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
};
const getHighlighter = (
language: BundledLanguage
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
const cached = highlighterCache.get(language);
if (cached) {
return cached;
}
const highlighterPromise = createHighlighter({
langs: [language],
themes: ["github-light", "github-dark"],
});
highlighterCache.set(language, highlighterPromise);
return highlighterPromise;
};
// Create raw tokens for immediate display while highlighting loads
const createRawTokens = (code: string): TokenizedCode => ({
bg: "transparent",
fg: "inherit",
tokens: code.split("\n").map((line) =>
line === ""
? []
: [
{
color: "inherit",
content: line,
} as ThemedToken,
]
),
});
// Synchronous highlight with callback for async results
export const highlightCode = (
code: string,
language: BundledLanguage,
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
callback?: (result: TokenizedCode) => void
): TokenizedCode | null => {
const tokensCacheKey = getTokensCacheKey(code, language);
// Return cached result if available
const cached = tokensCache.get(tokensCacheKey);
if (cached) {
return cached;
}
// Subscribe callback if provided
if (callback) {
if (!subscribers.has(tokensCacheKey)) {
subscribers.set(tokensCacheKey, new Set());
}
subscribers.get(tokensCacheKey)?.add(callback);
}
// Start highlighting in background - fire-and-forget async pattern
getHighlighter(language)
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
.then((highlighter) => {
const availableLangs = highlighter.getLoadedLanguages();
const langToUse = availableLangs.includes(language) ? language : "text";
const result = highlighter.codeToTokens(code, {
lang: langToUse,
themes: {
dark: "github-dark",
light: "github-light",
},
});
const tokenized: TokenizedCode = {
bg: result.bg ?? "transparent",
fg: result.fg ?? "inherit",
tokens: result.tokens,
};
// Cache the result
tokensCache.set(tokensCacheKey, tokenized);
// Notify all subscribers
const subs = subscribers.get(tokensCacheKey);
if (subs) {
for (const sub of subs) {
sub(tokenized);
}
subscribers.delete(tokensCacheKey);
}
})
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
.catch((error) => {
console.error("Failed to highlight code:", error);
subscribers.delete(tokensCacheKey);
});
return null;
};
// Line number styles using CSS counters
const LINE_NUMBER_CLASSES = cn(
"block",
"before:content-[counter(line)]",
"before:inline-block",
"before:[counter-increment:line]",
"before:w-8",
"before:mr-4",
"before:text-right",
"before:text-muted-foreground/50",
"before:font-mono",
"before:select-none"
);
const CodeBlockBody = memo(
({
tokenized,
showLineNumbers,
className,
}: {
tokenized: TokenizedCode;
showLineNumbers: boolean;
className?: string;
}) => {
const preStyle = useMemo(
() => ({
backgroundColor: tokenized.bg,
color: tokenized.fg,
}),
[tokenized.bg, tokenized.fg]
);
const keyedLines = useMemo(
() => addKeysToTokens(tokenized.tokens),
[tokenized.tokens]
);
return (
<pre
className={cn(
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
className
)}
style={preStyle}
>
<code
className={cn(
"font-mono text-sm",
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
)}
>
{keyedLines.map((keyedLine) => (
<LineSpan
key={keyedLine.key}
keyedLine={keyedLine}
showLineNumbers={showLineNumbers}
/>
))}
</code>
</pre>
);
},
(prevProps, nextProps) =>
prevProps.tokenized === nextProps.tokenized &&
prevProps.showLineNumbers === nextProps.showLineNumbers &&
prevProps.className === nextProps.className
);
CodeBlockBody.displayName = "CodeBlockBody";
export const CodeBlockContainer = ({
className,
language,
style,
...props
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
<div
className={cn(
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className
)}
data-language={language}
style={{
containIntrinsicSize: "auto 200px",
contentVisibility: "auto",
...style,
}}
{...props}
/>
);
export const CodeBlockHeader = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
className
)}
{...props}
>
{children}
</div>
);
export const CodeBlockTitle = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-center gap-2", className)} {...props}>
{children}
</div>
);
export const CodeBlockFilename = ({
children,
className,
...props
}: HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("font-mono", className)} {...props}>
{children}
</span>
);
export const CodeBlockActions = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
{...props}
>
{children}
</div>
);
export const CodeBlockContent = ({
code,
language,
showLineNumbers = false,
}: {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
}) => {
// Memoized raw tokens for immediate display
const rawTokens = useMemo(() => createRawTokens(code), [code]);
// Try to get cached result synchronously, otherwise use raw tokens
const [tokenized, setTokenized] = useState<TokenizedCode>(
() => highlightCode(code, language) ?? rawTokens
);
useEffect(() => {
let cancelled = false;
// Reset to raw tokens when code changes (shows current code, not stale tokens)
setTokenized(highlightCode(code, language) ?? rawTokens);
// Subscribe to async highlighting result
highlightCode(code, language, (result) => {
if (!cancelled) {
setTokenized(result);
}
});
return () => {
cancelled = true;
};
}, [code, language, rawTokens]);
return (
<div className="relative overflow-auto">
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
</div>
);
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const contextValue = useMemo(() => ({ code }), [code]);
return (
<CodeBlockContext.Provider value={contextValue}>
<CodeBlockContainer className={className} language={language} {...props}>
{children}
<CodeBlockContent
code={code}
language={language}
showLineNumbers={showLineNumbers}
/>
</CodeBlockContainer>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
if (!isCopied) {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(
() => setIsCopied(false),
timeout
);
}
} catch (error) {
onError?.(error as Error);
}
}, [code, onCopy, onError, timeout, isCopied]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
export const CodeBlockLanguageSelector = (
props: CodeBlockLanguageSelectorProps
) => <Select {...props} />;
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const CodeBlockLanguageSelectorTrigger = ({
className,
...props
}: CodeBlockLanguageSelectorTriggerProps) => (
<SelectTrigger
className={cn(
"h-7 border-none bg-transparent px-2 text-xs shadow-none",
className
)}
size="sm"
{...props}
/>
);
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
typeof SelectValue
>;
export const CodeBlockLanguageSelectorValue = (
props: CodeBlockLanguageSelectorValueProps
) => <SelectValue {...props} />;
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
typeof SelectContent
>;
export const CodeBlockLanguageSelectorContent = ({
align = "end",
...props
}: CodeBlockLanguageSelectorContentProps) => (
<SelectContent align={align} {...props} />
);
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
typeof SelectItem
>;
export const CodeBlockLanguageSelectorItem = (
props: CodeBlockLanguageSelectorItemProps
) => <SelectItem {...props} />;
+173
View File
@@ -0,0 +1,173 @@
"use client";
import type { ToolUIPart } from "ai";
import type { ComponentProps, ReactNode } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { createContext, useContext } from "react";
type ToolUIPartApproval =
| {
id: string;
approved?: never;
reason?: never;
}
| {
id: string;
approved: boolean;
reason?: string;
}
| {
id: string;
approved: true;
reason?: string;
}
| {
id: string;
approved: true;
reason?: string;
}
| {
id: string;
approved: false;
reason?: string;
}
| undefined;
interface ConfirmationContextValue {
approval: ToolUIPartApproval;
state: ToolUIPart["state"];
}
const ConfirmationContext = createContext<ConfirmationContextValue | null>(
null
);
const useConfirmation = () => {
const context = useContext(ConfirmationContext);
if (!context) {
throw new Error("Confirmation components must be used within Confirmation");
}
return context;
};
export type ConfirmationProps = ComponentProps<typeof Alert> & {
approval?: ToolUIPartApproval;
state: ToolUIPart["state"];
};
export const Confirmation = ({
className,
approval,
state,
...props
}: ConfirmationProps) => {
if (!approval || state === "input-streaming" || state === "input-available") {
return null;
}
return (
<ConfirmationContext.Provider value={{ approval, state }}>
<Alert className={cn("flex flex-col gap-2", className)} {...props} />
</ConfirmationContext.Provider>
);
};
export type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>;
export const ConfirmationTitle = ({
className,
...props
}: ConfirmationTitleProps) => (
<AlertDescription className={cn("inline", className)} {...props} />
);
export interface ConfirmationRequestProps {
children?: ReactNode;
}
export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
const { state } = useConfirmation();
// Only show when approval is requested
if (state !== "approval-requested") {
return null;
}
return children;
};
export interface ConfirmationAcceptedProps {
children?: ReactNode;
}
export const ConfirmationAccepted = ({
children,
}: ConfirmationAcceptedProps) => {
const { approval, state } = useConfirmation();
// Only show when approved and in response states
if (
!approval?.approved ||
(state !== "approval-responded" &&
state !== "output-denied" &&
state !== "output-available")
) {
return null;
}
return children;
};
export interface ConfirmationRejectedProps {
children?: ReactNode;
}
export const ConfirmationRejected = ({
children,
}: ConfirmationRejectedProps) => {
const { approval, state } = useConfirmation();
// Only show when rejected and in response states
if (
approval?.approved !== false ||
(state !== "approval-responded" &&
state !== "output-denied" &&
state !== "output-available")
) {
return null;
}
return children;
};
export type ConfirmationActionsProps = ComponentProps<"div">;
export const ConfirmationActions = ({
className,
...props
}: ConfirmationActionsProps) => {
const { state } = useConfirmation();
// Only show when approval is requested
if (state !== "approval-requested") {
return null;
}
return (
<div
className={cn("flex items-center justify-end gap-2 self-end", className)}
{...props}
/>
);
};
export type ConfirmationActionProps = ComponentProps<typeof Button>;
export const ConfirmationAction = (props: ConfirmationActionProps) => (
<Button className="h-8 px-3 text-sm" type="button" {...props} />
);
+167
View File
@@ -0,0 +1,167 @@
"use client";
import type { ComponentProps } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ArrowDownIcon, DownloadIcon } from "lucide-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>
)
);
};
export interface ConversationMessage {
role: "user" | "assistant" | "system" | "data" | "tool";
content: string;
}
export type ConversationDownloadProps = Omit<
ComponentProps<typeof Button>,
"onClick"
> & {
messages: ConversationMessage[];
filename?: string;
formatMessage?: (message: ConversationMessage, index: number) => string;
};
const defaultFormatMessage = (message: ConversationMessage): string => {
const roleLabel =
message.role.charAt(0).toUpperCase() + message.role.slice(1);
return `**${roleLabel}:** ${message.content}`;
};
export const messagesToMarkdown = (
messages: ConversationMessage[],
formatMessage: (
message: ConversationMessage,
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>
);
};
+298
View File
@@ -0,0 +1,298 @@
"use client";
import type { HTMLAttributes, ReactNode } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import {
ChevronRightIcon,
FileIcon,
FolderIcon,
FolderOpenIcon,
} from "lucide-react";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from "react";
interface FileTreeContextType {
expandedPaths: Set<string>;
togglePath: (path: string) => void;
selectedPath?: string;
onSelect?: (path: string) => void;
}
// Default noop for context default value
// oxlint-disable-next-line eslint(no-empty-function)
const noop = () => {};
const FileTreeContext = createContext<FileTreeContextType>({
// oxlint-disable-next-line eslint-plugin-unicorn(no-new-builtin)
expandedPaths: new Set(),
togglePath: noop,
});
export type FileTreeProps = HTMLAttributes<HTMLDivElement> & {
expanded?: Set<string>;
defaultExpanded?: Set<string>;
selectedPath?: string;
onSelect?: (path: string) => void;
onExpandedChange?: (expanded: Set<string>) => void;
};
export const FileTree = ({
expanded: controlledExpanded,
defaultExpanded = new Set(),
selectedPath,
onSelect,
onExpandedChange,
className,
children,
...props
}: FileTreeProps) => {
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
const expandedPaths = controlledExpanded ?? internalExpanded;
const togglePath = useCallback(
(path: string) => {
const newExpanded = new Set(expandedPaths);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setInternalExpanded(newExpanded);
onExpandedChange?.(newExpanded);
},
[expandedPaths, onExpandedChange]
);
const contextValue = useMemo(
() => ({ expandedPaths, onSelect, selectedPath, togglePath }),
[expandedPaths, onSelect, selectedPath, togglePath]
);
return (
<FileTreeContext.Provider value={contextValue}>
<div
className={cn(
"rounded-lg border bg-background font-mono text-sm",
className
)}
role="tree"
{...props}
>
<div className="p-2">{children}</div>
</div>
</FileTreeContext.Provider>
);
};
interface FileTreeFolderContextType {
path: string;
name: string;
isExpanded: boolean;
}
const FileTreeFolderContext = createContext<FileTreeFolderContextType>({
isExpanded: false,
name: "",
path: "",
});
export type FileTreeFolderProps = HTMLAttributes<HTMLDivElement> & {
path: string;
name: string;
};
export const FileTreeFolder = ({
path,
name,
className,
children,
...props
}: FileTreeFolderProps) => {
const { expandedPaths, togglePath, selectedPath, onSelect } =
useContext(FileTreeContext);
const isExpanded = expandedPaths.has(path);
const isSelected = selectedPath === path;
const handleOpenChange = useCallback(() => {
togglePath(path);
}, [togglePath, path]);
const handleSelect = useCallback(() => {
onSelect?.(path);
}, [onSelect, path]);
const folderContextValue = useMemo(
() => ({ isExpanded, name, path }),
[isExpanded, name, path]
);
return (
<FileTreeFolderContext.Provider value={folderContextValue}>
<Collapsible onOpenChange={handleOpenChange} open={isExpanded}>
<div
className={cn("", className)}
role="treeitem"
tabIndex={0}
{...props}
>
<CollapsibleTrigger asChild>
<button
className={cn(
"flex w-full items-center gap-1 rounded px-2 py-1 text-left transition-colors hover:bg-muted/50",
isSelected && "bg-muted"
)}
onClick={handleSelect}
type="button"
>
<ChevronRightIcon
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
isExpanded && "rotate-90"
)}
/>
<FileTreeIcon>
{isExpanded ? (
<FolderOpenIcon className="size-4 text-blue-500" />
) : (
<FolderIcon className="size-4 text-blue-500" />
)}
</FileTreeIcon>
<FileTreeName>{name}</FileTreeName>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="ml-4 border-l pl-2">{children}</div>
</CollapsibleContent>
</div>
</Collapsible>
</FileTreeFolderContext.Provider>
);
};
interface FileTreeFileContextType {
path: string;
name: string;
}
const FileTreeFileContext = createContext<FileTreeFileContextType>({
name: "",
path: "",
});
export type FileTreeFileProps = HTMLAttributes<HTMLDivElement> & {
path: string;
name: string;
icon?: ReactNode;
};
export const FileTreeFile = ({
path,
name,
icon,
className,
children,
...props
}: FileTreeFileProps) => {
const { selectedPath, onSelect } = useContext(FileTreeContext);
const isSelected = selectedPath === path;
const handleClick = useCallback(() => {
onSelect?.(path);
}, [onSelect, path]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
onSelect?.(path);
}
},
[onSelect, path]
);
const fileContextValue = useMemo(() => ({ name, path }), [name, path]);
return (
<FileTreeFileContext.Provider value={fileContextValue}>
<div
className={cn(
"flex cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50",
isSelected && "bg-muted",
className
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
role="treeitem"
tabIndex={0}
{...props}
>
{children ?? (
<>
{/* Spacer for alignment */}
<span className="size-4" />
<FileTreeIcon>
{icon ?? <FileIcon className="size-4 text-muted-foreground" />}
</FileTreeIcon>
<FileTreeName>{name}</FileTreeName>
</>
)}
</div>
</FileTreeFileContext.Provider>
);
};
export type FileTreeIconProps = HTMLAttributes<HTMLSpanElement>;
export const FileTreeIcon = ({
className,
children,
...props
}: FileTreeIconProps) => (
<span className={cn("shrink-0", className)} {...props}>
{children}
</span>
);
export type FileTreeNameProps = HTMLAttributes<HTMLSpanElement>;
export const FileTreeName = ({
className,
children,
...props
}: FileTreeNameProps) => (
<span className={cn("truncate", className)} {...props}>
{children}
</span>
);
export type FileTreeActionsProps = HTMLAttributes<HTMLDivElement>;
const stopPropagation = (e: React.SyntheticEvent) => e.stopPropagation();
export const FileTreeActions = ({
className,
children,
...props
}: FileTreeActionsProps) => (
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation required for nested interactions
// biome-ignore lint/a11y/useSemanticElements: fieldset doesn't fit this UI pattern
<div
className={cn("ml-auto flex items-center gap-1", className)}
onClick={stopPropagation}
onKeyDown={stopPropagation}
role="group"
{...props}
>
{children}
</div>
);
+359
View File
@@ -0,0 +1,359 @@
"use client";
import type { UIMessage } from "ai";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
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 { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { Streamdown } 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>;
const streamdownPlugins = { cjk, code, math, mermaid };
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
);
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>
);
@@ -0,0 +1,214 @@
import type { ComponentProps, ReactNode } from "react";
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";
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} />
);
File diff suppressed because it is too large. Load diff
+229
View File
@@ -0,0 +1,229 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
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 {
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} {...props}>
{children}
</Streamdown>
</CollapsibleContent>
)
);
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";
+78
View File
@@ -0,0 +1,78 @@
"use client";
import type { MotionProps } from "motion/react";
import type { CSSProperties, ElementType, JSX } from "react";
import { cn } from "@/lib/utils";
import { motion } from "motion/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);
+78
View File
@@ -0,0 +1,78 @@
"use client";
import type { ComponentProps } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { BookIcon, ChevronDownIcon } from "lucide-react";
export type SourcesProps = ComponentProps<"div">;
export const Sources = ({ className, ...props }: SourcesProps) => (
<Collapsible
className={cn("not-prose mb-4 text-primary text-xs", className)}
{...props}
/>
);
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
count: number;
};
export const SourcesTrigger = ({
className,
count,
children,
...props
}: SourcesTriggerProps) => (
<CollapsibleTrigger
className={cn("flex items-center gap-2", className)}
{...props}
>
{children ?? (
<>
<p className="font-medium">Used {count} sources</p>
<ChevronDownIcon className="h-4 w-4" />
</>
)}
</CollapsibleTrigger>
);
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
export const SourcesContent = ({
className,
...props
}: SourcesContentProps) => (
<CollapsibleContent
className={cn(
"mt-3 flex w-fit flex-col gap-2",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type SourceProps = ComponentProps<"a">;
export const Source = ({ href, title, children, ...props }: SourceProps) => (
<a
className="flex items-center gap-2"
href={href}
rel="noreferrer"
target="_blank"
{...props}
>
{children ?? (
<>
<BookIcon className="h-4 w-4" />
<span className="block font-medium">{title}</span>
</>
)}
</a>
);
+58
View File
@@ -0,0 +1,58 @@
"use client";
import type { ComponentProps } from "react";
import { Button } from "@/components/ui/button";
import {
ScrollArea,
ScrollBar,
} from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { useCallback } from "react";
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
export const Suggestions = ({
className,
children,
...props
}: SuggestionsProps) => (
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
{children}
</div>
<ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea>
);
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
suggestion: string;
onClick?: (suggestion: string) => void;
};
export const Suggestion = ({
suggestion,
onClick,
className,
variant = "outline",
size = "sm",
children,
...props
}: SuggestionProps) => {
const handleClick = useCallback(() => {
onClick?.(suggestion);
}, [onClick, suggestion]);
return (
<Button
className={cn("cursor-pointer rounded-full px-4", className)}
onClick={handleClick}
size={size}
type="button"
variant={variant}
{...props}
>
{children || suggestion}
</Button>
);
};
+88
View File
@@ -0,0 +1,88 @@
"use client";
import type { ComponentProps } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, SearchIcon } from "lucide-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>
);
+277
View File
@@ -0,0 +1,277 @@
"use client";
import type { ComponentProps, HTMLAttributes } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import Ansi from "ansi-to-react";
import { CheckIcon, CopyIcon, TerminalIcon, Trash2Icon } from "lucide-react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Shimmer } from "./shimmer";
interface TerminalContextType {
output: string;
isStreaming: boolean;
autoScroll: boolean;
onClear?: () => void;
}
const TerminalContext = createContext<TerminalContextType>({
autoScroll: true,
isStreaming: false,
output: "",
});
export type TerminalProps = HTMLAttributes<HTMLDivElement> & {
output: string;
isStreaming?: boolean;
autoScroll?: boolean;
onClear?: () => void;
};
export const Terminal = ({
output,
isStreaming = false,
autoScroll = true,
onClear,
className,
children,
...props
}: TerminalProps) => {
const contextValue = useMemo(
() => ({ autoScroll, isStreaming, onClear, output }),
[autoScroll, isStreaming, onClear, output]
);
return (
<TerminalContext.Provider value={contextValue}>
<div
className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-zinc-950 text-zinc-100",
className
)}
{...props}
>
{children ?? (
<>
<TerminalHeader>
<TerminalTitle />
<div className="flex items-center gap-1">
<TerminalStatus />
<TerminalActions>
<TerminalCopyButton />
{onClear && <TerminalClearButton />}
</TerminalActions>
</div>
</TerminalHeader>
<TerminalContent />
</>
)}
</div>
</TerminalContext.Provider>
);
};
export type TerminalHeaderProps = HTMLAttributes<HTMLDivElement>;
export const TerminalHeader = ({
className,
children,
...props
}: TerminalHeaderProps) => (
<div
className={cn(
"flex items-center justify-between border-zinc-800 border-b px-4 py-2",
className
)}
{...props}
>
{children}
</div>
);
export type TerminalTitleProps = HTMLAttributes<HTMLDivElement>;
export const TerminalTitle = ({
className,
children,
...props
}: TerminalTitleProps) => (
<div
className={cn("flex items-center gap-2 text-sm text-zinc-400", className)}
{...props}
>
<TerminalIcon className="size-4" />
{children ?? "Terminal"}
</div>
);
export type TerminalStatusProps = HTMLAttributes<HTMLDivElement>;
export const TerminalStatus = ({
className,
children,
...props
}: TerminalStatusProps) => {
const { isStreaming } = useContext(TerminalContext);
if (!isStreaming) {
return null;
}
return (
<div
className={cn("flex items-center gap-2 text-xs text-zinc-400", className)}
{...props}
>
{children ?? <Shimmer className="w-16">Loading...</Shimmer>}
</div>
);
};
export type TerminalActionsProps = HTMLAttributes<HTMLDivElement>;
export const TerminalActions = ({
className,
children,
...props
}: TerminalActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type TerminalCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const TerminalCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: TerminalCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { output } = useContext(TerminalContext);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
await navigator.clipboard.writeText(output);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
}
}, [output, onCopy, onError, timeout]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn(
"size-7 shrink-0 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100",
className
)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};
export type TerminalClearButtonProps = ComponentProps<typeof Button>;
export const TerminalClearButton = ({
children,
className,
...props
}: TerminalClearButtonProps) => {
const { onClear } = useContext(TerminalContext);
if (!onClear) {
return null;
}
return (
<Button
className={cn(
"size-7 shrink-0 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100",
className
)}
onClick={onClear}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Trash2Icon size={14} />}
</Button>
);
};
export type TerminalContentProps = HTMLAttributes<HTMLDivElement>;
export const TerminalContent = ({
className,
children,
...props
}: TerminalContentProps) => {
const { output, isStreaming, autoScroll } = useContext(TerminalContext);
const containerRef = useRef<HTMLDivElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: output triggers auto-scroll when new content arrives
useEffect(() => {
if (autoScroll && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [output, autoScroll]);
return (
<div
className={cn(
"max-h-96 overflow-auto p-4 font-mono text-sm leading-relaxed",
className
)}
ref={containerRef}
{...props}
>
{children ?? (
<pre className="whitespace-pre-wrap break-words">
<Ansi>{output}</Ansi>
{isStreaming && (
<span className="ml-0.5 inline-block h-4 w-2 animate-pulse bg-zinc-100" />
)}
</pre>
)}
</div>
);
};
+174
View File
@@ -0,0 +1,174 @@
"use client";
import type { DynamicToolUIPart, ToolUIPart } from "ai";
import type { ComponentProps, ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import {
CheckCircleIcon,
ChevronDownIcon,
CircleIcon,
ClockIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import { isValidElement } from "react";
import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("group not-prose mb-4 w-full rounded-md border", className)}
{...props}
/>
);
export type ToolPart = ToolUIPart | DynamicToolUIPart;
export type ToolHeaderProps = {
title?: string;
className?: string;
} & (
| { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
| {
type: DynamicToolUIPart["type"];
state: DynamicToolUIPart["state"];
toolName: string;
}
);
const statusLabels: Record<ToolPart["state"], string> = {
"approval-requested": "Awaiting Approval",
"approval-responded": "Responded",
"input-available": "Running",
"input-streaming": "Pending",
"output-available": "Completed",
"output-denied": "Denied",
"output-error": "Error",
};
const statusIcons: Record<ToolPart["state"], ReactNode> = {
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"input-streaming": <CircleIcon className="size-4" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
};
export const getStatusBadge = (status: ToolPart["state"]) => (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{statusIcons[status]}
{statusLabels[status]}
</Badge>
);
export const ToolHeader = ({
className,
title,
type,
state,
toolName,
...props
}: ToolHeaderProps) => {
const derivedName =
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between gap-4 p-3",
className
)}
{...props}
>
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{title ?? derivedName}</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
);
};
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<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 space-y-4 p-4 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
</div>
);
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolPart["output"];
errorText: ToolPart["errorText"];
};
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
let Output = <div>{output as ReactNode}</div>;
if (typeof output === "object" && !isValidElement(output)) {
Output = (
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
);
} else if (typeof output === "string") {
Output = <CodeBlock code={output} language="json" />;
}
return (
<div className={cn("space-y-2", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
</h4>
<div
className={cn(
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground"
)}
>
{errorText && <div>{errorText}</div>}
{Output}
</div>
</div>
);
};
+426
View File
@@ -0,0 +1,426 @@
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import type { Message, SSEEvent, TokenUsage, PermissionRequestEvent } from '@/types';
import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput';
import { usePanel } from '@/hooks/usePanel';
interface ToolUseInfo {
id: string;
name: string;
input: unknown;
}
interface ToolResultInfo {
tool_use_id: string;
content: string;
}
interface ChatViewProps {
sessionId: string;
initialMessages?: Message[];
modelName?: string;
initialMode?: string;
}
export function ChatView({ sessionId, initialMessages = [], modelName, initialMode }: ChatViewProps) {
const { setStreamingSessionId, workingDirectory, setWorkingDirectory, setPanelOpen, setPendingApprovalSessionId } = usePanel();
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [streamingContent, setStreamingContent] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [toolUses, setToolUses] = useState<ToolUseInfo[]>([]);
const [toolResults, setToolResults] = useState<ToolResultInfo[]>([]);
const [statusText, setStatusText] = useState<string | undefined>();
const [mode, setMode] = useState(initialMode || 'code');
const [currentModel, setCurrentModel] = useState(modelName || 'sonnet');
const [pendingPermission, setPendingPermission] = useState<PermissionRequestEvent | null>(null);
const [permissionResolved, setPermissionResolved] = useState<'allow' | 'deny' | null>(null);
const [streamingToolOutput, setStreamingToolOutput] = useState('');
const handleModeChange = useCallback((newMode: string) => {
setMode(newMode);
// Persist mode to database and notify chat list
if (sessionId) {
fetch(`/api/chat/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: newMode }),
}).then(() => {
window.dispatchEvent(new CustomEvent('session-updated'));
}).catch(() => { /* silent */ });
}
}, [sessionId]);
const abortControllerRef = useRef<AbortController | null>(null);
const handleWorkingDirectoryChange = useCallback((dir: string) => {
setWorkingDirectory(dir);
setPanelOpen(true);
// Persist to database
if (sessionId) {
fetch(`/api/chat/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ working_directory: dir }),
}).catch(() => { /* silent */ });
}
}, [sessionId, setWorkingDirectory, setPanelOpen]);
const initializedRef = useRef(false);
useEffect(() => {
if (initialMessages.length > 0 && !initializedRef.current) {
initializedRef.current = true;
setMessages(initialMessages);
}
}, [initialMessages]);
// Sync mode when session data loads
useEffect(() => {
if (initialMode) {
setMode(initialMode);
}
}, [initialMode]);
const stopStreaming = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
}, []);
const handlePermissionResponse = useCallback(async (decision: 'allow' | 'allow_session' | 'deny') => {
if (!pendingPermission) return;
const body: { permissionRequestId: string; decision: { behavior: 'allow'; updatedPermissions?: unknown[] } | { behavior: 'deny'; message?: string } } = {
permissionRequestId: pendingPermission.permissionRequestId,
decision: decision === 'deny'
? { behavior: 'deny', message: 'User denied permission' }
: {
behavior: 'allow',
...(decision === 'allow_session' && pendingPermission.suggestions
? { updatedPermissions: pendingPermission.suggestions }
: {}),
},
};
setPermissionResolved(decision === 'deny' ? 'deny' : 'allow');
setPendingApprovalSessionId('');
try {
await fetch('/api/chat/permission', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} catch {
// Best effort - the stream will handle timeout
}
// Clear permission state after a short delay so user sees the feedback
setTimeout(() => {
setPendingPermission(null);
setPermissionResolved(null);
}, 1000);
}, [pendingPermission, setPendingApprovalSessionId]);
const sendMessage = useCallback(
async (content: string) => {
if (isStreaming) return;
// Optimistic: add user message to UI immediately
const userMessage: Message = {
id: 'temp-' + Date.now(),
session_id: sessionId,
role: 'user',
content,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, userMessage]);
setIsStreaming(true);
setStreamingSessionId(sessionId);
setStreamingContent('');
setToolUses([]);
setToolResults([]);
setStatusText(undefined);
const controller = new AbortController();
abortControllerRef.current = controller;
let accumulated = '';
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, content, mode, model: currentModel }),
signal: controller.signal,
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error || 'Failed to send message');
}
const reader = response.body?.getReader();
if (!reader) throw new Error('No response stream');
const decoder = new TextDecoder();
let tokenUsage: TokenUsage | null = null;
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
// Keep the last potentially incomplete line in the buffer
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const event: SSEEvent = JSON.parse(line.slice(6));
switch (event.type) {
case 'text': {
accumulated += event.data;
setStreamingContent(accumulated);
break;
}
case 'tool_use': {
try {
const toolData = JSON.parse(event.data);
// Clear streaming output for new tool
setStreamingToolOutput('');
setToolUses((prev) => {
// Avoid duplicates
if (prev.some((t) => t.id === toolData.id)) return prev;
return [...prev, {
id: toolData.id,
name: toolData.name,
input: toolData.input,
}];
});
} catch {
// skip malformed tool_use data
}
break;
}
case 'tool_result': {
try {
const resultData = JSON.parse(event.data);
setStreamingToolOutput('');
setToolResults((prev) => [...prev, {
tool_use_id: resultData.tool_use_id,
content: resultData.content,
}]);
} catch {
// skip malformed tool_result data
}
break;
}
case 'tool_output': {
// Check if this is a progress heartbeat or real stderr output
try {
const parsed = JSON.parse(event.data);
if (parsed._progress) {
// SDK tool_progress event — update status with elapsed time
setStatusText(`Running ${parsed.tool_name}... (${Math.round(parsed.elapsed_time_seconds)}s)`);
break;
}
} catch {
// Not JSON — it's raw stderr output, fall through
}
// Real-time stderr output from tool execution
setStreamingToolOutput((prev) => {
const next = prev + (prev ? '\n' : '') + event.data;
return next.length > 5000 ? next.slice(-5000) : next;
});
break;
}
case 'status': {
try {
const statusData = JSON.parse(event.data);
if (statusData.session_id) {
// Init event — show briefly then clear so tool status can take over
setStatusText(`Connected (${statusData.model || 'claude'})`);
setTimeout(() => setStatusText(undefined), 2000);
} else if (statusData.notification) {
// Notification from SDK hooks — show as progress
setStatusText(statusData.message || statusData.title || undefined);
} else {
setStatusText(typeof event.data === 'string' ? event.data : undefined);
}
} catch {
setStatusText(event.data || undefined);
}
break;
}
case 'result': {
try {
const resultData = JSON.parse(event.data);
if (resultData.usage) {
tokenUsage = resultData.usage;
}
} catch {
// skip
}
setStatusText(undefined);
break;
}
case 'permission_request': {
try {
const permData: PermissionRequestEvent = JSON.parse(event.data);
setPendingPermission(permData);
setPermissionResolved(null);
setPendingApprovalSessionId(sessionId);
} catch {
// skip malformed permission_request data
}
break;
}
case 'error': {
accumulated += '\n\n**Error:** ' + event.data;
setStreamingContent(accumulated);
break;
}
case 'done': {
// Stream complete
break;
}
}
} catch {
// skip malformed SSE lines
}
}
}
// Add the assistant message to the list
if (accumulated.trim()) {
const assistantMessage: Message = {
id: 'temp-assistant-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: accumulated.trim(),
created_at: new Date().toISOString(),
token_usage: tokenUsage ? JSON.stringify(tokenUsage) : null,
};
setMessages((prev) => [...prev, assistantMessage]);
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
// User stopped generation - still add partial content
if (accumulated.trim()) {
const partialMessage: Message = {
id: 'temp-assistant-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: accumulated.trim() + '\n\n*(generation stopped)*',
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, partialMessage]);
}
} else {
const errMsg = error instanceof Error ? error.message : 'Unknown error';
const errorMessage: Message = {
id: 'temp-error-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: `**Error:** ${errMsg}`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, errorMessage]);
}
} finally {
setIsStreaming(false);
setStreamingSessionId('');
setStreamingContent('');
setToolUses([]);
setToolResults([]);
setStreamingToolOutput('');
setStatusText(undefined);
setPendingPermission(null);
setPermissionResolved(null);
setPendingApprovalSessionId('');
abortControllerRef.current = null;
}
},
[sessionId, isStreaming, setStreamingSessionId, setPendingApprovalSessionId, mode, currentModel]
);
const handleCommand = useCallback((command: string) => {
switch (command) {
case '/help': {
const helpMessage: Message = {
id: 'cmd-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: `## Available Commands\n\n- **/help** - Show this help message\n- **/clear** - Clear conversation history\n- **/compact** - Compress conversation context\n- **/cost** - Show token usage statistics\n- **/doctor** - Check system health\n- **/init** - Initialize CLAUDE.md\n- **/review** - Start code review\n- **/terminal-setup** - Configure terminal\n\n**Tips:**\n- Type \`@\` to mention files\n- Use Shift+Enter for new line\n- Select a project folder to enable file operations`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages(prev => [...prev, helpMessage]);
break;
}
case '/clear':
setMessages([]);
break;
case '/cost': {
const costMessage: Message = {
id: 'cmd-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: `## Token Usage\n\nToken usage tracking is available after sending messages. Check the token count displayed at the bottom of each assistant response.`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages(prev => [...prev, costMessage]);
break;
}
default:
sendMessage(command);
}
}, [sessionId, sendMessage]);
return (
<div className="flex h-full min-h-0 flex-col">
<MessageList
messages={messages}
streamingContent={streamingContent}
isStreaming={isStreaming}
toolUses={toolUses}
toolResults={toolResults}
streamingToolOutput={streamingToolOutput}
statusText={statusText}
pendingPermission={pendingPermission}
onPermissionResponse={handlePermissionResponse}
permissionResolved={permissionResolved}
/>
<MessageInput
onSend={sendMessage}
onCommand={handleCommand}
onStop={stopStreaming}
disabled={false}
isStreaming={isStreaming}
sessionId={sessionId}
modelName={currentModel}
onModelChange={setCurrentModel}
workingDirectory={workingDirectory}
onWorkingDirectoryChange={handleWorkingDirectoryChange}
mode={mode}
onModeChange={handleModeChange}
/>
</div>
);
}
+263
View File
@@ -0,0 +1,263 @@
'use client';
import { useState, useMemo, useRef } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark, vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import {
Copy01Icon,
Tick01Icon,
ArrowDown01Icon,
ArrowUp01Icon,
SourceCodeIcon,
CommandLineIcon,
CodeIcon,
File01Icon,
HashtagIcon,
} from "@hugeicons/core-free-icons";
import { cn } from '@/lib/utils';
const COLLAPSE_THRESHOLD = 20;
const VISIBLE_LINES = 10;
const TERMINAL_LANGUAGES = new Set(['bash', 'sh', 'shell', 'terminal', 'zsh', 'console']);
function getLanguageIcon(language: string): IconSvgElement {
const lower = language.toLowerCase();
if (TERMINAL_LANGUAGES.has(lower)) return CommandLineIcon;
if (['typescript', 'tsx', 'javascript', 'jsx'].includes(lower)) return CodeIcon;
if (['json', 'yaml', 'yml', 'toml', 'xml'].includes(lower)) return CodeIcon;
if (['python', 'ruby', 'go', 'rust', 'java', 'c', 'cpp'].includes(lower)) return HashtagIcon;
if (['css', 'scss', 'html'].includes(lower)) return File01Icon;
return SourceCodeIcon;
}
interface CodeBlockProps {
code: string;
language?: string;
filename?: string;
showLineNumbers?: boolean;
maxCollapsedLines?: number;
}
export function CodeBlock({
code,
language = 'text',
filename,
showLineNumbers = true,
maxCollapsedLines = VISIBLE_LINES,
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const [copiedMarkdown, setCopiedMarkdown] = useState(false);
const [expanded, setExpanded] = useState(false);
const codeContainerRef = useRef<HTMLDivElement>(null);
const [animatingHeight, setAnimatingHeight] = useState<string | undefined>(undefined);
const lines = useMemo(() => code.split('\n'), [code]);
const totalLines = lines.length;
const isCollapsible = totalLines > COLLAPSE_THRESHOLD;
const isTerminal = TERMINAL_LANGUAGES.has(language.toLowerCase());
const displayCode = useMemo(() => {
if (!isCollapsible || expanded) return code;
return lines.slice(0, maxCollapsedLines).join('\n');
}, [code, lines, isCollapsible, expanded, maxCollapsedLines]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard not available
}
};
const handleCopyMarkdown = async () => {
try {
const markdown = `\`\`\`${language}\n${code}\n\`\`\``;
await navigator.clipboard.writeText(markdown);
setCopiedMarkdown(true);
setTimeout(() => setCopiedMarkdown(false), 2000);
} catch {
// clipboard not available
}
};
const handleToggleExpand = () => {
const container = codeContainerRef.current;
if (!container) {
setExpanded(!expanded);
return;
}
const currentHeight = container.scrollHeight;
if (!expanded) {
// Expanding: set current height, then switch to auto after transition
setAnimatingHeight(`${currentHeight}px`);
setExpanded(true);
requestAnimationFrame(() => {
// Measure the full height after content change
const fullHeight = container.scrollHeight;
setAnimatingHeight(`${fullHeight}px`);
setTimeout(() => setAnimatingHeight(undefined), 300);
});
} else {
// Collapsing: set current height, then reduce
setAnimatingHeight(`${currentHeight}px`);
requestAnimationFrame(() => {
const collapsedH = maxCollapsedLines * 1.5 + 1.5;
setAnimatingHeight(`${collapsedH}rem`);
setTimeout(() => {
setExpanded(false);
setAnimatingHeight(undefined);
}, 300);
});
}
};
const languageIconData = getLanguageIcon(language);
const theme = isTerminal ? vscDarkPlus : oneDark;
return (
<div className="relative group not-prose my-3 rounded-lg overflow-hidden border border-zinc-700/50">
{/* Header bar */}
<div className={cn(
"flex items-center justify-between px-4 py-1.5 text-xs",
isTerminal
? "bg-zinc-950 text-zinc-400"
: "bg-zinc-800 dark:bg-zinc-900 text-zinc-400"
)}>
<div className="flex items-center gap-2 min-w-0">
<HugeiconsIcon icon={languageIconData} className={cn(
"h-3.5 w-3.5 shrink-0",
isTerminal ? "text-green-400" : "text-zinc-400",
)} />
{filename && (
<span className="truncate text-zinc-300 font-medium">{filename}</span>
)}
{filename && <span className="text-zinc-600">|</span>}
<span className={cn(
"bg-zinc-700/50 rounded px-1.5 py-0.5",
isTerminal && "text-green-400",
)}>{language.toUpperCase()}</span>
</div>
<div className="flex items-center gap-1 ml-2 shrink-0">
<button
onClick={handleCopy}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50 transition-colors"
title="Copy code"
>
{copied ? (
<>
<HugeiconsIcon icon={Tick01Icon} className="h-3 w-3" />
<span>Copied</span>
</>
) : (
<>
<HugeiconsIcon icon={Copy01Icon} className="h-3 w-3" />
<span>Copy</span>
</>
)}
</button>
<button
onClick={handleCopyMarkdown}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50 transition-colors"
title="Copy as Markdown"
>
{copiedMarkdown ? (
<>
<HugeiconsIcon icon={Tick01Icon} className="h-3 w-3" />
<span>Copied</span>
</>
) : (
<>
<HugeiconsIcon icon={SourceCodeIcon} className="h-3 w-3" />
<span>Markdown</span>
</>
)}
</button>
</div>
</div>
{/* Code area */}
<div
ref={codeContainerRef}
className="relative transition-[max-height] duration-300 ease-in-out overflow-hidden"
style={{
maxHeight: animatingHeight ?? (!isCollapsible || expanded ? undefined : `${maxCollapsedLines * 1.5 + 1.5}rem`),
}}
>
<SyntaxHighlighter
style={theme}
language={language}
PreTag="div"
showLineNumbers={showLineNumbers && !isTerminal}
lineNumberStyle={{
minWidth: '2.5em',
paddingRight: '1em',
color: '#3a3a48',
userSelect: 'none',
}}
customStyle={{
margin: 0,
borderRadius: 0,
fontSize: '0.8125rem',
lineHeight: '1.5',
padding: isTerminal ? '0.75rem 1rem' : '0.75rem 0.5rem',
background: isTerminal ? '#0a0a0a' : undefined,
overflow: 'auto',
}}
wrapLines
>
{expanded ? code : displayCode}
</SyntaxHighlighter>
{/* Gradient overlay for collapsed state */}
{isCollapsible && !expanded && (
<div className={cn(
"absolute bottom-0 left-0 right-0 h-16 pointer-events-none",
isTerminal
? "bg-gradient-to-t from-[#0a0a0a] to-transparent"
: "bg-gradient-to-t from-[#282c34] to-transparent"
)} />
)}
</div>
{/* Expand/Collapse button */}
{isCollapsible && (
<button
onClick={handleToggleExpand}
className={cn(
"flex w-full items-center justify-center gap-1.5 py-1.5 text-xs transition-colors",
isTerminal
? "bg-zinc-950 text-zinc-400 hover:text-zinc-200"
: "bg-zinc-800 dark:bg-zinc-900 text-zinc-400 hover:text-zinc-200"
)}
>
{expanded ? (
<>
<HugeiconsIcon icon={ArrowUp01Icon} className="h-3 w-3" />
<span>Collapse</span>
</>
) : (
<>
<HugeiconsIcon icon={ArrowDown01Icon} className="h-3 w-3" />
<span>Expand all {totalLines} lines</span>
</>
)}
</button>
)}
</div>
);
}
// Inline code component for reuse
export function InlineCode({ children }: { children: React.ReactNode }) {
return (
<code className="rounded bg-muted px-1.5 py-0.5 text-sm font-mono">
{children}
</code>
);
}
+167
View File
@@ -0,0 +1,167 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { HugeiconsIcon } from "@hugeicons/react";
import { Folder01Icon, FolderOpenIcon, ArrowRight01Icon, ArrowUp01Icon } from "@hugeicons/core-free-icons";
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
interface FolderEntry {
name: string;
path: string;
}
interface BrowseResponse {
current: string;
parent: string | null;
directories: FolderEntry[];
}
interface FolderPickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (path: string) => void;
initialPath?: string;
}
export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: FolderPickerProps) {
const [currentDir, setCurrentDir] = useState('');
const [parentDir, setParentDir] = useState<string | null>(null);
const [directories, setDirectories] = useState<FolderEntry[]>([]);
const [loading, setLoading] = useState(false);
const [pathInput, setPathInput] = useState('');
const browse = useCallback(async (dir?: string) => {
setLoading(true);
try {
const url = dir
? `/api/files/browse?dir=${encodeURIComponent(dir)}`
: '/api/files/browse';
const res = await fetch(url);
if (res.ok) {
const data: BrowseResponse = await res.json();
setCurrentDir(data.current);
setParentDir(data.parent);
setDirectories(data.directories);
setPathInput(data.current);
}
} catch {
// silently fail
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
browse(initialPath || undefined);
}
}, [open, initialPath, browse]);
const handleNavigate = (dir: string) => {
browse(dir);
};
const handleGoUp = () => {
if (parentDir) browse(parentDir);
};
const handlePathSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (pathInput.trim()) {
browse(pathInput.trim());
}
};
const handleSelect = () => {
onSelect(currentDir);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Select Project Folder</DialogTitle>
</DialogHeader>
{/* Path input */}
<form onSubmit={handlePathSubmit} className="flex gap-2">
<Input
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
placeholder="/path/to/project"
className="flex-1 font-mono text-sm"
/>
<Button type="submit" variant="outline" size="sm">
Go
</Button>
</form>
{/* Directory browser */}
<div className="rounded-md border border-border">
{/* Current path + go up */}
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-3 py-2">
<Button
variant="ghost"
size="icon-sm"
onClick={handleGoUp}
disabled={!parentDir}
className="shrink-0"
>
<HugeiconsIcon icon={ArrowUp01Icon} className="h-4 w-4" />
</Button>
<span className="truncate text-xs font-mono text-muted-foreground">
{currentDir}
</span>
</div>
{/* Folder list */}
<ScrollArea className="h-64">
{loading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading...
</div>
) : directories.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
No subdirectories
</div>
) : (
<div className="p-1">
{directories.map((dir) => (
<button
key={dir.path}
className="flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
onClick={() => handleNavigate(dir.path)}
>
<HugeiconsIcon icon={Folder01Icon} className="h-4 w-4 shrink-0 text-blue-500" />
<span className="truncate">{dir.name}</span>
<HugeiconsIcon icon={ArrowRight01Icon} className="ml-auto h-3 w-3 shrink-0 text-muted-foreground" />
</button>
))}
</div>
)}
</ScrollArea>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSelect} className="gap-2">
<HugeiconsIcon icon={FolderOpenIcon} className="h-4 w-4" />
Select This Folder
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+513
View File
@@ -0,0 +1,513 @@
'use client';
import { useRef, useState, useCallback, useEffect, type KeyboardEvent, type FormEvent } from 'react';
import { HugeiconsIcon } from "@hugeicons/react";
import {
AtIcon,
DivideSignIcon,
FolderOpenIcon,
Wrench01Icon,
ClipboardIcon,
HelpCircleIcon,
ArrowDown01Icon,
CommandLineIcon,
} from "@hugeicons/core-free-icons";
import { cn } from '@/lib/utils';
import { FolderPicker } from './FolderPicker';
import {
PromptInput,
PromptInputTextarea,
PromptInputFooter,
PromptInputTools,
PromptInputButton,
PromptInputSubmit,
} from '@/components/ai-elements/prompt-input';
import type { ChatStatus } from 'ai';
interface MessageInputProps {
onSend: (content: string) => void;
onCommand?: (command: string) => void;
onStop?: () => void;
disabled?: boolean;
isStreaming?: boolean;
sessionId?: string;
modelName?: string;
onModelChange?: (model: string) => void;
workingDirectory?: string;
onWorkingDirectoryChange?: (dir: string) => void;
mode?: string;
onModeChange?: (mode: string) => void;
}
interface PopoverItem {
label: string;
value: string;
description?: string;
builtIn?: boolean;
}
type PopoverMode = 'file' | 'skill' | null;
const BUILT_IN_COMMANDS: PopoverItem[] = [
{ label: 'help', value: '/help', description: 'Show help information', builtIn: true },
{ label: 'clear', value: '/clear', description: 'Clear conversation', builtIn: true },
{ label: 'compact', value: '/compact', description: 'Compress conversation context', builtIn: true },
{ label: 'cost', value: '/cost', description: 'Show token usage', builtIn: true },
{ label: 'doctor', value: '/doctor', description: 'Check system health', builtIn: true },
{ label: 'init', value: '/init', description: 'Initialize CLAUDE.md', builtIn: true },
{ label: 'review', value: '/review', description: 'Code review', builtIn: true },
{ label: 'terminal-setup', value: '/terminal-setup', description: 'Terminal configuration', builtIn: true },
];
interface ModeOption {
value: string;
label: string;
icon: typeof Wrench01Icon;
description: string;
}
const MODE_OPTIONS: ModeOption[] = [
{ value: 'code', label: 'Code', icon: Wrench01Icon, description: 'Read, write files & run commands' },
{ value: 'plan', label: 'Plan', icon: ClipboardIcon, description: 'Analyze & plan without executing' },
{ value: 'ask', label: 'Ask', icon: HelpCircleIcon, description: 'Answer questions only' },
];
const MODEL_OPTIONS = [
{ value: 'sonnet', label: 'Sonnet 4.5' },
{ value: 'opus', label: 'Opus 4.6' },
{ value: 'haiku', label: 'Haiku 4.5' },
];
export function MessageInput({
onSend,
onCommand,
onStop,
disabled,
isStreaming,
sessionId,
modelName,
onModelChange,
workingDirectory,
onWorkingDirectoryChange,
mode = 'code',
onModeChange,
}: MessageInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const modeMenuRef = useRef<HTMLDivElement>(null);
const modelMenuRef = useRef<HTMLDivElement>(null);
const [popoverMode, setPopoverMode] = useState<PopoverMode>(null);
const [popoverItems, setPopoverItems] = useState<PopoverItem[]>([]);
const [popoverFilter, setPopoverFilter] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const [triggerPos, setTriggerPos] = useState<number | null>(null);
const [folderPickerOpen, setFolderPickerOpen] = useState(false);
const [modeMenuOpen, setModeMenuOpen] = useState(false);
const [modelMenuOpen, setModelMenuOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
// Fetch files for @ mention
const fetchFiles = useCallback(async (filter: string) => {
try {
const params = new URLSearchParams();
if (sessionId) params.set('session_id', sessionId);
if (filter) params.set('q', filter);
const res = await fetch(`/api/files?${params.toString()}`);
if (!res.ok) return [];
const data = await res.json();
const tree = data.tree || [];
const items: PopoverItem[] = [];
function flattenTree(nodes: Array<{ name: string; path: string; type: string; children?: unknown[] }>) {
for (const node of nodes) {
items.push({ label: node.name, value: node.path });
if (node.children) flattenTree(node.children as typeof nodes);
}
}
flattenTree(tree);
return items.slice(0, 20);
} catch {
return [];
}
}, [sessionId]);
// Fetch skills for / command (built-in + API)
const fetchSkills = useCallback(async (filter: string) => {
const builtIn = BUILT_IN_COMMANDS.filter((cmd) =>
cmd.label.toLowerCase().includes(filter.toLowerCase())
);
let apiSkills: PopoverItem[] = [];
try {
const res = await fetch('/api/skills');
if (res.ok) {
const data = await res.json();
const skills = data.skills || [];
apiSkills = skills
.filter((s: { name: string; enabled: boolean }) => s.enabled && s.name.toLowerCase().includes(filter.toLowerCase()))
.map((s: { name: string; description: string }) => ({
label: s.name,
value: `/${s.name}`,
description: s.description,
builtIn: false,
}));
}
} catch {
// API not available - just use built-in commands
}
return [...builtIn, ...apiSkills].slice(0, 20);
}, []);
// Close popover
const closePopover = useCallback(() => {
setPopoverMode(null);
setPopoverItems([]);
setPopoverFilter('');
setSelectedIndex(0);
setTriggerPos(null);
}, []);
// Insert selected item
const insertItem = useCallback((item: PopoverItem) => {
if (triggerPos === null) return;
// Built-in commands: execute immediately
if (item.builtIn && onCommand) {
setInputValue('');
closePopover();
onCommand(item.value);
return;
}
const currentVal = inputValue;
const before = currentVal.slice(0, triggerPos);
const cursorEnd = triggerPos + (popoverMode === 'file' ? popoverFilter.length + 1 : popoverFilter.length + 1);
const after = currentVal.slice(cursorEnd);
const insertText = popoverMode === 'file' ? `@${item.value} ` : `${item.value} `;
setInputValue(before + insertText + after);
closePopover();
// Refocus textarea
setTimeout(() => textareaRef.current?.focus(), 0);
}, [triggerPos, popoverMode, closePopover, onCommand, inputValue, popoverFilter]);
// Handle input changes to detect @ and /
const handleInputChange = useCallback(async (val: string) => {
setInputValue(val);
const textarea = textareaRef.current;
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const beforeCursor = val.slice(0, cursorPos);
// Check for @ trigger
const atMatch = beforeCursor.match(/@([^\s@]*)$/);
if (atMatch) {
const filter = atMatch[1];
setPopoverMode('file');
setPopoverFilter(filter);
setTriggerPos(cursorPos - atMatch[0].length);
setSelectedIndex(0);
const items = await fetchFiles(filter);
setPopoverItems(items);
return;
}
// Check for / trigger (only at start of line or after space)
const slashMatch = beforeCursor.match(/(^|\s)\/([^\s]*)$/);
if (slashMatch) {
const filter = slashMatch[2];
setPopoverMode('skill');
setPopoverFilter(filter);
setTriggerPos(cursorPos - slashMatch[2].length - 1);
setSelectedIndex(0);
const items = await fetchSkills(filter);
setPopoverItems(items);
return;
}
if (popoverMode) {
closePopover();
}
}, [fetchFiles, fetchSkills, popoverMode, closePopover]);
const handleSubmit = useCallback((_msg: { text: string; files: unknown[] }, e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const content = inputValue.trim();
if (!content || disabled) return;
closePopover();
// Check if it's a built-in command
const builtInCmd = BUILT_IN_COMMANDS.find(cmd => cmd.value === content);
if (builtInCmd && onCommand) {
onCommand(content);
} else {
onSend(content);
}
setInputValue('');
}, [inputValue, onSend, onCommand, disabled, closePopover]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
// Popover navigation
if (popoverMode && popoverItems.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % filteredItems.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + filteredItems.length) % filteredItems.length);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
if (filteredItems[selectedIndex]) {
insertItem(filteredItems[selectedIndex]);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
closePopover();
return;
}
}
},
[popoverMode, popoverItems, selectedIndex, insertItem, closePopover]
);
// Click outside to close popover
useEffect(() => {
if (!popoverMode) return;
const handler = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
closePopover();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [popoverMode, closePopover]);
// Click outside to close mode menu
useEffect(() => {
if (!modeMenuOpen) return;
const handler = (e: MouseEvent) => {
if (modeMenuRef.current && !modeMenuRef.current.contains(e.target as Node)) {
setModeMenuOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [modeMenuOpen]);
// Click outside to close model menu
useEffect(() => {
if (!modelMenuOpen) return;
const handler = (e: MouseEvent) => {
if (modelMenuRef.current && !modelMenuRef.current.contains(e.target as Node)) {
setModelMenuOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [modelMenuOpen]);
const filteredItems = popoverItems.filter((item) =>
item.label.toLowerCase().includes(popoverFilter.toLowerCase())
);
const currentModelValue = modelName || 'sonnet';
const currentModelOption = MODEL_OPTIONS.find((m) => m.value === currentModelValue) || MODEL_OPTIONS[0];
const currentMode = MODE_OPTIONS.find((m) => m.value === mode) || MODE_OPTIONS[0];
const folderShortName = workingDirectory
? workingDirectory.split('/').filter(Boolean).pop() || workingDirectory
: '';
// Map isStreaming to ChatStatus for PromptInputSubmit
const chatStatus: ChatStatus = isStreaming ? 'streaming' : 'ready';
return (
<div className="bg-background/80 backdrop-blur-lg px-4 py-3">
<div className="mx-auto">
<div className="relative">
{/* Popover */}
{popoverMode && filteredItems.length > 0 && (
<div
ref={popoverRef}
className="absolute bottom-full left-0 right-0 mb-2 rounded-xl border bg-popover shadow-lg overflow-hidden z-50"
>
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b">
{popoverMode === 'file' ? 'Files' : 'Commands'}
</div>
<div className="max-h-48 overflow-y-auto py-1">
{filteredItems.map((item, i) => (
<button
key={item.value}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors",
i === selectedIndex ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
)}
onClick={() => insertItem(item)}
onMouseEnter={() => setSelectedIndex(i)}
>
{popoverMode === 'file' ? (
<HugeiconsIcon icon={AtIcon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : item.builtIn ? (
<HugeiconsIcon icon={CommandLineIcon} className="h-3.5 w-3.5 shrink-0 text-blue-400" />
) : (
<HugeiconsIcon icon={DivideSignIcon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="font-mono text-xs truncate">{item.label}</span>
{item.builtIn && (
<span className="text-[9px] px-1 py-0.5 rounded bg-blue-500/10 text-blue-400 font-medium shrink-0">
Built-in
</span>
)}
{item.description && (
<span className="ml-auto text-xs text-muted-foreground truncate max-w-[200px]">
{item.description}
</span>
)}
</button>
))}
</div>
</div>
)}
{/* PromptInput replaces the old input area */}
<PromptInput
onSubmit={handleSubmit}
>
<PromptInputTextarea
ref={textareaRef}
placeholder="Message Claude..."
value={inputValue}
onChange={(e) => handleInputChange(e.currentTarget.value)}
onKeyDown={handleKeyDown}
disabled={disabled || isStreaming}
className="min-h-10"
/>
<PromptInputFooter>
<PromptInputTools>
{/* Folder picker button */}
<PromptInputButton
onClick={() => setFolderPickerOpen(true)}
tooltip={workingDirectory || 'Select project folder'}
>
<HugeiconsIcon icon={FolderOpenIcon} className="h-3.5 w-3.5" />
<span className="max-w-[120px] truncate text-xs">
{folderShortName || 'Folder'}
</span>
</PromptInputButton>
{/* Mode selector */}
<div className="relative" ref={modeMenuRef}>
<PromptInputButton
onClick={() => setModeMenuOpen((prev) => !prev)}
>
<HugeiconsIcon icon={currentMode.icon} className="h-3.5 w-3.5" />
<span className="text-xs">{currentMode.label}</span>
<HugeiconsIcon icon={ArrowDown01Icon} className="h-2.5 w-2.5" />
</PromptInputButton>
{/* Mode dropdown */}
{modeMenuOpen && (
<div className="absolute bottom-full left-0 mb-1.5 w-56 rounded-lg border bg-popover shadow-lg overflow-hidden z-50">
<div className="py-1">
{MODE_OPTIONS.map((opt) => {
const isActive = opt.value === mode;
return (
<button
key={opt.value}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
)}
onClick={() => {
onModeChange?.(opt.value);
setModeMenuOpen(false);
}}
>
<HugeiconsIcon icon={opt.icon} className="h-4 w-4 shrink-0" />
<div className="flex flex-col min-w-0">
<span className="font-medium text-xs">{opt.label}</span>
<span className="text-[10px] text-muted-foreground truncate">
{opt.description}
</span>
</div>
</button>
);
})}
</div>
</div>
)}
</div>
</PromptInputTools>
<div className="flex items-center gap-1.5">
{/* Model selector */}
<div className="relative" ref={modelMenuRef}>
<PromptInputButton
onClick={() => setModelMenuOpen((prev) => !prev)}
>
<span className="text-xs font-mono">{currentModelOption.label}</span>
<HugeiconsIcon icon={ArrowDown01Icon} className="h-2.5 w-2.5" />
</PromptInputButton>
{modelMenuOpen && (
<div className="absolute bottom-full right-0 mb-1.5 w-48 rounded-lg border bg-popover shadow-lg overflow-hidden z-50">
<div className="py-1">
{MODEL_OPTIONS.map((opt) => {
const isActive = opt.value === currentModelValue;
return (
<button
key={opt.value}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
)}
onClick={() => {
onModelChange?.(opt.value);
setModelMenuOpen(false);
}}
>
<span className="font-mono text-xs">{opt.label}</span>
</button>
);
})}
</div>
</div>
)}
</div>
<PromptInputSubmit
status={chatStatus}
onStop={onStop}
disabled={disabled || (!isStreaming && !inputValue.trim())}
/>
</div>
</PromptInputFooter>
</PromptInput>
</div>
</div>
{/* FolderPicker dialog */}
<FolderPicker
open={folderPickerOpen}
onOpenChange={setFolderPickerOpen}
onSelect={(dir) => {
onWorkingDirectoryChange?.(dir);
}}
initialPath={workingDirectory || undefined}
/>
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
'use client';
import type { Message, TokenUsage } from '@/types';
import {
Message as AIMessage,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message';
import {
Tool,
ToolHeader,
ToolContent,
ToolInput,
ToolOutput,
} from '@/components/ai-elements/tool';
import type { ToolUIPart } from 'ai';
interface MessageItemProps {
message: Message;
}
interface ToolBlock {
type: 'tool_use' | 'tool_result';
id?: string;
name?: string;
input?: unknown;
content?: string;
is_error?: boolean;
}
function parseToolBlocks(content: string): { text: string; tools: ToolBlock[] } {
const tools: ToolBlock[] = [];
let text = content;
const toolUseRegex = /<!--tool_use:([\s\S]*?)-->/g;
let match;
while ((match = toolUseRegex.exec(content)) !== null) {
try {
const parsed = JSON.parse(match[1]);
tools.push({ type: 'tool_use', ...parsed });
} catch {
// skip malformed
}
text = text.replace(match[0], '');
}
const toolResultRegex = /<!--tool_result:([\s\S]*?)-->/g;
while ((match = toolResultRegex.exec(content)) !== null) {
try {
const parsed = JSON.parse(match[1]);
tools.push({ type: 'tool_result', ...parsed });
} catch {
// skip malformed
}
text = text.replace(match[0], '');
}
return { text: text.trim(), tools };
}
function pairTools(tools: ToolBlock[]): Array<{
name: string;
input: unknown;
result?: string;
isError?: boolean;
}> {
const paired: Array<{
name: string;
input: unknown;
result?: string;
isError?: boolean;
}> = [];
const resultMap = new Map<string, ToolBlock>();
for (const t of tools) {
if (t.type === 'tool_result' && t.id) {
resultMap.set(t.id, t);
}
}
for (const t of tools) {
if (t.type === 'tool_use' && t.name) {
const result = t.id ? resultMap.get(t.id) : undefined;
paired.push({
name: t.name,
input: t.input,
result: result?.content,
isError: result?.is_error,
});
}
}
for (const t of tools) {
if (t.type === 'tool_result' && !tools.some(u => u.type === 'tool_use' && u.id === t.id)) {
paired.push({
name: 'tool_result',
input: {},
result: t.content,
isError: t.is_error,
});
}
}
return paired;
}
function getToolState(result?: string, isError?: boolean): ToolUIPart['state'] {
if (result === undefined) return 'input-available';
if (isError) return 'output-error';
return 'output-available';
}
function TokenUsageDisplay({ usage }: { usage: TokenUsage }) {
const totalTokens = usage.input_tokens + usage.output_tokens;
const costStr = usage.cost_usd !== undefined && usage.cost_usd !== null
? ` · $${usage.cost_usd.toFixed(4)}`
: '';
return (
<span className="group/tokens relative cursor-default text-xs text-muted-foreground/50">
<span>{totalTokens.toLocaleString()} tokens{costStr}</span>
<span className="pointer-events-none absolute bottom-full left-0 mb-1.5 whitespace-nowrap rounded-md bg-popover px-2.5 py-1.5 text-[11px] text-popover-foreground shadow-md border border-border/50 opacity-0 group-hover/tokens:opacity-100 transition-opacity duration-150 z-50">
In: {usage.input_tokens.toLocaleString()} · Out: {usage.output_tokens.toLocaleString()}
{usage.cache_read_input_tokens ? ` · Cache: ${usage.cache_read_input_tokens.toLocaleString()}` : ''}
{costStr}
</span>
</span>
);
}
export function MessageItem({ message }: MessageItemProps) {
const isUser = message.role === 'user';
const { text, tools } = parseToolBlocks(message.content);
const pairedTools = pairTools(tools);
let tokenUsage: TokenUsage | null = null;
if (message.token_usage) {
try {
tokenUsage = JSON.parse(message.token_usage);
} catch {
// skip
}
}
const timestamp = new Date(message.created_at).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
return (
<AIMessage from={isUser ? 'user' : 'assistant'}>
<MessageContent>
{/* Tool calls for assistant messages */}
{!isUser && pairedTools.length > 0 && (
<div className="space-y-2 w-full">
{pairedTools.map((tool, i) => (
<Tool key={`tool-${i}`}>
<ToolHeader
type="tool-invocation"
title={tool.name}
state={getToolState(tool.result, tool.isError)}
/>
<ToolContent>
<ToolInput input={tool.input} />
<ToolOutput
output={tool.result}
errorText={tool.isError ? tool.result : undefined}
/>
</ToolContent>
</Tool>
))}
</div>
)}
{/* Text content */}
{text && (
isUser ? (
<div className="text-sm whitespace-pre-wrap break-words">{text}</div>
) : (
<MessageResponse>{text}</MessageResponse>
)
)}
</MessageContent>
{/* Footer with timestamp and token usage - outside MessageContent to avoid overflow-hidden clipping */}
{!isUser && (
<div className="flex items-center gap-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<span className="text-xs text-muted-foreground/50">{timestamp}</span>
{tokenUsage && <TokenUsageDisplay usage={tokenUsage} />}
</div>
)}
</AIMessage>
);
}
+92
View File
@@ -0,0 +1,92 @@
'use client';
import type { Message, PermissionRequestEvent } from '@/types';
import {
Conversation,
ConversationContent,
ConversationScrollButton,
ConversationEmptyState,
} from '@/components/ai-elements/conversation';
import { MessageItem } from './MessageItem';
import { StreamingMessage } from './StreamingMessage';
import { HugeiconsIcon } from "@hugeicons/react";
import { BotIcon } from "@hugeicons/core-free-icons";
interface ToolUseInfo {
id: string;
name: string;
input: unknown;
}
interface ToolResultInfo {
tool_use_id: string;
content: string;
is_error?: boolean;
}
interface MessageListProps {
messages: Message[];
streamingContent: string;
isStreaming: boolean;
toolUses?: ToolUseInfo[];
toolResults?: ToolResultInfo[];
streamingToolOutput?: string;
statusText?: string;
pendingPermission?: PermissionRequestEvent | null;
onPermissionResponse?: (decision: 'allow' | 'allow_session' | 'deny') => void;
permissionResolved?: 'allow' | 'deny' | null;
}
export function MessageList({
messages,
streamingContent,
isStreaming,
toolUses = [],
toolResults = [],
streamingToolOutput,
statusText,
pendingPermission,
onPermissionResponse,
permissionResolved,
}: MessageListProps) {
if (messages.length === 0 && !isStreaming) {
return (
<div className="flex flex-1 items-center justify-center">
<ConversationEmptyState
title="Claude Chat"
description="Start a conversation with Claude. Ask questions, get help with code, or explore ideas."
icon={
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br from-violet-500/20 to-purple-600/20">
<HugeiconsIcon icon={BotIcon} className="h-8 w-8 text-violet-500" />
</div>
}
/>
</div>
);
}
return (
<Conversation>
<ConversationContent className="mx-auto max-w-3xl px-4 py-6 gap-6">
{messages.map((message) => (
<MessageItem key={message.id} message={message} />
))}
{isStreaming && (
<StreamingMessage
content={streamingContent}
isStreaming={isStreaming}
toolUses={toolUses}
toolResults={toolResults}
streamingToolOutput={streamingToolOutput}
statusText={statusText}
pendingPermission={pendingPermission}
onPermissionResponse={onPermissionResponse}
permissionResolved={permissionResolved}
/>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
);
}
+258
View File
@@ -0,0 +1,258 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import {
Message as AIMessage,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message';
import {
Tool,
ToolHeader,
ToolContent,
ToolInput,
ToolOutput,
} from '@/components/ai-elements/tool';
import {
Confirmation,
ConfirmationTitle,
ConfirmationRequest,
ConfirmationAccepted,
ConfirmationRejected,
ConfirmationActions,
ConfirmationAction,
} from '@/components/ai-elements/confirmation';
import { Shimmer } from '@/components/ai-elements/shimmer';
import type { ToolUIPart } from 'ai';
import type { PermissionRequestEvent } from '@/types';
interface ToolUseInfo {
id: string;
name: string;
input: unknown;
}
interface ToolResultInfo {
tool_use_id: string;
content: string;
is_error?: boolean;
}
interface StreamingMessageProps {
content: string;
isStreaming: boolean;
toolUses?: ToolUseInfo[];
toolResults?: ToolResultInfo[];
streamingToolOutput?: string;
statusText?: string;
pendingPermission?: PermissionRequestEvent | null;
onPermissionResponse?: (decision: 'allow' | 'allow_session' | 'deny') => void;
permissionResolved?: 'allow' | 'deny' | null;
}
function getToolState(hasResult: boolean, isError?: boolean): ToolUIPart['state'] {
if (!hasResult) return 'input-available';
if (isError) return 'output-error';
return 'output-available';
}
function ElapsedTimer() {
const [elapsed, setElapsed] = useState(0);
const startRef = useRef(Date.now());
useEffect(() => {
const interval = setInterval(() => {
setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
}, 1000);
return () => clearInterval(interval);
}, []);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
return (
<span className="tabular-nums">
{mins > 0 ? `${mins}m ${secs}s` : `${secs}s`}
</span>
);
}
function StreamingStatusBar({ statusText }: { statusText?: string }) {
const displayText = statusText || 'Thinking';
return (
<div className="flex items-center gap-3 py-2 px-1 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Shimmer duration={1.5}>{displayText}</Shimmer>
</div>
<span className="text-muted-foreground/50">|</span>
<ElapsedTimer />
</div>
);
}
export function StreamingMessage({
content,
isStreaming,
toolUses = [],
toolResults = [],
streamingToolOutput,
statusText,
pendingPermission,
onPermissionResponse,
permissionResolved,
}: StreamingMessageProps) {
const runningTools = toolUses.filter(
(tool) => !toolResults.some((r) => r.tool_use_id === tool.id)
);
// Determine confirmation state for the AI Elements component
const getConfirmationState = (): ToolUIPart['state'] => {
if (permissionResolved) return 'approval-responded';
if (pendingPermission) return 'approval-requested';
return 'input-available';
};
const getApproval = () => {
if (!pendingPermission && !permissionResolved) return undefined;
if (permissionResolved === 'allow') {
return { id: pendingPermission?.permissionRequestId || '', approved: true as const };
}
if (permissionResolved === 'deny') {
return { id: pendingPermission?.permissionRequestId || '', approved: false as const };
}
// Pending - no decision yet
return { id: pendingPermission?.permissionRequestId || '' };
};
const formatToolInput = (input: Record<string, unknown>): string => {
if (input.command) return String(input.command);
if (input.file_path) return String(input.file_path);
if (input.path) return String(input.path);
return JSON.stringify(input, null, 2);
};
// Extract a human-readable summary of the running command
const getRunningCommandSummary = (): string | undefined => {
if (runningTools.length === 0) return undefined;
const tool = runningTools[runningTools.length - 1];
const input = tool.input as Record<string, unknown>;
if (tool.name === 'Bash' && input.command) {
const cmd = String(input.command);
return cmd.length > 80 ? cmd.slice(0, 80) + '...' : cmd;
}
if (input.file_path) return `${tool.name}: ${String(input.file_path)}`;
if (input.path) return `${tool.name}: ${String(input.path)}`;
return `Running ${tool.name}...`;
};
return (
<AIMessage from="assistant">
<MessageContent>
{/* Tool calls */}
{toolUses.length > 0 && (
<div className="space-y-2 w-full">
{toolUses.map((tool, index) => {
const result = toolResults.find((r) => r.tool_use_id === tool.id);
const isRunning = !result;
const isLastRunning = isRunning && index === toolUses.length - 1;
return (
<Tool key={tool.id} defaultOpen={isRunning || !!result}>
<ToolHeader
type="tool-invocation"
title={tool.name}
state={getToolState(!!result, result?.is_error)}
/>
<ToolContent>
<ToolInput input={tool.input} />
{isLastRunning && streamingToolOutput ? (
<ToolOutput output={streamingToolOutput} errorText={undefined} />
) : (
<ToolOutput
output={result?.content}
errorText={result?.is_error ? result.content : undefined}
/>
)}
</ToolContent>
</Tool>
);
})}
</div>
)}
{/* Permission approval confirmation */}
{(pendingPermission || permissionResolved) && (
<Confirmation
approval={getApproval()}
state={getConfirmationState()}
>
<ConfirmationTitle>
<span className="font-medium">{pendingPermission?.toolName}</span>
{pendingPermission?.decisionReason && (
<span className="text-muted-foreground ml-2">
— {pendingPermission.decisionReason}
</span>
)}
</ConfirmationTitle>
{pendingPermission && (
<div className="mt-1 rounded bg-muted/50 px-3 py-2 font-mono text-xs">
{formatToolInput(pendingPermission.toolInput)}
</div>
)}
<ConfirmationRequest>
<ConfirmationActions>
<ConfirmationAction
variant="outline"
onClick={() => onPermissionResponse?.('deny')}
>
Deny
</ConfirmationAction>
<ConfirmationAction
variant="outline"
onClick={() => onPermissionResponse?.('allow')}
>
Allow Once
</ConfirmationAction>
{pendingPermission?.suggestions && pendingPermission.suggestions.length > 0 && (
<ConfirmationAction
variant="default"
onClick={() => onPermissionResponse?.('allow_session')}
>
Allow for Session
</ConfirmationAction>
)}
</ConfirmationActions>
</ConfirmationRequest>
<ConfirmationAccepted>
<p className="text-xs text-green-600 dark:text-green-400">Allowed</p>
</ConfirmationAccepted>
<ConfirmationRejected>
<p className="text-xs text-red-600 dark:text-red-400">Denied</p>
</ConfirmationRejected>
</Confirmation>
)}
{/* Streaming text content rendered via Streamdown */}
{content && (
<MessageResponse>{content}</MessageResponse>
)}
{/* Loading indicator when no content yet */}
{isStreaming && !content && toolUses.length === 0 && !pendingPermission && (
<div className="py-2">
<Shimmer>Thinking...</Shimmer>
</div>
)}
{/* Status bar during streaming */}
{isStreaming && !pendingPermission && <StreamingStatusBar statusText={
statusText || getRunningCommandSummary()
} />}
</MessageContent>
</AIMessage>
);
}
+358
View File
@@ -0,0 +1,358 @@
'use client';
import { useState } from 'react';
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import {
File01Icon,
FileEditIcon,
CommandLineIcon,
Search01Icon,
Wrench01Icon,
ArrowDown01Icon,
ArrowRight01Icon,
Loading02Icon,
CheckmarkCircle02Icon,
CancelCircleIcon,
} from "@hugeicons/core-free-icons";
import { cn } from '@/lib/utils';
import { CodeBlock } from './CodeBlock';
type ToolStatus = 'running' | 'success' | 'error';
interface ToolCallBlockProps {
name: string;
input: unknown;
result?: string;
isError?: boolean;
status?: ToolStatus;
duration?: number;
}
// Classify tools by name
function getToolCategory(name: string): 'read' | 'write' | 'bash' | 'search' | 'other' {
const lower = name.toLowerCase();
if (lower === 'read' || lower === 'readfile' || lower === 'read_file') return 'read';
if (lower === 'write' || lower === 'edit' || lower === 'writefile' || lower === 'write_file'
|| lower === 'create_file' || lower === 'createfile'
|| lower === 'notebookedit' || lower === 'notebook_edit') return 'write';
if (lower === 'bash' || lower === 'execute' || lower === 'run' || lower === 'shell'
|| lower === 'execute_command') return 'bash';
if (lower === 'search' || lower === 'glob' || lower === 'grep'
|| lower === 'find_files' || lower === 'search_files'
|| lower === 'websearch' || lower === 'web_search') return 'search';
return 'other';
}
function getToolIcon(category: ReturnType<typeof getToolCategory>): IconSvgElement {
switch (category) {
case 'read': return File01Icon;
case 'write': return FileEditIcon;
case 'bash': return CommandLineIcon;
case 'search': return Search01Icon;
case 'other': return Wrench01Icon;
}
}
function getToolSummary(name: string, input: unknown, category: ReturnType<typeof getToolCategory>): string {
const inp = input as Record<string, unknown> | undefined;
if (!inp) return name;
switch (category) {
case 'read': {
const path = (inp.file_path || inp.path || inp.filePath || '') as string;
return path ? extractFilename(path) : name;
}
case 'write': {
const path = (inp.file_path || inp.path || inp.filePath || '') as string;
return path ? extractFilename(path) : name;
}
case 'bash': {
const cmd = (inp.command || inp.cmd || '') as string;
if (cmd) {
const truncated = cmd.length > 80 ? cmd.slice(0, 77) + '...' : cmd;
return truncated;
}
return name;
}
case 'search': {
const pattern = (inp.pattern || inp.query || inp.glob || '') as string;
return pattern ? `"${pattern}"` : name;
}
default:
return name;
}
}
function extractFilename(path: string): string {
const parts = path.split('/');
return parts[parts.length - 1] || path;
}
function getFilePath(input: unknown): string {
const inp = input as Record<string, unknown> | undefined;
if (!inp) return '';
return (inp.file_path || inp.path || inp.filePath || '') as string;
}
function StatusIndicator({ status }: { status: ToolStatus }) {
switch (status) {
case 'running':
return (
<span className="relative flex h-3.5 w-3.5 items-center justify-center">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-30" />
<HugeiconsIcon icon={Loading02Icon} className="relative h-3.5 w-3.5 animate-spin text-blue-500" />
</span>
);
case 'success':
return <HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-3.5 w-3.5 text-green-500" />;
case 'error':
return <HugeiconsIcon icon={CancelCircleIcon} className="h-3.5 w-3.5 text-red-500" />;
}
}
// Detect simple diff in Write/Edit tools (old_string/new_string)
function renderDiff(input: unknown): React.ReactNode | null {
const inp = input as Record<string, unknown> | undefined;
if (!inp) return null;
const oldStr = (inp.old_string ?? inp.oldString ?? '') as string;
const newStr = (inp.new_string ?? inp.newString ?? '') as string;
if (!oldStr && !newStr) return null;
const oldLines = oldStr ? oldStr.split('\n') : [];
const newLines = newStr ? newStr.split('\n') : [];
return (
<div className="my-2 rounded-md border border-zinc-700/50 overflow-hidden text-xs font-mono">
{oldLines.length > 0 && oldLines.map((line, i) => (
<div key={`old-${i}`} className="flex bg-red-950/30 text-red-300">
<span className="select-none w-8 text-right pr-2 text-red-400/60 shrink-0">-</span>
<span className="px-2 whitespace-pre-wrap break-all">{line}</span>
</div>
))}
{newLines.length > 0 && newLines.map((line, i) => (
<div key={`new-${i}`} className="flex bg-green-950/30 text-green-300">
<span className="select-none w-8 text-right pr-2 text-green-400/60 shrink-0">+</span>
<span className="px-2 whitespace-pre-wrap break-all">{line}</span>
</div>
))}
</div>
);
}
export function ToolCallBlock({
name,
input,
result,
isError,
status = result !== undefined ? (isError ? 'error' : 'success') : 'running',
duration,
}: ToolCallBlockProps) {
const [expanded, setExpanded] = useState(false);
const category = getToolCategory(name);
const toolIconData = getToolIcon(category);
const summary = getToolSummary(name, input, category);
const filePath = getFilePath(input);
const renderContent = () => {
switch (category) {
case 'read': {
return (
<div className="space-y-2">
{filePath && (
<div className="text-xs text-muted-foreground font-mono px-1">{filePath}</div>
)}
{result && (
<CodeBlock
code={result.slice(0, 5000)}
language={guessLanguageFromPath(filePath)}
showLineNumbers={true}
/>
)}
{!result && status === 'running' && (
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<HugeiconsIcon icon={Loading02Icon} className="h-3 w-3 animate-spin" />
Reading file...
</div>
)}
</div>
);
}
case 'write': {
const diff = renderDiff(input);
const inp = input as Record<string, unknown> | undefined;
const content = (inp?.content || inp?.new_source || inp?.new_string || '') as string;
return (
<div className="space-y-2">
{filePath && (
<div className="text-xs text-muted-foreground font-mono px-1">{filePath}</div>
)}
{diff}
{!diff && content && (
<CodeBlock
code={content.slice(0, 5000)}
language={guessLanguageFromPath(filePath)}
showLineNumbers={true}
/>
)}
{result && (
<div className="text-xs text-muted-foreground px-1 mt-1">{result.slice(0, 500)}</div>
)}
</div>
);
}
case 'bash': {
const inp = input as Record<string, unknown> | undefined;
const command = (inp?.command || inp?.cmd || '') as string;
return (
<div className="space-y-2">
{command && (
<div className="rounded-md bg-black p-3 font-mono text-xs text-zinc-100 overflow-x-auto">
<span className="text-green-400 select-none">$ </span>
<span className="whitespace-pre-wrap break-all">{command}</span>
</div>
)}
{result && (
<div className="rounded-md bg-zinc-950 p-3 font-mono text-xs text-zinc-300 max-h-60 overflow-auto whitespace-pre-wrap break-all">
{result.slice(0, 5000)}
</div>
)}
{!result && status === 'running' && (
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<HugeiconsIcon icon={Loading02Icon} className="h-3 w-3 animate-spin" />
Executing...
</div>
)}
</div>
);
}
case 'search': {
const inp = input as Record<string, unknown> | undefined;
const pattern = (inp?.pattern || inp?.query || inp?.glob || '') as string;
return (
<div className="space-y-2">
{pattern && (
<div className="text-xs font-mono text-muted-foreground px-1">
Pattern: <span className="text-foreground">{pattern}</span>
</div>
)}
{result && (
<div className="rounded-md bg-muted/50 p-2 font-mono text-xs max-h-60 overflow-auto">
{result.split('\n').slice(0, 50).map((line, i) => (
<div key={i} className="py-0.5 text-muted-foreground hover:text-foreground transition-colors">
{line}
</div>
))}
{result.split('\n').length > 50 && (
<div className="pt-1 text-zinc-500">... and {result.split('\n').length - 50} more lines</div>
)}
</div>
)}
</div>
);
}
default: {
return (
<div className="space-y-2">
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Input</div>
<pre className="overflow-x-auto whitespace-pre-wrap text-xs font-mono text-muted-foreground bg-muted/50 rounded p-2">
{JSON.stringify(input, null, 2)}
</pre>
</div>
{result && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Output</div>
<pre className="overflow-x-auto whitespace-pre-wrap text-xs font-mono text-muted-foreground bg-muted/50 rounded p-2 max-h-60 overflow-auto">
{result.slice(0, 3000)}
</pre>
</div>
)}
</div>
);
}
}
};
const statusBorderColor = {
running: 'border-blue-500/70',
success: 'border-green-500/50',
error: 'border-red-500/60',
}[status];
const statusBgColor = {
running: 'bg-blue-500/[0.03] dark:bg-blue-500/[0.05]',
success: 'bg-transparent',
error: 'bg-red-500/[0.03] dark:bg-red-500/[0.05]',
}[status];
return (
<div className={cn("my-0.5 border-l-2 rounded-r-md overflow-hidden transition-colors duration-300", statusBorderColor, statusBgColor)}>
<button
onClick={() => setExpanded(!expanded)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1 text-left text-sm hover:bg-muted/30 transition-colors",
expanded && "border-b border-border/30"
)}
>
{expanded ? (
<HugeiconsIcon icon={ArrowDown01Icon} className="h-3 w-3 shrink-0 text-muted-foreground" />
) : (
<HugeiconsIcon icon={ArrowRight01Icon} className="h-3 w-3 shrink-0 text-muted-foreground" />
)}
<HugeiconsIcon icon={toolIconData} className={cn(
"h-3.5 w-3.5 shrink-0",
category === 'read' && "text-blue-500",
category === 'write' && "text-amber-500",
category === 'bash' && "text-green-500",
category === 'search' && "text-purple-500",
category === 'other' && "text-zinc-500",
)} />
<span className="font-mono text-xs truncate flex-1 text-foreground/80">{summary}</span>
<div className="flex items-center gap-2 shrink-0 ml-2">
{duration !== undefined && (
<span className="text-xs text-muted-foreground">{formatDuration(duration)}</span>
)}
<StatusIndicator status={status} />
</div>
</button>
<div className={cn(
"grid transition-[grid-template-rows] duration-200 ease-in-out",
expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}>
<div className="overflow-hidden">
<div className="px-3 py-2">
{renderContent()}
</div>
</div>
</div>
</div>
);
}
function guessLanguageFromPath(path: string): string {
if (!path) return 'text';
const ext = path.split('.').pop()?.toLowerCase() || '';
const map: Record<string, string> = {
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
py: 'python', rb: 'ruby', go: 'go', rs: 'rust',
java: 'java', kt: 'kotlin', swift: 'swift',
css: 'css', scss: 'scss', html: 'html',
json: 'json', yaml: 'yaml', yml: 'yaml',
md: 'markdown', sql: 'sql', sh: 'bash',
toml: 'toml', xml: 'xml', c: 'c', cpp: 'cpp', h: 'c',
};
return map[ext] || 'text';
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { usePathname } from "next/navigation";
import { TooltipProvider } from "@/components/ui/tooltip";
import { NavRail } from "./NavRail";
import { ChatListPanel } from "./ChatListPanel";
import { RightPanel } from "./RightPanel";
import { PanelContext, type PanelContent } from "@/hooks/usePanel";
const LG_BREAKPOINT = 1024;
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const [chatListOpen, setChatListOpenRaw] = useState(false);
// Panel state
const isChatRoute = pathname.startsWith("/chat/") || pathname === "/chat";
const isChatDetailRoute = pathname.startsWith("/chat/");
// Auto-close chat list when leaving chat routes
const setChatListOpen = useCallback((open: boolean) => {
setChatListOpenRaw(open);
}, []);
useEffect(() => {
if (!isChatRoute) {
setChatListOpenRaw(false);
}
}, [isChatRoute]);
const [panelOpen, setPanelOpenRaw] = useState(false);
const [panelContent, setPanelContent] = useState<PanelContent>("files");
const [workingDirectory, setWorkingDirectory] = useState("");
const [sessionId, setSessionId] = useState("");
const [sessionTitle, setSessionTitle] = useState("");
const [streamingSessionId, setStreamingSessionId] = useState("");
const [pendingApprovalSessionId, setPendingApprovalSessionId] = useState("");
// Auto-open panel on chat detail routes, close on others
useEffect(() => {
setPanelOpenRaw(isChatDetailRoute);
}, [isChatDetailRoute]);
const setPanelOpen = useCallback((open: boolean) => {
setPanelOpenRaw(open);
}, []);
// Keep chat list state in sync when resizing across the breakpoint (only on chat routes)
useEffect(() => {
if (!isChatRoute) return;
const mql = window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`);
const handler = (e: MediaQueryListEvent) => setChatListOpenRaw(e.matches);
mql.addEventListener("change", handler);
setChatListOpenRaw(mql.matches);
return () => mql.removeEventListener("change", handler);
}, [isChatRoute]);
const panelContextValue = useMemo(
() => ({
panelOpen,
setPanelOpen,
panelContent,
setPanelContent,
workingDirectory,
setWorkingDirectory,
sessionId,
setSessionId,
sessionTitle,
setSessionTitle,
streamingSessionId,
setStreamingSessionId,
pendingApprovalSessionId,
setPendingApprovalSessionId,
}),
[panelOpen, setPanelOpen, panelContent, workingDirectory, sessionId, sessionTitle, streamingSessionId, pendingApprovalSessionId]
);
return (
<PanelContext.Provider value={panelContextValue}>
<TooltipProvider delayDuration={300}>
<div className="flex h-screen overflow-hidden">
<NavRail
chatListOpen={chatListOpen}
onToggleChatList={() => setChatListOpen(!chatListOpen)}
/>
<ChatListPanel open={chatListOpen} />
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Electron draggable title bar region */}
<div
className="h-11 w-full shrink-0"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
/>
<main className="relative flex-1 overflow-hidden">{children}</main>
</div>
{isChatDetailRoute && <RightPanel />}
</div>
</TooltipProvider>
</PanelContext.Provider>
);
}
+287
View File
@@ -0,0 +1,287 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import { useEffect, useState, useCallback } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Delete02Icon, Search01Icon, Notification02Icon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { usePanel } from "@/hooks/usePanel";
import { ConnectionStatus } from "./ConnectionStatus";
import type { ChatSession } from "@/types";
interface ChatListPanelProps {
open: boolean;
}
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr.includes("T") ? dateStr : dateStr + "Z");
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHr < 24) return `${diffHr}h ago`;
if (diffDay < 7) return `${diffDay}d ago`;
return date.toLocaleDateString();
}
const DATE_GROUP_ORDER = ["Today", "Yesterday", "Last 7 Days", "Older"];
function groupSessionsByDate(
sessions: ChatSession[]
): Record<string, ChatSession[]> {
const groups: Record<string, ChatSession[]> = {};
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 86400000);
const lastWeek = new Date(today.getTime() - 7 * 86400000);
for (const session of sessions) {
const date = new Date(session.updated_at);
let group: string;
if (date >= today) group = "Today";
else if (date >= yesterday) group = "Yesterday";
else if (date >= lastWeek) group = "Last 7 Days";
else group = "Older";
if (!groups[group]) groups[group] = [];
groups[group].push(session);
}
return groups;
}
const MODE_BADGE_CONFIG = {
code: { label: "Code", className: "bg-blue-500/10 text-blue-500" },
plan: { label: "Plan", className: "bg-purple-500/10 text-purple-500" },
ask: { label: "Ask", className: "bg-green-500/10 text-green-500" },
} as const;
export function ChatListPanel({ open }: ChatListPanelProps) {
const pathname = usePathname();
const router = useRouter();
const { streamingSessionId, pendingApprovalSessionId } = usePanel();
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [hoveredSession, setHoveredSession] = useState<string | null>(null);
const [deletingSession, setDeletingSession] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const fetchSessions = useCallback(async () => {
try {
const res = await fetch("/api/chat/sessions");
if (res.ok) {
const data = await res.json();
setSessions(data.sessions || []);
}
} catch {
// API may not be available yet
}
}, []);
useEffect(() => {
fetchSessions();
}, [fetchSessions]);
// Refresh session list when navigating
useEffect(() => {
fetchSessions();
}, [pathname, fetchSessions]);
// Refresh session list when a session is created or updated
useEffect(() => {
const handler = () => fetchSessions();
window.addEventListener('session-created', handler);
window.addEventListener('session-updated', handler);
return () => {
window.removeEventListener('session-created', handler);
window.removeEventListener('session-updated', handler);
};
}, [fetchSessions]);
const handleDeleteSession = async (
e: React.MouseEvent,
sessionId: string
) => {
e.preventDefault();
e.stopPropagation();
if (!confirm('Delete this conversation?')) return;
setDeletingSession(sessionId);
try {
const res = await fetch(`/api/chat/sessions/${sessionId}`, {
method: "DELETE",
});
if (res.ok) {
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
if (pathname === `/chat/${sessionId}`) {
router.push("/chat");
}
}
} catch {
// Silently fail
} finally {
setDeletingSession(null);
}
};
const filteredSessions = searchQuery
? sessions.filter(
(s) =>
s.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
(s.project_name &&
s.project_name.toLowerCase().includes(searchQuery.toLowerCase()))
)
: sessions;
const groupedSessions = groupSessionsByDate(filteredSessions);
if (!open) return null;
return (
<aside className="hidden h-full w-60 shrink-0 flex-col overflow-hidden bg-sidebar lg:flex">
{/* Header - extra top padding for macOS traffic lights */}
<div className="flex h-12 shrink-0 items-center justify-between px-3 mt-5 pl-6">
<span className="text-[13px] font-semibold tracking-tight text-sidebar-foreground">
Chats
</span>
<ConnectionStatus />
</div>
{/* Search */}
<div className="px-3 py-2">
<div className="relative">
<HugeiconsIcon icon={Search01Icon} className="absolute left-2.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search chats..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-7 text-xs"
/>
</div>
</div>
{/* Chat sessions list */}
<ScrollArea className="flex-1 min-h-0 px-3">
<div className="flex flex-col pb-3">
{filteredSessions.length === 0 ? (
<p className="px-2.5 py-3 text-[11px] text-muted-foreground/60">
{searchQuery ? "No matching chats" : "No conversations yet"}
</p>
) : (
DATE_GROUP_ORDER.map((group) => {
const groupSessions = groupedSessions[group];
if (!groupSessions || groupSessions.length === 0) return null;
return (
<div key={group} className="mt-2 first:mt-0">
<span className="px-2.5 py-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60">
{group}
</span>
<div className="mt-1 flex flex-col gap-1">
{groupSessions.map((session) => {
const isActive = pathname === `/chat/${session.id}`;
const isHovered = hoveredSession === session.id;
const isDeleting = deletingSession === session.id;
const isSessionStreaming = streamingSessionId === session.id;
const needsApproval = pendingApprovalSessionId === session.id;
const mode = session.mode || "code";
const badgeCfg = MODE_BADGE_CONFIG[mode];
return (
<div
key={session.id}
className="group relative"
onMouseEnter={() => setHoveredSession(session.id)}
onMouseLeave={() => setHoveredSession(null)}
>
<Link
href={`/chat/${session.id}`}
className={cn(
"flex flex-col gap-0.5 rounded-lg px-2.5 py-2 transition-all duration-150",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-accent/50"
)}
>
<div className="flex items-center gap-1.5 min-w-0">
{/* Streaming pulse indicator */}
{isSessionStreaming && (
<span className="relative flex h-2 w-2 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" />
</span>
)}
<span className="line-clamp-2 text-[13px] font-medium leading-tight break-all">
{session.title}
</span>
{/* Approval reminder */}
{needsApproval && (
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
<HugeiconsIcon icon={Notification02Icon} className="h-2.5 w-2.5 text-amber-500" />
</span>
)}
</div>
<div className="flex items-center gap-1.5 min-w-0">
{/* Mode badge */}
<span className={cn("text-[9px] px-1 py-0.5 rounded font-medium leading-none shrink-0", badgeCfg.className)}>
{badgeCfg.label}
</span>
{session.project_name && (
<span className="truncate text-[10px] text-muted-foreground/50">
{session.project_name}
</span>
)}
{session.project_name && (
<span className="text-muted-foreground/30 text-[10px]">
·
</span>
)}
<span className="text-[10px] text-muted-foreground/40 shrink-0">
{formatRelativeTime(session.updated_at)}
</span>
</div>
</Link>
{(isHovered || isDeleting) && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="absolute right-1 top-2 text-muted-foreground/60 hover:text-destructive"
onClick={(e) =>
handleDeleteSession(e, session.id)
}
disabled={isDeleting}
>
<HugeiconsIcon icon={Delete02Icon} className="h-3 w-3" />
<span className="sr-only">
Delete session
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
Delete
</TooltipContent>
</Tooltip>
)}
</div>
);
})}
</div>
</div>
);
})
)}
</div>
</ScrollArea>
</aside>
);
}
+141
View File
@@ -0,0 +1,141 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ClaudeStatus {
connected: boolean;
version: string | null;
}
export function ConnectionStatus() {
const [status, setStatus] = useState<ClaudeStatus | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const checkStatus = useCallback(async () => {
try {
const res = await fetch("/api/claude-status");
if (res.ok) {
const data: ClaudeStatus = await res.json();
setStatus(data);
}
} catch {
setStatus({ connected: false, version: null });
}
}, []);
useEffect(() => {
checkStatus();
const interval = setInterval(checkStatus, 30000);
return () => clearInterval(interval);
}, [checkStatus]);
const connected = status?.connected ?? false;
return (
<>
<button
onClick={() => setDialogOpen(true)}
className={cn(
"flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[11px] font-medium transition-colors",
status === null
? "bg-muted text-muted-foreground"
: connected
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400"
: "bg-red-500/15 text-red-700 dark:text-red-400"
)}
>
<span
className={cn(
"block h-1.5 w-1.5 shrink-0 rounded-full",
status === null
? "bg-muted-foreground/40"
: connected
? "bg-emerald-500"
: "bg-red-500"
)}
/>
{status === null
? "Checking"
: connected
? "Connected"
: "Disconnected"}
</button>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{connected ? "Claude Code Connected" : "Claude Code Not Connected"}
</DialogTitle>
<DialogDescription>
{connected
? `Claude Code CLI v${status?.version} is running and ready.`
: "Claude Code CLI is required to use this application."}
</DialogDescription>
</DialogHeader>
{connected ? (
<div className="space-y-3 text-sm">
<div className="flex items-center gap-3 rounded-lg bg-emerald-500/10 px-4 py-3">
<span className="block h-2.5 w-2.5 shrink-0 rounded-full bg-emerald-500" />
<div>
<p className="font-medium text-emerald-700 dark:text-emerald-400">Active</p>
<p className="text-xs text-muted-foreground">Version {status?.version}</p>
</div>
</div>
</div>
) : (
<div className="space-y-4 text-sm">
<div className="flex items-center gap-3 rounded-lg bg-red-500/10 px-4 py-3">
<span className="block h-2.5 w-2.5 shrink-0 rounded-full bg-red-500" />
<p className="font-medium text-red-700 dark:text-red-400">Not detected</p>
</div>
<div>
<h4 className="font-medium mb-1.5">1. Install Claude Code</h4>
<code className="block rounded-md bg-muted px-3 py-2 text-xs">
npm install -g @anthropic-ai/claude-code
</code>
</div>
<div>
<h4 className="font-medium mb-1.5">2. Authenticate</h4>
<code className="block rounded-md bg-muted px-3 py-2 text-xs">
claude login
</code>
</div>
<div>
<h4 className="font-medium mb-1.5">3. Verify Installation</h4>
<code className="block rounded-md bg-muted px-3 py-2 text-xs">
claude --version
</code>
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => {
checkStatus();
}}
>
Refresh
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Moon02Icon, Sun02Icon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function Header() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<header className="flex h-11 shrink-0 items-center gap-2 border-b border-border/50 bg-background px-4">
<div className="ml-auto flex items-center gap-2">
{mounted && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="h-7 w-7"
>
{theme === "dark" ? (
<HugeiconsIcon icon={Sun02Icon} className="h-4 w-4" />
) : (
<HugeiconsIcon icon={Moon02Icon} className="h-4 w-4" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{theme === "dark" ? "Light mode" : "Dark mode"}
</TooltipContent>
</Tooltip>
)}
</div>
</header>
);
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import {
Message02Icon,
PlusSignIcon,
GridIcon,
Settings02Icon,
Moon02Icon,
Sun02Icon,
} from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface NavRailProps {
chatListOpen: boolean;
onToggleChatList: () => void;
}
const navItems = [
{ href: "/chat", label: "Chats", icon: Message02Icon },
{ href: "/extensions", label: "Extensions", icon: GridIcon },
{ href: "/settings", label: "Settings", icon: Settings02Icon },
] as const;
export function NavRail({ chatListOpen, onToggleChatList }: NavRailProps) {
const pathname = usePathname();
const router = useRouter();
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const isChatRoute = pathname === "/chat" || pathname.startsWith("/chat/");
useEffect(() => {
setMounted(true);
}, []);
return (
<aside className="flex w-14 shrink-0 flex-col items-center bg-sidebar pb-3 pt-10">
{/* New Chat */}
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
variant="ghost"
size="icon"
className="mb-2 h-9 w-9"
>
<Link href="/chat">
<HugeiconsIcon icon={PlusSignIcon} className="h-4 w-4" />
<span className="sr-only">New Chat</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="right">New Chat</TooltipContent>
</Tooltip>
{/* Divider */}
<div className="mx-auto mb-2 h-px w-6 bg-border/50" />
{/* Nav icons */}
<nav className="flex flex-1 flex-col items-center gap-1">
{navItems.map((item) => {
const isActive =
item.href === "/chat"
? pathname === "/chat" || pathname.startsWith("/chat/")
: item.href === "/extensions"
? pathname.startsWith("/extensions")
: pathname === item.href || pathname.startsWith(item.href + "?");
return (
<Tooltip key={item.href}>
<TooltipTrigger asChild>
{item.href === "/chat" ? (
<Button
variant="ghost"
size="icon"
className={cn(
"h-9 w-9",
isActive && "bg-sidebar-accent text-sidebar-accent-foreground"
)}
onClick={() => {
if (!isChatRoute) {
// Navigate to chat page first, then open chat list
router.push("/chat");
onToggleChatList();
} else {
onToggleChatList();
}
}}
>
<HugeiconsIcon icon={item.icon} className="h-4 w-4" />
<span className="sr-only">{item.label}</span>
</Button>
) : (
<Button
asChild
variant="ghost"
size="icon"
className={cn(
"h-9 w-9",
isActive && "bg-sidebar-accent text-sidebar-accent-foreground"
)}
>
<Link href={item.href}>
<HugeiconsIcon icon={item.icon} className="h-4 w-4" />
<span className="sr-only">{item.label}</span>
</Link>
</Button>
)}
</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
);
})}
</nav>
{/* Bottom: theme toggle */}
<div className="mt-auto flex flex-col items-center gap-2">
{mounted && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="h-8 w-8"
>
{theme === "dark" ? (
<HugeiconsIcon icon={Sun02Icon} className="h-4 w-4" />
) : (
<HugeiconsIcon icon={Moon02Icon} className="h-4 w-4" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{theme === "dark" ? "Light mode" : "Dark mode"}
</TooltipContent>
</Tooltip>
)}
</div>
</aside>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { StructureFolderIcon, PanelRightCloseIcon, PencilEdit01Icon, Tick01Icon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { usePanel } from "@/hooks/usePanel";
import { FileTree } from "@/components/project/FileTree";
import { FilePreview } from "@/components/project/FilePreview";
export function RightPanel() {
const { panelOpen, setPanelOpen, workingDirectory, sessionId, sessionTitle, setSessionTitle } = usePanel();
const [previewPath, setPreviewPath] = useState<string | null>(null);
const [isEditingName, setIsEditingName] = useState(false);
const [editName, setEditName] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const displayTitle = sessionTitle || (sessionId ? `Session ${sessionId.slice(0, 8)}` : "Untitled Chat");
useEffect(() => {
if (isEditingName && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditingName]);
const handleStartEdit = useCallback(() => {
setEditName(displayTitle);
setIsEditingName(true);
}, [displayTitle]);
const handleSaveName = useCallback(async () => {
const trimmed = editName.trim();
if (!trimmed || !sessionId) {
setIsEditingName(false);
return;
}
try {
const res = await fetch(`/api/chat/sessions/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: trimmed }),
});
if (res.ok) {
setSessionTitle(trimmed);
}
} catch {
// silently fail
}
setIsEditingName(false);
}, [editName, sessionId, setSessionTitle]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSaveName();
} else if (e.key === "Escape") {
setIsEditingName(false);
}
}, [handleSaveName]);
if (!panelOpen) {
return (
<div className="flex flex-col items-center gap-2 bg-background p-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPanelOpen(true)}
>
<HugeiconsIcon icon={StructureFolderIcon} className="h-4 w-4" />
<span className="sr-only">Open panel</span>
</Button>
</TooltipTrigger>
<TooltipContent side="left">Open panel</TooltipContent>
</Tooltip>
</div>
);
}
const handleFileSelect = (path: string) => {
setPreviewPath(path);
};
const handleBackToTree = () => {
setPreviewPath(null);
};
return (
<aside className="hidden h-full w-72 shrink-0 flex-col overflow-hidden bg-background lg:flex">
{/* Header */}
<div className="flex h-10 shrink-0 items-center justify-between px-4">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Chat Info</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPanelOpen(false)}
>
<HugeiconsIcon icon={PanelRightCloseIcon} className="h-4 w-4" />
<span className="sr-only">Close panel</span>
</Button>
</TooltipTrigger>
<TooltipContent side="left">Close panel</TooltipContent>
</Tooltip>
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-4 space-y-4">
{/* Name - editable */}
<div>
<label className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70 mb-1.5 block">Name</label>
{isEditingName ? (
<div className="flex items-center gap-1.5">
<Input
ref={inputRef}
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSaveName}
className="h-7 text-sm"
/>
<Button variant="ghost" size="icon-xs" onClick={handleSaveName}>
<HugeiconsIcon icon={Tick01Icon} className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<div className="flex items-center gap-1.5 group">
<p className="text-sm font-medium flex-1 truncate">{displayTitle}</p>
<Button
variant="ghost"
size="icon-xs"
className="opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={handleStartEdit}
>
<HugeiconsIcon icon={PencilEdit01Icon} className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
{/* Divider */}
<div className="h-px bg-border/50" />
{/* Files */}
<div className="flex flex-col min-h-0">
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70 mb-1.5 block">Files</span>
<div className="overflow-hidden">
{previewPath ? (
<FilePreview filePath={previewPath} onBack={handleBackToTree} />
) : (
<FileTree
workingDirectory={workingDirectory}
onFileSelect={handleFileSelect}
/>
)}
</div>
</div>
</div>
</aside>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ReactNode } from "react";
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
}
+63
View File
@@ -0,0 +1,63 @@
'use client';
import { useState, useEffect } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
interface ConfigEditorProps {
value: string;
onSave: (value: string) => void;
label?: string;
}
export function ConfigEditor({ value, onSave, label }: ConfigEditorProps) {
const [text, setText] = useState(value);
const [error, setError] = useState<string | null>(null);
useEffect(() => { setText(value); }, [value]);
function handleSave() {
try {
JSON.parse(text);
setError(null);
onSave(text);
} catch (e) {
setError(e instanceof Error ? e.message : 'Invalid JSON');
}
}
function handleFormat() {
try {
const parsed = JSON.parse(text);
setText(JSON.stringify(parsed, null, 2));
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Invalid JSON');
}
}
return (
<div className="space-y-2">
{label && <Label>{label}</Label>}
<Textarea
value={text}
onChange={(e) => {
setText(e.target.value);
setError(null);
}}
className="font-mono text-sm min-h-[200px]"
placeholder="{}"
/>
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex gap-2">
<Button size="sm" onClick={handleSave}>
Save
</Button>
<Button size="sm" variant="outline" onClick={handleFormat}>
Format
</Button>
</div>
</div>
);
}
+211
View File
@@ -0,0 +1,211 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { HugeiconsIcon } from "@hugeicons/react";
import { PlusSignIcon, ListViewIcon, CodeIcon, Loading02Icon } from "@hugeicons/core-free-icons";
import { McpServerList } from "@/components/plugins/McpServerList";
import { McpServerEditor } from "@/components/plugins/McpServerEditor";
import { ConfigEditor } from "@/components/plugins/ConfigEditor";
import type { MCPServer } from "@/types";
export function McpManager() {
const [servers, setServers] = useState<Record<string, MCPServer>>({});
const [loading, setLoading] = useState(true);
const [editorOpen, setEditorOpen] = useState(false);
const [editingName, setEditingName] = useState<string | undefined>();
const [editingServer, setEditingServer] = useState<MCPServer | undefined>();
const [tab, setTab] = useState<"list" | "json">("list");
const [error, setError] = useState<string | null>(null);
const fetchServers = useCallback(async () => {
try {
setError(null);
const res = await fetch("/api/plugins/mcp");
const data = await res.json();
if (data.mcpServers) {
setServers(data.mcpServers);
} else if (data.error) {
setError(data.error);
}
} catch (err) {
console.error("Failed to fetch MCP servers:", err);
setError("Failed to connect to API");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchServers();
}, [fetchServers]);
function handleEdit(name: string, server: MCPServer) {
setEditingName(name);
setEditingServer(server);
setEditorOpen(true);
}
function handleAdd() {
setEditingName(undefined);
setEditingServer(undefined);
setEditorOpen(true);
}
async function handleDelete(name: string) {
try {
const res = await fetch(`/api/plugins/mcp/${encodeURIComponent(name)}`, {
method: "DELETE",
});
if (res.ok) {
setServers((prev) => {
const updated = { ...prev };
delete updated[name];
return updated;
});
} else {
const data = await res.json();
console.error("Failed to delete MCP server:", data.error);
}
} catch (err) {
console.error("Failed to delete MCP server:", err);
}
}
async function handleSave(name: string, server: MCPServer) {
if (editingName && editingName !== name) {
const updated = { ...servers };
delete updated[editingName];
updated[name] = server;
try {
await fetch("/api/plugins/mcp", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mcpServers: updated }),
});
setServers(updated);
} catch (err) {
console.error("Failed to save MCP server:", err);
}
} else if (editingName) {
const updated = { ...servers, [name]: server };
try {
await fetch("/api/plugins/mcp", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mcpServers: updated }),
});
setServers(updated);
} catch (err) {
console.error("Failed to save MCP server:", err);
}
} else {
try {
const res = await fetch("/api/plugins/mcp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, server }),
});
if (res.ok) {
setServers((prev) => ({ ...prev, [name]: server }));
} else {
const data = await res.json();
console.error("Failed to add MCP server:", data.error);
}
} catch (err) {
console.error("Failed to add MCP server:", err);
}
}
}
async function handleJsonSave(jsonStr: string) {
try {
const parsed = JSON.parse(jsonStr);
await fetch("/api/plugins/mcp", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mcpServers: parsed }),
});
setServers(parsed);
} catch (err) {
console.error("Failed to save MCP config:", err);
}
}
const serverCount = Object.keys(servers).length;
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">MCP Servers</h3>
{serverCount > 0 && (
<span className="text-sm text-muted-foreground">
({serverCount})
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5">
Configure Model Context Protocol servers for Claude
</p>
</div>
<Button size="sm" className="gap-1" onClick={handleAdd}>
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
Add Server
</Button>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 mb-4">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<Tabs value={tab} onValueChange={(v) => setTab(v as "list" | "json")}>
<TabsList>
<TabsTrigger value="list" className="gap-1.5">
<HugeiconsIcon icon={ListViewIcon} className="h-3.5 w-3.5" />
Servers
</TabsTrigger>
<TabsTrigger value="json" className="gap-1.5">
<HugeiconsIcon icon={CodeIcon} className="h-3.5 w-3.5" />
JSON Config
</TabsTrigger>
</TabsList>
<TabsContent value="list" className="mt-4">
{loading ? (
<div className="flex items-center justify-center gap-2 py-12 text-muted-foreground">
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
<p className="text-sm">Loading MCP servers...</p>
</div>
) : (
<McpServerList
servers={servers}
onEdit={handleEdit}
onDelete={handleDelete}
/>
)}
</TabsContent>
<TabsContent value="json" className="mt-4">
<ConfigEditor
value={JSON.stringify(servers, null, 2)}
onSave={handleJsonSave}
label="MCP Server Configuration"
/>
</TabsContent>
</Tabs>
<McpServerEditor
open={editorOpen}
onOpenChange={setEditorOpen}
name={editingName}
server={editingServer}
onSave={handleSave}
/>
</div>
);
}
+376
View File
@@ -0,0 +1,376 @@
'use client';
import { useState, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { HugeiconsIcon } from "@hugeicons/react";
import { ServerStack01Icon, Wifi01Icon, GlobeIcon, CodeIcon } from "@hugeicons/core-free-icons";
import type { MCPServer } from '@/types';
type ServerType = 'stdio' | 'sse' | 'http';
interface McpServerEditorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
name?: string;
server?: MCPServer;
onSave: (name: string, server: MCPServer) => void;
}
export function McpServerEditor({
open,
onOpenChange,
name: initialName,
server: initialServer,
onSave,
}: McpServerEditorProps) {
const isEditing = !!initialName;
const [name, setName] = useState(initialName || '');
const [serverType, setServerType] = useState<ServerType>(
initialServer?.type || 'stdio'
);
const [command, setCommand] = useState(initialServer?.command || '');
const [args, setArgs] = useState(initialServer?.args?.join('\n') || '');
const [url, setUrl] = useState(initialServer?.url || '');
const [headersText, setHeadersText] = useState(
initialServer?.headers ? JSON.stringify(initialServer.headers, null, 2) : '{}'
);
const [envText, setEnvText] = useState(
initialServer?.env ? JSON.stringify(initialServer.env, null, 2) : '{}'
);
const [jsonMode, setJsonMode] = useState(false);
const [jsonText, setJsonText] = useState('');
const [error, setError] = useState<string | null>(null);
// Reset form when dialog opens with new data
useEffect(() => {
if (open) {
setName(initialName || '');
setServerType(initialServer?.type || 'stdio');
setCommand(initialServer?.command || '');
setArgs(initialServer?.args?.join('\n') || '');
setUrl(initialServer?.url || '');
setHeadersText(
initialServer?.headers
? JSON.stringify(initialServer.headers, null, 2)
: '{}'
);
setEnvText(
initialServer?.env
? JSON.stringify(initialServer.env, null, 2)
: '{}'
);
setJsonMode(false);
setJsonText(
initialServer
? JSON.stringify(initialServer, null, 2)
: '{\n "command": "",\n "args": []\n}'
);
setError(null);
}
}, [open, initialName, initialServer]);
function handleSave() {
setError(null);
if (!name.trim()) {
setError('Server name is required');
return;
}
if (jsonMode) {
try {
const parsed = JSON.parse(jsonText);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
setError('JSON must be an object');
return;
}
onSave(name.trim(), parsed as MCPServer);
onOpenChange(false);
} catch {
setError('Invalid JSON configuration');
}
return;
}
// Validate based on server type
if (serverType === 'stdio') {
if (!command.trim()) {
setError('Command is required for stdio servers');
return;
}
} else {
if (!url.trim()) {
setError('URL is required for SSE/HTTP servers');
return;
}
}
let env: Record<string, string> | undefined;
try {
const parsed = JSON.parse(envText);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
env = Object.keys(parsed).length > 0 ? parsed : undefined;
} else {
setError('Environment must be a JSON object');
return;
}
} catch {
setError('Invalid JSON in environment variables');
return;
}
let headers: Record<string, string> | undefined;
if (serverType !== 'stdio') {
try {
const parsed = JSON.parse(headersText);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
headers = Object.keys(parsed).length > 0 ? parsed : undefined;
} else {
setError('Headers must be a JSON object');
return;
}
} catch {
setError('Invalid JSON in headers');
return;
}
}
const serverArgs = args
.split('\n')
.map((s: string) => s.trim())
.filter(Boolean);
const server: MCPServer = serverType === 'stdio'
? {
command: command.trim(),
...(serverArgs.length > 0 ? { args: serverArgs } : {}),
...(env ? { env } : {}),
}
: {
command: '',
type: serverType,
...(url ? { url: url.trim() } : {}),
...(serverArgs.length > 0 ? { args: serverArgs } : {}),
...(env ? { env } : {}),
...(headers ? { headers } : {}),
};
onSave(name.trim(), server);
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{isEditing ? `Edit MCP Server: ${initialName}` : 'Add MCP Server'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="server-name">Server Name</Label>
<Input
id="server-name"
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
placeholder="my-mcp-server"
disabled={isEditing}
/>
</div>
<div className="flex items-center gap-2">
<Label className="shrink-0">Edit Mode:</Label>
<Button
variant={jsonMode ? 'outline' : 'default'}
size="sm"
onClick={() => {
setJsonMode(false);
setError(null);
}}
>
Form
</Button>
<Button
variant={jsonMode ? 'default' : 'outline'}
size="sm"
className="gap-1.5"
onClick={() => {
// Build current config as JSON for the editor
const currentConfig: Record<string, unknown> = {};
if (serverType !== 'stdio') {
currentConfig.type = serverType;
if (url) currentConfig.url = url;
} else {
currentConfig.command = command;
}
const argsArr = args.split('\n').map(s => s.trim()).filter(Boolean);
if (argsArr.length > 0) currentConfig.args = argsArr;
try {
const envParsed = JSON.parse(envText);
if (Object.keys(envParsed).length > 0) currentConfig.env = envParsed;
} catch { /* ignore */ }
try {
const headersParsed = JSON.parse(headersText);
if (Object.keys(headersParsed).length > 0) currentConfig.headers = headersParsed;
} catch { /* ignore */ }
setJsonText(JSON.stringify(currentConfig, null, 2));
setJsonMode(true);
setError(null);
}}
>
<HugeiconsIcon icon={CodeIcon} className="h-3.5 w-3.5" />
JSON
</Button>
</div>
{jsonMode ? (
<div className="space-y-2">
<Label>Server Configuration (JSON)</Label>
<Textarea
value={jsonText}
onChange={(e) => {
setJsonText(e.target.value);
setError(null);
}}
className="font-mono text-sm min-h-[250px]"
placeholder='{"command": "npx", "args": ["-y", "@server/name"]}'
/>
</div>
) : (
<>
<div className="space-y-2">
<Label>Server Type</Label>
<Tabs
value={serverType}
onValueChange={(v) => {
setServerType(v as ServerType);
setError(null);
}}
>
<TabsList className="w-full">
<TabsTrigger value="stdio" className="flex-1 gap-1.5">
<HugeiconsIcon icon={ServerStack01Icon} className="h-3.5 w-3.5" />
stdio
</TabsTrigger>
<TabsTrigger value="sse" className="flex-1 gap-1.5">
<HugeiconsIcon icon={Wifi01Icon} className="h-3.5 w-3.5" />
SSE
</TabsTrigger>
<TabsTrigger value="http" className="flex-1 gap-1.5">
<HugeiconsIcon icon={GlobeIcon} className="h-3.5 w-3.5" />
HTTP
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{serverType === 'stdio' ? (
<>
<div className="space-y-2">
<Label htmlFor="server-command">Command</Label>
<Input
id="server-command"
value={command}
onChange={(e) => {
setCommand(e.target.value);
setError(null);
}}
placeholder="npx -y @modelcontextprotocol/server-name"
className="font-mono text-sm"
/>
</div>
<div className="space-y-2">
<Label htmlFor="server-args">Arguments (one per line)</Label>
<Textarea
id="server-args"
value={args}
onChange={(e) => setArgs(e.target.value)}
placeholder={"--flag\nvalue"}
className="font-mono text-sm min-h-[80px]"
/>
</div>
</>
) : (
<>
<div className="space-y-2">
<Label htmlFor="server-url">URL</Label>
<Input
id="server-url"
value={url}
onChange={(e) => {
setUrl(e.target.value);
setError(null);
}}
placeholder={
serverType === 'sse'
? 'http://localhost:3001/sse'
: 'http://localhost:3001'
}
className="font-mono text-sm"
/>
</div>
<div className="space-y-2">
<Label htmlFor="server-headers">Headers (JSON)</Label>
<Textarea
id="server-headers"
value={headersText}
onChange={(e) => {
setHeadersText(e.target.value);
setError(null);
}}
placeholder='{"Authorization": "Bearer ..."}'
className="font-mono text-sm min-h-[80px]"
/>
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="server-env">Environment Variables (JSON)</Label>
<Textarea
id="server-env"
value={envText}
onChange={(e) => {
setEnvText(e.target.value);
setError(null);
}}
placeholder='{"API_KEY": "..."}'
className="font-mono text-sm min-h-[80px]"
/>
</div>
</>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave}>
{isEditing ? 'Save Changes' : 'Add Server'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+121
View File
@@ -0,0 +1,121 @@
'use client';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import { Delete02Icon, PencilIcon, ServerStack01Icon, Wifi01Icon, GlobeIcon } from "@hugeicons/core-free-icons";
import type { MCPServer } from '@/types';
interface McpServerListProps {
servers: Record<string, MCPServer>;
onEdit: (name: string, server: MCPServer) => void;
onDelete: (name: string) => void;
}
function getServerTypeInfo(server: MCPServer) {
const type = server.type || 'stdio';
switch (type) {
case 'sse':
return { label: 'SSE', icon: Wifi01Icon, color: 'text-blue-500' };
case 'http':
return { label: 'HTTP', icon: GlobeIcon, color: 'text-green-500' };
default:
return { label: 'stdio', icon: ServerStack01Icon, color: 'text-muted-foreground' };
}
}
export function McpServerList({ servers, onEdit, onDelete }: McpServerListProps) {
const entries = Object.entries(servers);
if (entries.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<HugeiconsIcon icon={ServerStack01Icon} className="h-10 w-10 mb-3 opacity-50" />
<p className="text-sm">No MCP servers configured</p>
<p className="text-xs mt-1">
Add an MCP server to extend Claude&apos;s capabilities
</p>
</div>
);
}
return (
<div className="space-y-3">
{entries.map(([name, server]) => {
const typeInfo = getServerTypeInfo(server);
return (
<Card key={name}>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
<div className="flex-1 min-w-0 mr-3">
<div className="flex items-center gap-2 mb-1">
<HugeiconsIcon icon={typeInfo.icon} className={`h-4 w-4 shrink-0 ${typeInfo.color}`} />
<CardTitle className="text-sm font-medium">{name}</CardTitle>
<Badge variant="outline" className="text-xs shrink-0">
{typeInfo.label}
</Badge>
<Badge variant="secondary" className="text-xs shrink-0">
Configured
</Badge>
</div>
<CardDescription className="text-xs mt-1 font-mono">
{server.url
? server.url
: `${server.command} ${server.args?.join(' ') || ''}`}
</CardDescription>
</div>
<div className="flex gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onEdit(name, server)}
>
<HugeiconsIcon icon={PencilIcon} className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => onDelete(name)}
>
<HugeiconsIcon icon={Delete02Icon} className="h-3.5 w-3.5" />
</Button>
</div>
</CardHeader>
{(server.env && Object.keys(server.env).length > 0) ||
(server.args && server.args.length > 0) ? (
<CardContent className="pt-0">
{server.args && server.args.length > 0 && (
<div className="mb-2">
<p className="text-xs text-muted-foreground mb-1">Arguments:</p>
<div className="flex gap-1 flex-wrap">
{server.args.map((arg, i) => (
<Badge key={i} variant="outline" className="text-xs font-mono">
{arg}
</Badge>
))}
</div>
</div>
)}
{server.env && Object.keys(server.env).length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">Environment:</p>
<div className="flex gap-1 flex-wrap">
{Object.keys(server.env).map((key) => (
<Badge key={key} variant="outline" className="text-xs font-mono">
{key}
</Badge>
))}
</div>
</div>
)}
</CardContent>
) : null}
</Card>
);
})}
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
'use client';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowDown01Icon, ArrowUp01Icon, ZapIcon } from "@hugeicons/core-free-icons";
export interface SkillInfo {
name: string;
description: string;
source: 'global' | 'project' | 'plugin';
content: string;
filePath: string;
enabled: boolean;
}
interface PluginCardProps {
plugin: SkillInfo;
onSelect: (plugin: SkillInfo) => void;
}
export function PluginCard({ plugin, onSelect }: PluginCardProps) {
const [expanded, setExpanded] = useState(false);
const isProject = plugin.source === 'project';
const isPlugin = plugin.source === 'plugin';
const displayName = isProject
? plugin.name.replace('project:', '')
: plugin.name;
return (
<Card className="transition-colors hover:bg-accent/50">
<CardHeader
className="flex flex-row items-start justify-between space-y-0 pb-3 cursor-pointer"
onClick={() => onSelect(plugin)}
>
<div className="flex-1 min-w-0 mr-3">
<div className="flex items-center gap-2 mb-1">
<HugeiconsIcon icon={ZapIcon} className="h-4 w-4 text-muted-foreground shrink-0" />
<CardTitle className="text-sm font-medium truncate">
/{displayName}
</CardTitle>
<Badge
variant={isProject ? 'secondary' : 'outline'}
className="text-xs shrink-0"
>
{isPlugin ? 'Plugin' : isProject ? 'Project' : 'Global'}
</Badge>
</div>
<CardDescription className="text-xs line-clamp-2">
{plugin.description}
</CardDescription>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
>
{expanded ? (
<HugeiconsIcon icon={ArrowUp01Icon} className="h-4 w-4" />
) : (
<HugeiconsIcon icon={ArrowDown01Icon} className="h-4 w-4" />
)}
</Button>
</CardHeader>
{expanded && (
<CardContent className="pt-0">
<div className="rounded-md bg-muted p-3 max-h-60 overflow-auto">
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
{plugin.content}
</pre>
</div>
<p className="text-xs text-muted-foreground mt-2 font-mono truncate">
{plugin.filePath}
</p>
</CardContent>
)}
</Card>
);
}
+71
View File
@@ -0,0 +1,71 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { HugeiconsIcon } from "@hugeicons/react";
import { Cancel01Icon, ZapIcon } from "@hugeicons/core-free-icons";
import type { SkillInfo } from './PluginCard';
interface PluginDetailProps {
plugin: SkillInfo;
onClose: () => void;
}
export function PluginDetail({ plugin, onClose }: PluginDetailProps) {
const isProject = plugin.source === 'project';
const displayName = isProject
? plugin.name.replace('project:', '')
: plugin.name;
return (
<div className="border rounded-lg p-4 bg-card">
<div className="flex items-start justify-between mb-3">
<div>
<div className="flex items-center gap-2">
<HugeiconsIcon icon={ZapIcon} className="h-4 w-4 text-muted-foreground" />
<h3 className="text-lg font-semibold">/{displayName}</h3>
<Badge variant={isProject ? 'secondary' : 'outline'}>
{isProject ? 'Project' : 'Global'}
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
{plugin.description}
</p>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<HugeiconsIcon icon={Cancel01Icon} className="h-4 w-4" />
</Button>
</div>
<Separator className="my-3" />
<div className="space-y-3">
<div>
<p className="text-sm font-medium mb-1">Source</p>
<p className="text-xs text-muted-foreground font-mono break-all">
{plugin.filePath}
</p>
</div>
<div>
<p className="text-sm font-medium mb-1">Type</p>
<p className="text-xs text-muted-foreground">
{isProject ? 'Project-level skill' : 'User-level skill'}
</p>
</div>
<Separator />
<div>
<p className="text-sm font-medium mb-2">Content</p>
<div className="rounded-md bg-muted p-3 max-h-80 overflow-auto">
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
{plugin.content}
</pre>
</div>
</div>
</div>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
'use client';
import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PluginCard, type SkillInfo } from './PluginCard';
import { HugeiconsIcon } from "@hugeicons/react";
import { Search01Icon, GlobeIcon, FolderOpenIcon, Plug01Icon } from "@hugeicons/core-free-icons";
interface PluginListProps {
plugins: SkillInfo[];
onSelect: (plugin: SkillInfo) => void;
}
export function PluginList({ plugins, onSelect }: PluginListProps) {
const [search, setSearch] = useState('');
const [sourceFilter, setSourceFilter] = useState<'all' | 'global' | 'project' | 'plugin'>('all');
const filtered = plugins.filter((p) => {
const matchesSearch =
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase());
if (sourceFilter === 'all') return matchesSearch;
return matchesSearch && p.source === sourceFilter;
});
const globalCount = plugins.filter((p) => p.source === 'global').length;
const projectCount = plugins.filter((p) => p.source === 'project').length;
const pluginCount = plugins.filter((p) => p.source === 'plugin').length;
return (
<div className="space-y-4">
<div className="relative">
<HugeiconsIcon icon={Search01Icon} className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search skills by name..."
className="pl-9"
/>
</div>
<Tabs
value={sourceFilter}
onValueChange={(v) => setSourceFilter(v as 'all' | 'global' | 'project' | 'plugin')}
>
<TabsList>
<TabsTrigger value="all">
All ({plugins.length})
</TabsTrigger>
<TabsTrigger value="global" className="gap-1.5">
<HugeiconsIcon icon={GlobeIcon} className="h-3.5 w-3.5" />
Global ({globalCount})
</TabsTrigger>
<TabsTrigger value="project" className="gap-1.5">
<HugeiconsIcon icon={FolderOpenIcon} className="h-3.5 w-3.5" />
Project ({projectCount})
</TabsTrigger>
{pluginCount > 0 && (
<TabsTrigger value="plugin" className="gap-1.5">
<HugeiconsIcon icon={Plug01Icon} className="h-3.5 w-3.5" />
Plugin ({pluginCount})
</TabsTrigger>
)}
</TabsList>
<TabsContent value={sourceFilter} className="mt-4">
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm">
{plugins.length === 0
? 'No skills found'
: 'No matching skills'}
</p>
<p className="text-xs mt-1">
{plugins.length === 0
? 'Add .md files to ~/.claude/commands/ or .claude/commands/ to create skills'
: 'Try adjusting your search or filter'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{filtered.map((plugin) => (
<PluginCard
key={plugin.name}
plugin={plugin}
onSelect={onSelect}
/>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
"use client";
import { useState, useEffect } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowLeft01Icon, Copy01Icon, Tick01Icon, Loading02Icon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import type { FilePreview as FilePreviewType } from "@/types";
interface FilePreviewProps {
filePath: string;
onBack: () => void;
}
export function FilePreview({ filePath, onBack }: FilePreviewProps) {
const [preview, setPreview] = useState<FilePreviewType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
async function loadPreview() {
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/files/preview?path=${encodeURIComponent(filePath)}`
);
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to load file");
}
const data = await res.json();
setPreview(data.preview);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load file");
} finally {
setLoading(false);
}
}
loadPreview();
}, [filePath]);
const handleCopyPath = async () => {
await navigator.clipboard.writeText(filePath);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
// Extract filename from path
const fileName = filePath.split("/").pop() || filePath;
// Build breadcrumb segments
const segments = filePath.split("/").filter(Boolean);
const displaySegments = segments.slice(-3); // show last 3 segments
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center gap-2 pb-2">
<Button variant="ghost" size="icon-sm" onClick={onBack}>
<HugeiconsIcon icon={ArrowLeft01Icon} className="h-3.5 w-3.5" />
<span className="sr-only">Back to file tree</span>
</Button>
<div className="min-w-0 flex-1">
<p className="truncate text-xs text-muted-foreground">
{displaySegments.length < segments.length && ".../"}{displaySegments.join("/")}
</p>
</div>
<Button variant="ghost" size="icon-sm" onClick={handleCopyPath}>
{copied ? (
<HugeiconsIcon icon={Tick01Icon} className="h-3.5 w-3.5 text-green-500" />
) : (
<HugeiconsIcon icon={Copy01Icon} className="h-3.5 w-3.5" />
)}
<span className="sr-only">Copy path</span>
</Button>
</div>
{/* File info */}
{preview && (
<div className="flex items-center gap-2 pb-2">
<Badge variant="secondary" className="text-[10px]">
{preview.language}
</Badge>
<span className="text-[10px] text-muted-foreground">
{preview.line_count} lines
</span>
</div>
)}
{/* Content */}
<ScrollArea className="flex-1">
{loading ? (
<div className="flex items-center justify-center py-8">
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="py-4 text-center">
<p className="text-xs text-destructive">{error}</p>
<Button
variant="ghost"
size="sm"
onClick={onBack}
className="mt-2 text-xs"
>
Back to file tree
</Button>
</div>
) : preview ? (
<div className="rounded-md border border-border text-xs">
<SyntaxHighlighter
language={preview.language}
style={atomOneDark}
showLineNumbers
customStyle={{
margin: 0,
padding: "8px",
borderRadius: "6px",
fontSize: "11px",
lineHeight: "1.5",
}}
lineNumberStyle={{
minWidth: "2.5em",
paddingRight: "8px",
color: "#636d83",
userSelect: "none",
}}
>
{preview.content}
</SyntaxHighlighter>
</div>
) : null}
</ScrollArea>
</div>
);
}
+197
View File
@@ -0,0 +1,197 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { RefreshIcon, Search01Icon, SourceCodeIcon, CodeIcon, File01Icon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { FileTreeNode } from "@/types";
import {
FileTree as AIFileTree,
FileTreeFolder,
FileTreeFile,
} from "@/components/ai-elements/file-tree";
import type { ReactNode } from "react";
interface FileTreeProps {
workingDirectory: string;
onFileSelect: (path: string) => void;
}
function getFileIcon(extension?: string): ReactNode {
switch (extension) {
case "ts":
case "tsx":
case "js":
case "jsx":
case "py":
case "rb":
case "rs":
case "go":
case "java":
case "c":
case "cpp":
case "h":
case "hpp":
case "cs":
case "swift":
case "kt":
case "dart":
case "lua":
case "php":
case "zig":
return <HugeiconsIcon icon={SourceCodeIcon} className="size-4 text-muted-foreground" />;
case "json":
case "yaml":
case "yml":
case "toml":
return <HugeiconsIcon icon={CodeIcon} className="size-4 text-muted-foreground" />;
case "md":
case "mdx":
case "txt":
case "csv":
return <HugeiconsIcon icon={File01Icon} className="size-4 text-muted-foreground" />;
default:
return <HugeiconsIcon icon={File01Icon} className="size-4 text-muted-foreground" />;
}
}
function containsMatch(node: FileTreeNode, query: string): boolean {
const q = query.toLowerCase();
if (node.name.toLowerCase().includes(q)) return true;
if (node.children) {
return node.children.some((child) => containsMatch(child, q));
}
return false;
}
function filterTree(nodes: FileTreeNode[], query: string): FileTreeNode[] {
if (!query) return nodes;
return nodes
.filter((node) => containsMatch(node, query))
.map((node) => ({
...node,
children: node.children ? filterTree(node.children, query) : undefined,
}));
}
function RenderTreeNodes({ nodes, searchQuery }: { nodes: FileTreeNode[]; searchQuery: string }) {
const filtered = searchQuery ? filterTree(nodes, searchQuery) : nodes;
return (
<>
{filtered.map((node) => {
if (node.type === "directory") {
return (
<FileTreeFolder key={node.path} path={node.path} name={node.name}>
{node.children && (
<RenderTreeNodes nodes={node.children} searchQuery={searchQuery} />
)}
</FileTreeFolder>
);
}
return (
<FileTreeFile
key={node.path}
path={node.path}
name={node.name}
icon={getFileIcon(node.extension)}
/>
);
})}
</>
);
}
export function FileTree({ workingDirectory, onFileSelect }: FileTreeProps) {
const [tree, setTree] = useState<FileTreeNode[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const fetchTree = useCallback(async () => {
if (!workingDirectory) {
setTree([]);
return;
}
setLoading(true);
try {
const res = await fetch(
`/api/files?dir=${encodeURIComponent(workingDirectory)}&depth=4`
);
if (res.ok) {
const data = await res.json();
setTree(data.tree || []);
} else {
setTree([]);
}
} catch {
setTree([]);
} finally {
setLoading(false);
}
}, [workingDirectory]);
useEffect(() => {
fetchTree();
}, [fetchTree]);
// Build default expanded set from first-level directories
const defaultExpanded = new Set(
tree.filter((n) => n.type === "directory").map((n) => n.path)
);
return (
<div className="flex flex-col">
{/* Header */}
<div className="flex items-center gap-2 p-2 border-b border-border/30">
<p className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground" title={workingDirectory}>
{workingDirectory || 'No directory selected'}
</p>
<Button
variant="ghost"
size="icon-sm"
onClick={fetchTree}
disabled={loading}
className="h-6 w-6 shrink-0"
>
<HugeiconsIcon icon={RefreshIcon} className={cn("h-3 w-3", loading && "animate-spin")} />
<span className="sr-only">Refresh</span>
</Button>
</div>
{/* Search */}
<div className="relative p-2 border-b border-border/30">
<HugeiconsIcon icon={Search01Icon} className="absolute left-4 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Filter files..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-7 pl-7 text-xs"
/>
</div>
{/* Tree */}
<div className="max-h-[400px] overflow-auto">
{loading && tree.length === 0 ? (
<div className="flex items-center justify-center py-8">
<HugeiconsIcon icon={RefreshIcon} className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : tree.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
{workingDirectory ? 'No files found' : 'Select a project folder to view files'}
</p>
) : (
<AIFileTree
defaultExpanded={defaultExpanded}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- AI Elements FileTree onSelect type conflicts with HTMLAttributes.onSelect
onSelect={onFileSelect as any}
className="border-0 rounded-none"
>
<RenderTreeNodes nodes={tree} searchQuery={searchQuery} />
</AIFileTree>
)}
</div>
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Delete02Icon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { TaskItem, TaskStatus } from "@/types";
interface TaskCardProps {
task: TaskItem;
onUpdate: (id: string, updates: { title?: string; status?: TaskStatus }) => void;
onDelete: (id: string) => void;
}
const statusColors: Record<TaskStatus, string> = {
pending: "bg-yellow-500",
in_progress: "bg-blue-500",
completed: "bg-green-500",
failed: "bg-red-500",
};
const nextStatus: Record<TaskStatus, TaskStatus> = {
pending: "in_progress",
in_progress: "completed",
completed: "pending",
failed: "pending",
};
function formatTime(dateStr: string): string {
const date = new Date(dateStr.includes("T") ? dateStr : dateStr + "Z");
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHr < 24) return `${diffHr}h ago`;
return date.toLocaleDateString();
}
export function TaskCard({ task, onUpdate, onDelete }: TaskCardProps) {
const [editing, setEditing] = useState(false);
const [editTitle, setEditTitle] = useState(task.title);
const [hovered, setHovered] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
const handleStatusClick = () => {
onUpdate(task.id, { status: nextStatus[task.status] });
};
const handleTitleSubmit = () => {
const trimmed = editTitle.trim();
if (trimmed && trimmed !== task.title) {
onUpdate(task.id, { title: trimmed });
} else {
setEditTitle(task.title);
}
setEditing(false);
};
return (
<div
className="group flex items-start gap-2 rounded-md border border-border p-2 transition-colors hover:bg-accent/50"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Status dot */}
<button
onClick={handleStatusClick}
className="mt-1 shrink-0"
title={`Status: ${task.status} (click to change)`}
>
<span
className={cn(
"block h-2.5 w-2.5 rounded-full transition-transform hover:scale-125",
statusColors[task.status]
)}
/>
</button>
{/* Content */}
<div className="min-w-0 flex-1">
{editing ? (
<input
ref={inputRef}
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onBlur={handleTitleSubmit}
onKeyDown={(e) => {
if (e.key === "Enter") handleTitleSubmit();
if (e.key === "Escape") {
setEditTitle(task.title);
setEditing(false);
}
}}
className="w-full rounded border-none bg-transparent p-0 text-xs font-medium outline-none ring-1 ring-ring/30 px-1"
/>
) : (
<p
className={cn(
"cursor-pointer truncate text-xs font-medium",
task.status === "completed" && "text-muted-foreground line-through"
)}
onDoubleClick={() => setEditing(true)}
>
{task.title}
</p>
)}
{task.description && (
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">
{task.description}
</p>
)}
<p className="mt-0.5 text-[10px] text-muted-foreground">
{formatTime(task.updated_at)}
</p>
</div>
{/* Delete button */}
{hovered && (
<Button
variant="ghost"
size="icon-xs"
onClick={() => onDelete(task.id)}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<HugeiconsIcon icon={Delete02Icon} className="h-3 w-3" />
<span className="sr-only">Delete task</span>
</Button>
)}
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { PlusSignIcon } from "@hugeicons/core-free-icons";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { TaskCard } from "./TaskCard";
import type { TaskItem, TaskStatus } from "@/types";
interface TaskListProps {
sessionId: string;
}
type FilterTab = "all" | "in_progress" | "completed";
export function TaskList({ sessionId }: TaskListProps) {
const [tasks, setTasks] = useState<TaskItem[]>([]);
const [loading, setLoading] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [filter, setFilter] = useState<FilterTab>("all");
const fetchTasks = useCallback(async () => {
if (!sessionId) return;
setLoading(true);
try {
const res = await fetch(`/api/tasks?session_id=${encodeURIComponent(sessionId)}`);
if (res.ok) {
const data = await res.json();
setTasks(data.tasks || []);
}
} catch {
// silently fail
} finally {
setLoading(false);
}
}, [sessionId]);
useEffect(() => {
fetchTasks();
}, [fetchTasks]);
const handleCreate = async () => {
const title = newTitle.trim();
if (!title || !sessionId) return;
try {
const res = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, title }),
});
if (res.ok) {
const data = await res.json();
setTasks((prev) => [...prev, data.task]);
setNewTitle("");
}
} catch {
// silently fail
}
};
const handleUpdate = async (
id: string,
updates: { title?: string; status?: TaskStatus }
) => {
try {
const res = await fetch(`/api/tasks/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (res.ok) {
const data = await res.json();
setTasks((prev) => prev.map((t) => (t.id === id ? data.task : t)));
}
} catch {
// silently fail
}
};
const handleDelete = async (id: string) => {
try {
const res = await fetch(`/api/tasks/${id}`, { method: "DELETE" });
if (res.ok) {
setTasks((prev) => prev.filter((t) => t.id !== id));
}
} catch {
// silently fail
}
};
const filtered = tasks.filter((task) => {
if (filter === "all") return true;
if (filter === "in_progress")
return task.status === "pending" || task.status === "in_progress";
if (filter === "completed") return task.status === "completed";
return true;
});
const filterTabs: { key: FilterTab; label: string }[] = [
{ key: "all", label: "All" },
{ key: "in_progress", label: "Active" },
{ key: "completed", label: "Done" },
];
return (
<div className="flex h-full flex-col">
{/* Filter tabs */}
<div className="flex items-center gap-1 pb-2">
{filterTabs.map((tab) => (
<Button
key={tab.key}
variant="ghost"
size="sm"
className={cn(
"h-6 px-2 text-[10px]",
filter === tab.key && "bg-accent"
)}
onClick={() => setFilter(tab.key)}
>
{tab.label}
</Button>
))}
</div>
{/* Add task input */}
<div className="flex items-center gap-1 pb-2">
<Input
placeholder="Add a task..."
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
className="h-7 text-xs"
/>
<Button
variant="ghost"
size="icon-sm"
onClick={handleCreate}
disabled={!newTitle.trim()}
>
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
<span className="sr-only">Add task</span>
</Button>
</div>
{/* Task list */}
<ScrollArea className="flex-1">
{loading && tasks.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
Loading tasks...
</p>
) : filtered.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
{tasks.length === 0 ? "No tasks yet" : "No matching tasks"}
</p>
) : (
<div className="flex flex-col gap-1.5 pb-4">
{filtered.map((task) => (
<TaskCard
key={task.id}
task={task}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
))}
</div>
)}
</ScrollArea>
</div>
);
}
+204
View File
@@ -0,0 +1,204 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { HugeiconsIcon } from "@hugeicons/react";
import { Loading02Icon, GlobeIcon, FolderOpenIcon } from "@hugeicons/core-free-icons";
import { cn } from "@/lib/utils";
interface CreateSkillDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreate: (name: string, scope: "global" | "project", content: string) => Promise<void>;
}
const TEMPLATES: { label: string; content: string }[] = [
{ label: "Blank", content: "" },
{
label: "Commit Helper",
content: `# Commit Helper
Review the staged changes and generate a concise, descriptive commit message following conventional commit format.
Rules:
- Use conventional commit prefixes: feat, fix, refactor, docs, test, chore
- Keep the first line under 72 characters
- Add a blank line and detailed description if needed
- Reference relevant issue numbers if applicable
`,
},
{
label: "Code Reviewer",
content: `# Code Reviewer
Review the provided code and give feedback on:
1. **Correctness** - Logic errors, edge cases, potential bugs
2. **Performance** - Inefficiencies, unnecessary allocations
3. **Readability** - Naming, structure, comments where needed
4. **Security** - Input validation, injection risks, data exposure
Be specific with line references. Suggest concrete improvements, not just problems.
`,
},
];
export function CreateSkillDialog({
open,
onOpenChange,
onCreate,
}: CreateSkillDialogProps) {
const [name, setName] = useState("");
const [scope, setScope] = useState<"global" | "project">("project");
const [templateIdx, setTemplateIdx] = useState(0);
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
const handleCreate = async () => {
const trimmed = name.trim();
if (!trimmed) {
setError("Name is required");
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
setError("Name can only contain letters, numbers, hyphens, and underscores");
return;
}
setCreating(true);
setError("");
try {
await onCreate(trimmed, scope, TEMPLATES[templateIdx].content);
// Reset on success
setName("");
setScope("project");
setTemplateIdx(0);
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create skill");
} finally {
setCreating(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create New Skill</DialogTitle>
<DialogDescription>
Create a new slash command skill. It will be saved as a .md file.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Name input */}
<div className="space-y-2">
<Label htmlFor="skill-name">Name</Label>
<div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">/</span>
<Input
id="skill-name"
placeholder="my-skill"
value={name}
onChange={(e) => {
setName(e.target.value);
setError("");
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
/>
</div>
</div>
{/* Scope selection */}
<div className="space-y-2">
<Label>Scope</Label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setScope("project")}
className={cn(
"flex-1 flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors",
scope === "project"
? "border-blue-500/50 bg-blue-500/10 text-blue-600 dark:text-blue-400"
: "border-border hover:bg-accent"
)}
>
<HugeiconsIcon icon={FolderOpenIcon} className="h-4 w-4" />
Project
</button>
<button
type="button"
onClick={() => setScope("global")}
className={cn(
"flex-1 flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors",
scope === "global"
? "border-green-500/50 bg-green-500/10 text-green-600 dark:text-green-400"
: "border-border hover:bg-accent"
)}
>
<HugeiconsIcon icon={GlobeIcon} className="h-4 w-4" />
Global
</button>
</div>
<p className="text-xs text-muted-foreground">
{scope === "project"
? "Saved in .claude/commands/ (this project only)"
: "Saved in ~/.claude/commands/ (available everywhere)"}
</p>
</div>
{/* Template selection */}
<div className="space-y-2">
<Label>Template</Label>
<div className="flex gap-2 flex-wrap">
{TEMPLATES.map((t, i) => (
<button
key={t.label}
type="button"
onClick={() => setTemplateIdx(i)}
className={cn(
"rounded-md border px-3 py-1 text-xs transition-colors",
templateIdx === i
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-accent"
)}
>
{t.label}
</button>
))}
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={creating}
>
Cancel
</Button>
<Button onClick={handleCreate} disabled={creating} className="gap-2">
{creating && <HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />}
Create Skill
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { HugeiconsIcon } from "@hugeicons/react";
import {
FloppyDiskIcon,
Delete02Icon,
EyeIcon,
Edit02Icon,
GlobeIcon,
FolderOpenIcon,
Loading02Icon,
LayoutTwoColumnIcon,
} from "@hugeicons/core-free-icons";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { SkillItem } from "./SkillListItem";
type ViewMode = "edit" | "preview" | "split";
interface SkillEditorProps {
skill: SkillItem;
onSave: (name: string, content: string) => Promise<void>;
onDelete: (skill: SkillItem) => void;
}
export function SkillEditor({ skill, onSave, onDelete }: SkillEditorProps) {
const [content, setContent] = useState(skill.content);
const [viewMode, setViewMode] = useState<ViewMode>("edit");
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isDirty = content !== skill.content;
// Reset content when skill changes
useEffect(() => {
setContent(skill.content);
setConfirmDelete(false);
setSaved(false);
}, [skill.name, skill.filePath, skill.content]);
const handleSave = useCallback(async () => {
setSaving(true);
try {
await onSave(skill.name, content);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} finally {
setSaving(false);
}
}, [skill.name, content, onSave]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Tab indentation
if (e.key === "Tab") {
e.preventDefault();
const textarea = e.currentTarget;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newContent =
content.substring(0, start) + " " + content.substring(end);
setContent(newContent);
// Restore cursor position after React re-render
requestAnimationFrame(() => {
textarea.selectionStart = start + 2;
textarea.selectionEnd = start + 2;
});
}
// Ctrl/Cmd + S to save
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
if (isDirty) handleSave();
}
},
[content, isDirty, handleSave]
);
const handleDelete = () => {
if (confirmDelete) {
onDelete(skill);
setConfirmDelete(false);
} else {
setConfirmDelete(true);
setTimeout(() => setConfirmDelete(false), 3000);
}
};
const markdownContent = (
<div className="prose prose-sm dark:prose-invert max-w-none p-4 overflow-auto">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center gap-2 border-b border-border px-4 py-2 shrink-0">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="text-sm font-semibold truncate">/{skill.name}</span>
{isDirty && (
<span
className="h-2 w-2 rounded-full bg-orange-400 shrink-0"
title="Unsaved changes"
/>
)}
<Badge
variant="outline"
className={cn(
"text-[10px] px-1.5 py-0 shrink-0",
skill.source === "global"
? "border-green-500/40 text-green-600 dark:text-green-400"
: "border-blue-500/40 text-blue-600 dark:text-blue-400"
)}
>
{skill.source === "global" ? (
<HugeiconsIcon icon={GlobeIcon} className="h-2.5 w-2.5 mr-0.5" />
) : (
<HugeiconsIcon icon={FolderOpenIcon} className="h-2.5 w-2.5 mr-0.5" />
)}
{skill.source}
</Badge>
</div>
<div className="flex items-center gap-1">
{/* View mode toggles */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={viewMode === "edit" ? "secondary" : "ghost"}
size="icon-xs"
onClick={() => setViewMode("edit")}
>
<HugeiconsIcon icon={Edit02Icon} className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={viewMode === "preview" ? "secondary" : "ghost"}
size="icon-xs"
onClick={() => setViewMode("preview")}
>
<HugeiconsIcon icon={EyeIcon} className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Preview</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={viewMode === "split" ? "secondary" : "ghost"}
size="icon-xs"
onClick={() => setViewMode("split")}
>
<HugeiconsIcon icon={LayoutTwoColumnIcon} className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Split</TooltipContent>
</Tooltip>
<div className="w-px h-4 bg-border mx-1" />
{/* Save */}
<Button
size="xs"
onClick={handleSave}
disabled={!isDirty || saving}
className="gap-1"
>
{saving ? (
<HugeiconsIcon icon={Loading02Icon} className="h-3 w-3 animate-spin" />
) : (
<HugeiconsIcon icon={FloppyDiskIcon} className="h-3 w-3" />
)}
{saving ? "Saving" : saved ? "Saved" : "Save"}
</Button>
{/* Delete */}
<Button
variant={confirmDelete ? "destructive" : "ghost"}
size="icon-xs"
onClick={handleDelete}
>
<HugeiconsIcon icon={Delete02Icon} className="h-3 w-3" />
</Button>
</div>
</div>
{/* Content area */}
<div className="flex-1 min-h-0 overflow-hidden">
{viewMode === "edit" && (
<Textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
className="h-full w-full resize-none rounded-none border-0 font-mono text-sm focus-visible:ring-0 focus-visible:ring-offset-0 min-h-[400px]"
placeholder="Write your skill prompt in Markdown..."
/>
)}
{viewMode === "preview" && (
<div className="h-full overflow-auto">{markdownContent}</div>
)}
{viewMode === "split" && (
<div className="flex h-full divide-x divide-border">
<div className="flex-1 min-w-0">
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
className="h-full w-full resize-none rounded-none border-0 font-mono text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="Write your skill prompt in Markdown..."
/>
</div>
<div className="flex-1 min-w-0 overflow-auto">
{markdownContent}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center gap-2 border-t border-border px-4 py-1.5 shrink-0">
<span className="text-xs text-muted-foreground truncate">
{skill.filePath}
</span>
</div>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { HugeiconsIcon } from "@hugeicons/react";
import { ZapIcon, Delete02Icon, GlobeIcon, FolderOpenIcon, Plug01Icon, Download04Icon } from "@hugeicons/core-free-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useState } from "react";
export interface SkillItem {
name: string;
description: string;
content: string;
source: "global" | "project" | "plugin" | "installed";
filePath: string;
}
interface SkillListItemProps {
skill: SkillItem;
selected: boolean;
onSelect: () => void;
onDelete: (skill: SkillItem) => void;
}
export function SkillListItem({
skill,
selected,
onSelect,
onDelete,
}: SkillListItemProps) {
const [hovered, setHovered] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
if (confirmDelete) {
onDelete(skill);
setConfirmDelete(false);
} else {
setConfirmDelete(true);
// Auto-reset after 3 seconds
setTimeout(() => setConfirmDelete(false), 3000);
}
};
return (
<div
className={cn(
"group flex items-center gap-2 rounded-md px-3 py-2 cursor-pointer transition-colors",
selected
? "bg-accent text-accent-foreground"
: "hover:bg-accent/50"
)}
onClick={onSelect}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => {
setHovered(false);
setConfirmDelete(false);
}}
>
<HugeiconsIcon icon={ZapIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">/{skill.name}</span>
<Badge
variant="outline"
className={cn(
"text-[10px] px-1.5 py-0",
skill.source === "global"
? "border-green-500/40 text-green-600 dark:text-green-400"
: skill.source === "installed"
? "border-orange-500/40 text-orange-600 dark:text-orange-400"
: skill.source === "plugin"
? "border-purple-500/40 text-purple-600 dark:text-purple-400"
: "border-blue-500/40 text-blue-600 dark:text-blue-400"
)}
>
{skill.source === "global" ? (
<HugeiconsIcon icon={GlobeIcon} className="h-2.5 w-2.5 mr-0.5" />
) : skill.source === "installed" ? (
<HugeiconsIcon icon={Download04Icon} className="h-2.5 w-2.5 mr-0.5" />
) : skill.source === "plugin" ? (
<HugeiconsIcon icon={Plug01Icon} className="h-2.5 w-2.5 mr-0.5" />
) : (
<HugeiconsIcon icon={FolderOpenIcon} className="h-2.5 w-2.5 mr-0.5" />
)}
{skill.source}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">
{skill.description}
</p>
</div>
{(hovered || confirmDelete) && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={confirmDelete ? "destructive" : "ghost"}
size="icon-xs"
className="shrink-0"
onClick={handleDelete}
>
<HugeiconsIcon icon={Delete02Icon} className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{confirmDelete ? "Click again to confirm" : "Delete"}
</TooltipContent>
</Tooltip>
)}
</div>
);
}
+287
View File
@@ -0,0 +1,287 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { HugeiconsIcon } from "@hugeicons/react";
import { PlusSignIcon, Search01Icon, ZapIcon, Loading02Icon } from "@hugeicons/core-free-icons";
import { SkillListItem } from "./SkillListItem";
import { SkillEditor } from "./SkillEditor";
import { CreateSkillDialog } from "./CreateSkillDialog";
import type { SkillItem } from "./SkillListItem";
export function SkillsManager() {
const [skills, setSkills] = useState<SkillItem[]>([]);
const [selected, setSelected] = useState<SkillItem | null>(null);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [showCreate, setShowCreate] = useState(false);
const fetchSkills = useCallback(async () => {
try {
const res = await fetch("/api/skills");
if (res.ok) {
const data = await res.json();
setSkills(data.skills || []);
}
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchSkills();
}, [fetchSkills]);
const handleCreate = useCallback(
async (name: string, scope: "global" | "project", content: string) => {
const res = await fetch("/api/skills", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, content, scope }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to create skill");
}
const data = await res.json();
setSkills((prev) => [...prev, data.skill]);
setSelected(data.skill);
},
[]
);
const handleSave = useCallback(
async (name: string, content: string) => {
const res = await fetch(`/api/skills/${encodeURIComponent(name)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to save skill");
}
const data = await res.json();
// Update in list
setSkills((prev) =>
prev.map((s) => (s.name === name && s.source === data.skill.source ? data.skill : s))
);
// Update selected
setSelected(data.skill);
},
[]
);
const handleDelete = useCallback(
async (skill: SkillItem) => {
const res = await fetch(
`/api/skills/${encodeURIComponent(skill.name)}`,
{ method: "DELETE" }
);
if (res.ok) {
setSkills((prev) =>
prev.filter(
(s) => !(s.name === skill.name && s.source === skill.source)
)
);
if (selected?.name === skill.name && selected?.source === skill.source) {
setSelected(null);
}
}
},
[selected]
);
const filtered = skills.filter(
(s) =>
s.name.toLowerCase().includes(search.toLowerCase()) ||
s.description.toLowerCase().includes(search.toLowerCase())
);
const globalSkills = filtered.filter((s) => s.source === "global");
const projectSkills = filtered.filter((s) => s.source === "project");
const installedSkills = filtered.filter((s) => s.source === "installed");
const pluginSkills = filtered.filter((s) => s.source === "plugin");
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
Loading skills...
</span>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center gap-3 mb-4">
<h3 className="text-lg font-semibold flex-1">Skills</h3>
<Button size="sm" onClick={() => setShowCreate(true)} className="gap-1">
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
New Skill
</Button>
</div>
{/* Main content */}
<div className="flex gap-4 flex-1 min-h-0">
{/* Left: skill list */}
<div className="w-64 shrink-0 flex flex-col border border-border rounded-lg overflow-hidden">
<div className="p-2 border-b border-border">
<div className="relative">
<HugeiconsIcon icon={Search01Icon} className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search skills..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-7 h-8 text-sm"
/>
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-1">
{projectSkills.length > 0 && (
<div className="mb-1">
<span className="px-3 py-1 text-[10px] font-medium uppercase text-muted-foreground">
Project
</span>
{projectSkills.map((skill) => (
<SkillListItem
key={`${skill.source}:${skill.name}`}
skill={skill}
selected={
selected?.name === skill.name &&
selected?.source === skill.source
}
onSelect={() => setSelected(skill)}
onDelete={handleDelete}
/>
))}
</div>
)}
{globalSkills.length > 0 && (
<div className="mb-1">
<span className="px-3 py-1 text-[10px] font-medium uppercase text-muted-foreground">
Global
</span>
{globalSkills.map((skill) => (
<SkillListItem
key={`${skill.source}:${skill.name}`}
skill={skill}
selected={
selected?.name === skill.name &&
selected?.source === skill.source
}
onSelect={() => setSelected(skill)}
onDelete={handleDelete}
/>
))}
</div>
)}
{installedSkills.length > 0 && (
<div className="mb-1">
<span className="px-3 py-1 text-[10px] font-medium uppercase text-muted-foreground">
Installed
</span>
{installedSkills.map((skill) => (
<SkillListItem
key={`${skill.source}:${skill.name}`}
skill={skill}
selected={
selected?.name === skill.name &&
selected?.source === skill.source
}
onSelect={() => setSelected(skill)}
onDelete={handleDelete}
/>
))}
</div>
)}
{pluginSkills.length > 0 && (
<div className="mb-1">
<span className="px-3 py-1 text-[10px] font-medium uppercase text-muted-foreground">
Plugins
</span>
{pluginSkills.map((skill) => (
<SkillListItem
key={`${skill.source}:${skill.name}`}
skill={skill}
selected={
selected?.name === skill.name &&
selected?.source === skill.source
}
onSelect={() => setSelected(skill)}
onDelete={handleDelete}
/>
))}
</div>
)}
{filtered.length === 0 && (
<div className="flex flex-col items-center gap-2 py-8 text-muted-foreground">
<HugeiconsIcon icon={ZapIcon} className="h-8 w-8 opacity-40" />
<p className="text-xs">
{search ? "No skills match your search" : "No skills yet"}
</p>
{!search && (
<Button
variant="outline"
size="xs"
onClick={() => setShowCreate(true)}
className="gap-1"
>
<HugeiconsIcon icon={PlusSignIcon} className="h-3 w-3" />
Create one
</Button>
)}
</div>
)}
</div>
</ScrollArea>
</div>
{/* Right: editor */}
<div className="flex-1 min-w-0 border border-border rounded-lg overflow-hidden">
{selected ? (
<SkillEditor
key={`${selected.source}:${selected.name}`}
skill={selected}
onSave={handleSave}
onDelete={handleDelete}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
<HugeiconsIcon icon={ZapIcon} className="h-12 w-12 opacity-30" />
<div className="text-center">
<p className="text-sm font-medium">No skill selected</p>
<p className="text-xs">
Select a skill from the list or create a new one
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreate(true)}
className="gap-1"
>
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
New Skill
</Button>
</div>
)}
</div>
</div>
<CreateSkillDialog
open={showCreate}
onOpenChange={setShowCreate}
onCreate={handleCreate}
/>
</div>
);
}
+196
View File
@@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content 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 p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+66
View File
@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+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 [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
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(
"bg-muted flex items-center gap-2 rounded-md border 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(
"bg-input relative !m-0 self-stretch 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 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
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:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input 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 rounded-md gap-1.5 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 }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+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(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
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="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[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(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden 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(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground 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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
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(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
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(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_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-muted-foreground text-sm", 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(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 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 p-1 shadow-md",
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(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
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(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
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(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
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(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
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 border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"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-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
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 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
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(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md 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(
"text-muted-foreground flex items-center gap-2 text-sm [&_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(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+58
View File
@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+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(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
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(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 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 shadow-md",
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("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground 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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[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("bg-border pointer-events-none -mx-1 my-1 h-px", 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(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+143
View File
@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+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 }
+35
View File
@@ -0,0 +1,35 @@
"use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+91
View File
@@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+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(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
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(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }