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:
1 parent
6776cc6297
commit
ff1fb38735
149 files changed
+31117
-122
No files matched your search
@@ -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";
|
||||
@@ -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} />;
|
||||
@@ -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} />
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user