diff --git a/components/ai-elements/code-block.tsx b/components/ai-elements/code-block.tsx deleted file mode 100644 index bc1441a..0000000 --- a/components/ai-elements/code-block.tsx +++ /dev/null @@ -1,562 +0,0 @@ -"use client"; - -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 type { ComponentProps, CSSProperties, HTMLAttributes } from "react"; -import { - createContext, - memo, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import type { - BundledLanguage, - BundledTheme, - HighlighterGeneric, - ThemedToken, -} from "shiki"; -import { createHighlighter } from "shiki"; - -// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline -// oxlint-disable-next-line eslint(no-bitwise) -const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1; -// oxlint-disable-next-line eslint(no-bitwise) -const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2; -const isUnderline = (fontStyle: number | undefined) => - // 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 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" -); - -// 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; -}; - -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]); - - // Synchronous cache lookup — avoids setState in effect for cached results - const syncTokens = useMemo( - () => highlightCode(code, language) ?? rawTokens, - [code, language, rawTokens] - ); - - // Async highlighting result (populated after shiki loads) - const [asyncTokens, setAsyncTokens] = useState(null); - const asyncKeyRef = useRef({ code, language }); - - // Invalidate stale async tokens synchronously during render - if ( - asyncKeyRef.current.code !== code || - asyncKeyRef.current.language !== language - ) { - asyncKeyRef.current = { code, language }; - setAsyncTokens(null); - } - - useEffect(() => { - let cancelled = false; - - highlightCode(code, language, (result) => { - if (!cancelled) { - setAsyncTokens(result); - } - }); - - return () => { - cancelled = true; - }; - }, [code, language]); - - const tokenized = asyncTokens ?? syncTokens; - - 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 -) => + ), + img: ({ alt, ...props }: ComponentProps<"img">) => ( + // biome-ignore lint/nursery/noImgElement: model output, not a known asset + {alt + ), + li: (props: ComponentProps<"li">) =>
  • , + ol: (props: ComponentProps<"ol">) => ( +
      + ), + p: (props: ComponentProps<"p">) => ( +

      + ), + pre: (props: ComponentProps<"pre">) => ( +

      +  ),
      +  strong: (props: ComponentProps<"strong">) => (
      +    
      +  ),
      +  table: (props: ComponentProps<"table">) => (
      +    
      + + + ), + td: (props: ComponentProps<"td">) => ( +
      + ), + th: (props: ComponentProps<"th">) => ( + + ), + ul: (props: ComponentProps<"ul">) => ( +
        + ), +}); + +export type MarkdownProps = Omit, "children"> & { + children: string; + /** True while tokens are still arriving; enables incomplete-syntax repair. */ + isAnimating?: boolean; + linkSafety?: LinkSafetyConfig; +}; + +export const Markdown = memo( + ({ + children, + className, + isAnimating, + linkSafety, + ...props + }: MarkdownProps) => { + const [pending, setPending] = useState(null); + + const handleUntrusted = useCallback((url: string) => setPending(url), []); + + const content = useMemo(() => { + // While streaming, close syntax the model has not finished emitting so a + // partial `**bold` renders as bold rather than as literal asterisks. + const source = repair(children ?? "", isAnimating === true); + const tree = processor.runSync(processor.parse(source)) as Nodes; + + return toJsxRuntime(tree, { + components: buildComponents(linkSafety, handleUntrusted), + Fragment, + jsx, + jsxs, + }); + }, [children, isAnimating, linkSafety, handleUntrusted]); + + return ( +
        *:first-child]:mt-0 [&>*:last-child]:mb-0", + className + )} + {...props} + > + {content} + !open && setPending(null)} + open={pending !== null} + > + + + Leave this site? + + This link points somewhere outside the store. Continue only if + you trust it. + + +

        + {pending} +

        + + + + +
        +
        +
        + ); + }, + (prev, next) => + prev.children === next.children && prev.isAnimating === next.isAnimating +); + +Markdown.displayName = "Markdown"; diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx index cc0e62c..0906a23 100644 --- a/components/ai-elements/message.tsx +++ b/components/ai-elements/message.tsx @@ -12,23 +12,19 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import { cjk } from "@streamdown/cjk"; -import { code } from "@streamdown/code"; -import { math } from "@streamdown/math"; -import { mermaid } from "@streamdown/mermaid"; import type { UIMessage } from "ai"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; import { createContext, - memo, useCallback, useContext, useEffect, useMemo, useState, } from "react"; -import { Streamdown } from "streamdown"; + +import { Markdown } from "./markdown"; export type MessageProps = HTMLAttributes & { from: UIMessage["role"]; @@ -319,27 +315,10 @@ export const MessageBranchPage = ({ ); }; -export type MessageResponseProps = ComponentProps; +export type MessageResponseProps = ComponentProps; -const streamdownPlugins = { cjk, code, math, mermaid }; - -export const MessageResponse = memo( - ({ className, ...props }: MessageResponseProps) => ( - *:first-child]:mt-0 [&>*:last-child]:mb-0", - className - )} - plugins={streamdownPlugins} - {...props} - /> - ), - (prevProps, nextProps) => - prevProps.children === nextProps.children && - nextProps.isAnimating === prevProps.isAnimating -); - -MessageResponse.displayName = "MessageResponse"; +// Markdown already memoises on children/isAnimating. +export const MessageResponse = Markdown; export type MessageToolbarProps = ComponentProps<"div">; diff --git a/components/ai-elements/reasoning.tsx b/components/ai-elements/reasoning.tsx index b56adac..b940398 100644 --- a/components/ai-elements/reasoning.tsx +++ b/components/ai-elements/reasoning.tsx @@ -7,10 +7,6 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; -import { cjk } from "@streamdown/cjk"; -import { code } from "@streamdown/code"; -import { math } from "@streamdown/math"; -import { mermaid } from "@streamdown/mermaid"; import { BrainIcon, ChevronDownIcon } from "lucide-react"; import type { ComponentProps, ReactNode } from "react"; import { @@ -23,8 +19,8 @@ import { useRef, useState, } from "react"; -import { Streamdown } from "streamdown"; +import { Markdown } from "./markdown"; import { Shimmer } from "./shimmer"; interface ReasoningContextValue { @@ -204,21 +200,23 @@ export type ReasoningContentProps = ComponentProps< children: string; }; -const streamdownPlugins = { cjk, code, math, mermaid }; - export const ReasoningContent = memo( - ({ className, children, ...props }: ReasoningContentProps) => ( - - {children} - - ) + ({ className, children, ...props }: ReasoningContentProps) => { + const { isStreaming } = useReasoning(); + + return ( + + {children} + + ); + } ); Reasoning.displayName = "Reasoning"; diff --git a/components/ai-elements/tool.tsx b/components/ai-elements/tool.tsx deleted file mode 100644 index 18c4bb8..0000000 --- a/components/ai-elements/tool.tsx +++ /dev/null @@ -1,173 +0,0 @@ -"use client"; - -import { Badge } from "@/components/ui/badge"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { cn } from "@/lib/utils"; -import type { DynamicToolUIPart, ToolUIPart } from "ai"; -import { - CheckCircleIcon, - ChevronDownIcon, - CircleIcon, - ClockIcon, - WrenchIcon, - XCircleIcon, -} from "lucide-react"; -import type { ComponentProps, ReactNode } from "react"; -import { isValidElement } from "react"; - -import { CodeBlock } from "./code-block"; - -export type ToolProps = ComponentProps; - -export const Tool = ({ className, ...props }: ToolProps) => ( - -); - -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 = { - "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 = { - "approval-requested": , - "approval-responded": , - "input-available": , - "input-streaming": , - "output-available": , - "output-denied": , - "output-error": , -}; - -export const getStatusBadge = (status: ToolPart["state"]) => ( - - {statusIcons[status]} - {statusLabels[status]} - -); - -export const ToolHeader = ({ - className, - title, - type, - state, - toolName, - ...props -}: ToolHeaderProps) => { - const derivedName = - type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-"); - - return ( - -
        - - {title ?? derivedName} - {getStatusBadge(state)} -
        - -
        - ); -}; - -export type ToolContentProps = ComponentProps; - -export const ToolContent = ({ className, ...props }: ToolContentProps) => ( - -); - -export type ToolInputProps = ComponentProps<"div"> & { - input: ToolPart["input"]; -}; - -export const ToolInput = ({ className, input, ...props }: ToolInputProps) => ( -
        -

        - Parameters -

        -
        - -
        -
        -); - -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 =
        {output as ReactNode}
        ; - - if (typeof output === "object" && !isValidElement(output)) { - Output = ( - - ); - } else if (typeof output === "string") { - Output = ; - } - - return ( -
        -

        - {errorText ? "Error" : "Result"} -

        -
        - {errorText &&
        {errorText}
        } - {Output} -
        -
        - ); -}; diff --git a/components/shopify/account-form.tsx b/components/shopify/account-form.tsx index 47ca830..871ad74 100644 --- a/components/shopify/account-form.tsx +++ b/components/shopify/account-form.tsx @@ -4,7 +4,7 @@ import React, { useState } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { Button } from '@/components/ui/button'; -import { Loader } from '@/app/components/ui/loader'; +import { Loader } from '@/components/ui/loader'; export interface AccountFormField { name: string; diff --git a/components/shopify/cart-drawer.tsx b/components/shopify/cart-drawer.tsx index e175d04..81ace6f 100644 --- a/components/shopify/cart-drawer.tsx +++ b/components/shopify/cart-drawer.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react'; import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart'; import { Button } from '@/components/ui/button'; -import { Loader } from '@/app/components/ui/loader'; +import { Loader } from '@/components/ui/loader'; import { Sheet, SheetContent, diff --git a/components/shopify/collection-detail.tsx b/components/shopify/collection-detail.tsx index 5c588dd..2860d60 100644 --- a/components/shopify/collection-detail.tsx +++ b/components/shopify/collection-detail.tsx @@ -6,7 +6,7 @@ import ProductCard from './product-card'; import ProductFilters, { type ProductFilterFacet } from './product-filters'; import ProductToolbar from './product-toolbar'; import { Button } from '@/components/ui/button'; -import { Loader } from '@/app/components/ui/loader'; +import { Loader } from '@/components/ui/loader'; import { getCollectionProductsPage, type CollectionSortKey, diff --git a/components/shopify/product-detail/product-detail-info.tsx b/components/shopify/product-detail/product-detail-info.tsx index c701d95..aadea71 100644 --- a/components/shopify/product-detail/product-detail-info.tsx +++ b/components/shopify/product-detail/product-detail-info.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Button } from '@/components/ui/button'; -import { Loader } from '@/app/components/ui/loader'; +import { Loader } from '@/components/ui/loader'; import { RiSubtractLine, RiAddLine } from '@remixicon/react'; import ShopPayButton from '@/components/shopify/shop-pay-button'; import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products'; diff --git a/components/shopify/products.tsx b/components/shopify/products.tsx index 9d31568..7c643b3 100644 --- a/components/shopify/products.tsx +++ b/components/shopify/products.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react'; import ProductCard from './product-card'; import { getProductsPage } from '@/hooks/use-shopify-products'; import { Button } from '@/components/ui/button'; -import { Loader } from '@/app/components/ui/loader'; +import { Loader } from '@/components/ui/loader'; interface ProductImage { url: string; diff --git a/components/shopify/search-dialog.tsx b/components/shopify/search-dialog.tsx index 9bb12f1..b4fc655 100644 --- a/components/shopify/search-dialog.tsx +++ b/components/shopify/search-dialog.tsx @@ -11,7 +11,7 @@ import { CommandItem, } from '@/components/ui/command'; import { Button } from '@/components/ui/button'; -import { Loader } from '@/app/components/ui/loader'; +import { Loader } from '@/components/ui/loader'; import { RiSearchLine, RiImageLine, RiCloseLine } from '@remixicon/react'; import { searchSuggestions, @@ -111,7 +111,7 @@ const SearchDialog: React.FC = () => { if (event.key === 'Enter') goToSearchPage(); }} /> -
        +
        {hasQuery && (