diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1d57a670b7..809a353d6b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,5 +21,5 @@ jobs: cache: "pnpm" - name: Install dependencies run: pnpm install - - name: Run lint - run: pnpm lint + - name: Run check + run: pnpm check diff --git a/.gitignore b/.gitignore index 864e97de91..05bfb89a1e 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ yarn-error.log* /playwright-report/ /blob-report/ /playwright/* + +next-env.d.ts \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 66db5da925..3179bac86f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,7 +1,9 @@ +import { Analytics } from "@vercel/analytics/next"; import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Toaster } from "sonner"; import { ThemeProvider } from "@/components/theme-provider"; +import "katex/dist/katex.min.css"; import "./globals.css"; import { SessionProvider } from "next-auth/react"; @@ -81,6 +83,7 @@ export default function RootLayout({ {children} + ); diff --git a/biome.jsonc b/biome.jsonc index dbaebba800..af049a9048 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -11,13 +11,9 @@ }, "files": { "includes": [ - "**/*.ts", - "**/*.tsx", - "**/*.js", - "**/*.jsx", - "!node_modules", - "!.next", - "!ai-sdk", + "**/*", + "!components/ai-elements", + "!components/elements", "!components/ui", "!lib/utils.ts", "!hooks/use-mobile.ts" diff --git a/components.json b/components.json index 388ec17744..9b95b037a4 100644 --- a/components.json +++ b/components.json @@ -1,6 +1,6 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "default", + "style": "new-york", "rsc": true, "tsx": true, "tailwind": { diff --git a/components/ai-elements/artifact.tsx b/components/ai-elements/artifact.tsx deleted file mode 100644 index 0aaeaec095..0000000000 --- a/components/ai-elements/artifact.tsx +++ /dev/null @@ -1,147 +0,0 @@ -"use client"; - -import { type LucideIcon, XIcon } 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"; - -export type ArtifactProps = HTMLAttributes; - -export const Artifact = ({ className, ...props }: ArtifactProps) => ( -
-); - -export type ArtifactHeaderProps = HTMLAttributes; - -export const ArtifactHeader = ({ - className, - ...props -}: ArtifactHeaderProps) => ( -
-); - -export type ArtifactCloseProps = ComponentProps; - -export const ArtifactClose = ({ - className, - children, - size = "sm", - variant = "ghost", - ...props -}: ArtifactCloseProps) => ( - -); - -export type ArtifactTitleProps = HTMLAttributes; - -export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => ( -

-); - -export type ArtifactDescriptionProps = HTMLAttributes; - -export const ArtifactDescription = ({ - className, - ...props -}: ArtifactDescriptionProps) => ( -

-); - -export type ArtifactActionsProps = HTMLAttributes; - -export const ArtifactActions = ({ - className, - ...props -}: ArtifactActionsProps) => ( -

-); - -export type ArtifactActionProps = ComponentProps & { - tooltip?: string; - label?: string; - icon?: LucideIcon; -}; - -export const ArtifactAction = ({ - tooltip, - label, - icon: Icon, - children, - className, - size = "sm", - variant = "ghost", - ...props -}: ArtifactActionProps) => { - const button = ( - - ); - - if (tooltip) { - return ( - - - {button} - -

{tooltip}

-
-
-
- ); - } - - return button; -}; - -export type ArtifactContentProps = HTMLAttributes; - -export const ArtifactContent = ({ - className, - ...props -}: ArtifactContentProps) => ( -
-); diff --git a/components/ai-elements/canvas.tsx b/components/ai-elements/canvas.tsx deleted file mode 100644 index 5aa83cb5e7..0000000000 --- a/components/ai-elements/canvas.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react"; -import type { ReactNode } from "react"; -import "@xyflow/react/dist/style.css"; - -type CanvasProps = ReactFlowProps & { - children?: ReactNode; -}; - -export const Canvas = ({ children, ...props }: CanvasProps) => ( - - - {children} - -); diff --git a/components/ai-elements/chain-of-thought.tsx b/components/ai-elements/chain-of-thought.tsx deleted file mode 100644 index 11f2323c1b..0000000000 --- a/components/ai-elements/chain-of-thought.tsx +++ /dev/null @@ -1,231 +0,0 @@ -"use client"; - -import { useControllableState } from "@radix-ui/react-use-controllable-state"; -import { - BrainIcon, - ChevronDownIcon, - DotIcon, - type LucideIcon, -} from "lucide-react"; -import type { ComponentProps, ReactNode } from "react"; -import { createContext, memo, useContext, useMemo } from "react"; -import { Badge } from "@/components/ui/badge"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { cn } from "@/lib/utils"; - -type ChainOfThoughtContextValue = { - isOpen: boolean; - setIsOpen: (open: boolean) => void; -}; - -const ChainOfThoughtContext = createContext( - 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({ - prop: open, - defaultProp: defaultOpen, - onChange: onOpenChange, - }); - - const chainOfThoughtContext = useMemo( - () => ({ isOpen, setIsOpen }), - [isOpen, setIsOpen] - ); - - return ( - -
- {children} -
-
- ); - } -); - -export type ChainOfThoughtHeaderProps = ComponentProps< - typeof CollapsibleTrigger ->; - -export const ChainOfThoughtHeader = memo( - ({ className, children, ...props }: ChainOfThoughtHeaderProps) => { - const { isOpen, setIsOpen } = useChainOfThought(); - - return ( - - - - - {children ?? "Chain of Thought"} - - - - - ); - } -); - -export type ChainOfThoughtStepProps = ComponentProps<"div"> & { - icon?: LucideIcon; - label: ReactNode; - description?: ReactNode; - status?: "complete" | "active" | "pending"; -}; - -export const ChainOfThoughtStep = memo( - ({ - className, - icon: Icon = DotIcon, - label, - description, - status = "complete", - children, - ...props - }: ChainOfThoughtStepProps) => { - const statusStyles = { - complete: "text-muted-foreground", - active: "text-foreground", - pending: "text-muted-foreground/50", - }; - - return ( -
-
- -
-
-
-
{label}
- {description && ( -
{description}
- )} - {children} -
-
- ); - } -); - -export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">; - -export const ChainOfThoughtSearchResults = memo( - ({ className, ...props }: ChainOfThoughtSearchResultsProps) => ( -
- ) -); - -export type ChainOfThoughtSearchResultProps = ComponentProps; - -export const ChainOfThoughtSearchResult = memo( - ({ className, children, ...props }: ChainOfThoughtSearchResultProps) => ( - - {children} - - ) -); - -export type ChainOfThoughtContentProps = ComponentProps< - typeof CollapsibleContent ->; - -export const ChainOfThoughtContent = memo( - ({ className, children, ...props }: ChainOfThoughtContentProps) => { - const { isOpen } = useChainOfThought(); - - return ( - - - {children} - - - ); - } -); - -export type ChainOfThoughtImageProps = ComponentProps<"div"> & { - caption?: string; -}; - -export const ChainOfThoughtImage = memo( - ({ className, children, caption, ...props }: ChainOfThoughtImageProps) => ( -
-
- {children} -
- {caption &&

{caption}

} -
- ) -); - -ChainOfThought.displayName = "ChainOfThought"; -ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader"; -ChainOfThoughtStep.displayName = "ChainOfThoughtStep"; -ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults"; -ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult"; -ChainOfThoughtContent.displayName = "ChainOfThoughtContent"; -ChainOfThoughtImage.displayName = "ChainOfThoughtImage"; diff --git a/components/ai-elements/checkpoint.tsx b/components/ai-elements/checkpoint.tsx deleted file mode 100644 index 80a7a2ebd3..0000000000 --- a/components/ai-elements/checkpoint.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import { BookmarkIcon, type LucideProps } from "lucide-react"; -import type { ComponentProps, HTMLAttributes } from "react"; -import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; - -export type CheckpointProps = HTMLAttributes; - -export const Checkpoint = ({ - className, - children, - ...props -}: CheckpointProps) => ( -
- {children} - -
-); - -export type CheckpointIconProps = LucideProps; - -export const CheckpointIcon = ({ - className, - children, - ...props -}: CheckpointIconProps) => - children ?? ( - - ); - -export type CheckpointTriggerProps = ComponentProps & { - tooltip?: string; -}; - -export const CheckpointTrigger = ({ - children, - className, - variant = "ghost", - size = "sm", - tooltip, - ...props -}: CheckpointTriggerProps) => - tooltip ? ( - - - - - - {tooltip} - - - ) : ( - - ); diff --git a/components/ai-elements/code-block.tsx b/components/ai-elements/code-block.tsx new file mode 100644 index 0000000000..164f5e3da5 --- /dev/null +++ b/components/ai-elements/code-block.tsx @@ -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 }) => ( + + {token.content} + +); + +// Line rendering component +const LineSpan = ({ + keyedLine, + showLineNumbers, +}: { + keyedLine: KeyedLine; + showLineNumbers: boolean; +}) => ( + + {keyedLine.tokens.length === 0 + ? "\n" + : keyedLine.tokens.map(({ token, key }) => ( + + ))} + +); + +// Types +type CodeBlockProps = HTMLAttributes & { + code: string; + language: BundledLanguage; + showLineNumbers?: boolean; +}; + +interface TokenizedCode { + tokens: ThemedToken[][]; + fg: string; + bg: string; +} + +interface CodeBlockContextType { + code: string; +} + +// Context +const CodeBlockContext = createContext({ + code: "", +}); + +// Highlighter cache (singleton per language) +const highlighterCache = new Map< + string, + Promise> +>(); + +// Token cache +const tokensCache = new Map(); + +// Subscribers for async token updates +const subscribers = new Map 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> => { + 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 ( +
+        
+          {keyedLines.map((keyedLine) => (
+            
+          ))}
+        
+      
+ ); + }, + (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 & { language: string }) => ( +
+); + +export const CodeBlockHeader = ({ + children, + className, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); + +export const CodeBlockTitle = ({ + children, + className, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); + +export const CodeBlockFilename = ({ + children, + className, + ...props +}: HTMLAttributes) => ( + + {children} + +); + +export const CodeBlockActions = ({ + children, + className, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); + +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( + () => 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 ( +
+ +
+ ); +}; + +export const CodeBlock = ({ + code, + language, + showLineNumbers = false, + className, + children, + ...props +}: CodeBlockProps) => { + const contextValue = useMemo(() => ({ code }), [code]); + + return ( + + + {children} + + + + ); +}; + +export type CodeBlockCopyButtonProps = ComponentProps & { + 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(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 ( + + ); +}; + +export type CodeBlockLanguageSelectorProps = ComponentProps; + +export const CodeBlockLanguageSelector = ( + props: CodeBlockLanguageSelectorProps +) => - ); -}; - -export type WebPreviewBodyProps = ComponentProps<"iframe"> & { - loading?: ReactNode; -}; - -export const WebPreviewBody = ({ - className, - loading, - src, - ...props -}: WebPreviewBodyProps) => { - const { url } = useWebPreview(); - - return ( -
-