Initial commit

This commit is contained in:
xggz
2026-03-06 22:56:13 +08:00
commit 54d1097b41
273 changed files with 92457 additions and 0 deletions
+564
View File
@@ -0,0 +1,564 @@
"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
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1
// biome-ignore lint/suspicious/noBitwiseOperators: 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])
// Synchronous cached-or-raw value, recomputed when code/language changes
const syncTokenized = useMemo(
() => highlightCode(code, language) ?? rawTokens,
[code, language, rawTokens]
)
// Async highlighted result, tagged with its source code/language
const [asyncState, setAsyncState] = useState<{
code: string
language: string
tokenized: TokenizedCode
} | null>(null)
useEffect(() => {
let cancelled = false
// Subscribe to async highlighting result
highlightCode(code, language, (result) => {
if (!cancelled) {
setAsyncState({ code, language, tokenized: result })
}
})
return () => {
cancelled = true
}
}, [code, language])
// Use async result only if it matches current code/language
const tokenized =
asyncState?.code === code && asyncState?.language === language
? asyncState.tokenized
: syncTokenized
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} />
+449
View File
@@ -0,0 +1,449 @@
"use client"
import type {
ComponentProps,
HTMLAttributes,
KeyboardEvent,
MouseEvent,
} from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import {
CheckIcon,
CopyIcon,
FileIcon,
GitCommitIcon,
MinusIcon,
PlusIcon,
} from "lucide-react"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
export type CommitProps = ComponentProps<typeof Collapsible>
export const Commit = ({ className, children, ...props }: CommitProps) => (
<Collapsible
className={cn("rounded-lg border bg-background", className)}
{...props}
>
{children}
</Collapsible>
)
export type CommitHeaderProps = ComponentProps<typeof CollapsibleTrigger>
export const CommitHeader = ({
className,
children,
...props
}: CommitHeaderProps) => (
<CollapsibleTrigger asChild {...props}>
<div
className={cn(
"group flex cursor-pointer items-center justify-between gap-4 p-3 text-left transition-colors hover:opacity-80",
className
)}
>
{children}
</div>
</CollapsibleTrigger>
)
export type CommitHashProps = HTMLAttributes<HTMLSpanElement>
export const CommitHash = ({
className,
children,
...props
}: CommitHashProps) => (
<span className={cn("font-mono text-xs", className)} {...props}>
<GitCommitIcon className="mr-1 inline-block size-3" />
{children}
</span>
)
export type CommitMessageProps = HTMLAttributes<HTMLSpanElement>
export const CommitMessage = ({
className,
children,
...props
}: CommitMessageProps) => (
<span className={cn("font-medium text-sm", className)} {...props}>
{children}
</span>
)
export type CommitMetadataProps = HTMLAttributes<HTMLDivElement>
export const CommitMetadata = ({
className,
children,
...props
}: CommitMetadataProps) => (
<div
className={cn(
"flex items-center gap-2 text-muted-foreground text-xs",
className
)}
{...props}
>
{children}
</div>
)
export type CommitSeparatorProps = HTMLAttributes<HTMLSpanElement>
export const CommitSeparator = ({
className,
children,
...props
}: CommitSeparatorProps) => (
<span className={className} {...props}>
{children ?? "•"}
</span>
)
export type CommitInfoProps = HTMLAttributes<HTMLDivElement>
export const CommitInfo = ({
className,
children,
...props
}: CommitInfoProps) => (
<div className={cn("flex flex-1 flex-col", className)} {...props}>
{children}
</div>
)
export type CommitAuthorProps = HTMLAttributes<HTMLDivElement>
export const CommitAuthor = ({
className,
children,
...props
}: CommitAuthorProps) => (
<div className={cn("flex items-center", className)} {...props}>
{children}
</div>
)
export type CommitAuthorAvatarProps = ComponentProps<typeof Avatar> & {
initials: string
}
export const CommitAuthorAvatar = ({
initials,
className,
...props
}: CommitAuthorAvatarProps) => (
<Avatar className={cn("size-8", className)} {...props}>
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
</Avatar>
)
export type CommitTimestampProps = HTMLAttributes<HTMLTimeElement> & {
date: Date
}
export const CommitTimestamp = ({
date,
className,
children,
...props
}: CommitTimestampProps) => {
const formatted = date.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
})
return (
<time
className={cn("text-xs", className)}
dateTime={date.toISOString()}
{...props}
>
{children ?? formatted}
</time>
)
}
export type CommitActionsProps = HTMLAttributes<HTMLDivElement>
const handleActionsClick = (e: MouseEvent) => e.stopPropagation()
const handleActionsKeyDown = (e: KeyboardEvent) => e.stopPropagation()
export const CommitActions = ({
className,
children,
...props
}: CommitActionsProps) => (
<div
className={cn("flex items-center gap-1", className)}
onClick={handleActionsClick}
onKeyDown={handleActionsKeyDown}
role="group"
{...props}
>
{children}
</div>
)
export type CommitCopyButtonProps = ComponentProps<typeof Button> & {
hash: string
onCopy?: () => void
onError?: (error: Error) => void
timeout?: number
}
export const CommitCopyButton = ({
hash,
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CommitCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false)
const timeoutRef = useRef<number>(0)
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(hash)
setIsCopied(true)
onCopy?.()
timeoutRef.current = window.setTimeout(
() => setIsCopied(false),
timeout
)
}
} catch (error) {
onError?.(error as Error)
}
}, [hash, onCopy, onError, timeout, isCopied])
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current)
},
[]
)
const Icon = isCopied ? CheckIcon : CopyIcon
return (
<Button
className={cn("size-7 shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
)
}
export type CommitContentProps = ComponentProps<typeof CollapsibleContent>
export const CommitContent = ({
className,
children,
...props
}: CommitContentProps) => (
<CollapsibleContent className={cn("border-t p-3", className)} {...props}>
{children}
</CollapsibleContent>
)
export type CommitFilesProps = HTMLAttributes<HTMLDivElement>
export const CommitFiles = ({
className,
children,
...props
}: CommitFilesProps) => (
<div className={cn("space-y-1", className)} {...props}>
{children}
</div>
)
export type CommitFileProps = HTMLAttributes<HTMLDivElement>
export const CommitFile = ({
className,
children,
...props
}: CommitFileProps) => (
<div
className={cn(
"flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50",
className
)}
{...props}
>
{children}
</div>
)
export type CommitFileInfoProps = HTMLAttributes<HTMLDivElement>
export const CommitFileInfo = ({
className,
children,
...props
}: CommitFileInfoProps) => (
<div className={cn("flex min-w-0 items-center gap-2", className)} {...props}>
{children}
</div>
)
const fileStatusStyles = {
added: "text-green-600 dark:text-green-400",
deleted: "text-red-600 dark:text-red-400",
modified: "text-yellow-600 dark:text-yellow-400",
renamed: "text-blue-600 dark:text-blue-400",
}
const fileStatusLabels = {
added: "A",
deleted: "D",
modified: "M",
renamed: "R",
}
export type CommitFileStatusProps = HTMLAttributes<HTMLSpanElement> & {
status: "added" | "modified" | "deleted" | "renamed"
}
export const CommitFileStatus = ({
status,
className,
children,
...props
}: CommitFileStatusProps) => (
<span
className={cn(
"font-medium font-mono text-xs",
fileStatusStyles[status],
className
)}
{...props}
>
{children ?? fileStatusLabels[status]}
</span>
)
export type CommitFileIconProps = ComponentProps<typeof FileIcon>
export const CommitFileIcon = ({
className,
...props
}: CommitFileIconProps) => (
<FileIcon
className={cn("size-3.5 shrink-0 text-muted-foreground", className)}
{...props}
/>
)
export type CommitFilePathProps = HTMLAttributes<HTMLSpanElement>
export const CommitFilePath = ({
className,
children,
...props
}: CommitFilePathProps) => (
<span className={cn("truncate font-mono text-xs", className)} {...props}>
{children}
</span>
)
export type CommitFileChangesProps = HTMLAttributes<HTMLDivElement>
export const CommitFileChanges = ({
className,
children,
...props
}: CommitFileChangesProps) => (
<div
className={cn(
"flex shrink-0 items-center gap-1 font-mono text-xs",
className
)}
{...props}
>
{children}
</div>
)
export type CommitFileAdditionsProps = HTMLAttributes<HTMLSpanElement> & {
count: number
}
export const CommitFileAdditions = ({
count,
className,
children,
...props
}: CommitFileAdditionsProps) => {
if (count <= 0) {
return null
}
return (
<span
className={cn("text-green-600 dark:text-green-400", className)}
{...props}
>
{children ?? (
<>
<PlusIcon className="inline-block size-3" />
{count}
</>
)}
</span>
)
}
export type CommitFileDeletionsProps = HTMLAttributes<HTMLSpanElement> & {
count: number
}
export const CommitFileDeletions = ({
count,
className,
children,
...props
}: CommitFileDeletionsProps) => {
if (count <= 0) {
return null
}
return (
<span
className={cn("text-red-600 dark:text-red-400", className)}
{...props}
>
{children ?? (
<>
<MinusIcon className="inline-block size-3" />
{count}
</>
)}
</span>
)
}
+322
View File
@@ -0,0 +1,322 @@
"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 = Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> & {
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="w-max min-w-full 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
nameClassName?: string
iconClassName?: string
suffix?: ReactNode
suffixClassName?: string
}
export const FileTreeFolder = ({
path,
name,
nameClassName,
iconClassName,
suffix,
suffixClassName,
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)}
aria-selected={isSelected}
role="treeitem"
tabIndex={0}
{...props}
>
<CollapsibleTrigger asChild>
<button
className={cn(
"flex w-max min-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={cn("size-4 text-blue-500", iconClassName)}
/>
) : (
<FolderIcon
className={cn("size-4 text-blue-500", iconClassName)}
/>
)}
</FileTreeIcon>
<FileTreeName className={nameClassName}>{name}</FileTreeName>
{suffix ? (
<span
className={cn(
"ml-1 shrink-0 whitespace-nowrap text-muted-foreground/60",
suffixClassName
)}
>
{suffix}
</span>
) : null}
</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 w-max min-w-full 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}
aria-selected={isSelected}
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("shrink-0 whitespace-nowrap", 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,166 @@
"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 MessageThreadProps = ComponentProps<typeof StickToBottom>
export const MessageThread = ({ className, ...props }: MessageThreadProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
)
export type MessageThreadContentProps = ComponentProps<
typeof StickToBottom.Content
>
export const MessageThreadContent = ({
className,
...props
}: MessageThreadContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
)
export type MessageThreadEmptyStateProps = ComponentProps<"div"> & {
title?: string
description?: string
icon?: React.ReactNode
}
export const MessageThreadEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: MessageThreadEmptyStateProps) => (
<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 MessageThreadScrollButtonProps = ComponentProps<typeof Button>
export const MessageThreadScrollButton = ({
className,
...props
}: MessageThreadScrollButtonProps) => {
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 ThreadMessage {
role: "user" | "assistant" | "system" | "data" | "tool"
content: string
}
export type MessageThreadDownloadProps = Omit<
ComponentProps<typeof Button>,
"onClick"
> & {
messages: ThreadMessage[]
filename?: string
formatMessage?: (message: ThreadMessage, index: number) => string
}
const defaultFormatMessage = (message: ThreadMessage): string => {
const roleLabel = message.role.charAt(0).toUpperCase() + message.role.slice(1)
return `**${roleLabel}:** ${message.content}`
}
export const messagesToMarkdown = (
messages: ThreadMessage[],
formatMessage: (
message: ThreadMessage,
index: number
) => string = defaultFormatMessage
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n")
export const MessageThreadDownload = ({
messages,
filename = "conversation.md",
formatMessage = defaultFormatMessage,
className,
children,
...props
}: MessageThreadDownloadProps) => {
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>
)
}
+358
View File
@@ -0,0 +1,358 @@
"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 flex-col gap-2",
from === "user"
? "is-user ml-auto justify-end max-w-[80%]"
: "is-assistant",
className
)}
{...props}
/>
)
export type MessageContentProps = HTMLAttributes<HTMLDivElement>
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex min-w-0 flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:w-fit group-[.is-user]:max-w-full 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]:w-full 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>
)
+229
View File
@@ -0,0 +1,229 @@
"use client"
import type { ComponentProps, ReactNode } from "react"
import { useControllableState } from "@radix-ui/react-use-controllable-state"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
import { cjk } from "@streamdown/cjk"
import { code } from "@streamdown/code"
import { math } from "@streamdown/math"
import { mermaid } from "@streamdown/mermaid"
import { BrainIcon, ChevronDownIcon } from "lucide-react"
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { Streamdown } from "streamdown"
import { Shimmer } from "./shimmer"
interface ReasoningContextValue {
isStreaming: boolean
isOpen: boolean
setIsOpen: (open: boolean) => void
duration: number | undefined
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null)
export const useReasoning = () => {
const context = useContext(ReasoningContext)
if (!context) {
throw new Error("Reasoning components must be used within Reasoning")
}
return context
}
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
duration?: number
}
const AUTO_CLOSE_DELAY = 1000
const MS_IN_S = 1000
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const resolvedDefaultOpen = defaultOpen ?? isStreaming
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
const isExplicitlyClosed = defaultOpen === false
const [isOpen, setIsOpen] = useControllableState<boolean>({
defaultProp: resolvedDefaultOpen,
onChange: onOpenChange,
prop: open,
})
const [duration, setDuration] = useControllableState<number | undefined>({
defaultProp: undefined,
prop: durationProp,
})
const hasEverStreamedRef = useRef(isStreaming)
const [hasAutoClosed, setHasAutoClosed] = useState(false)
const startTimeRef = useRef<number | null>(null)
// Track when streaming starts and compute duration
useEffect(() => {
if (isStreaming) {
hasEverStreamedRef.current = true
if (startTimeRef.current === null) {
startTimeRef.current = Date.now()
}
} else if (startTimeRef.current !== null) {
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S))
startTimeRef.current = null
}
}, [isStreaming, setDuration])
// Auto-open when streaming starts (unless explicitly closed)
useEffect(() => {
if (isStreaming && !isOpen && !isExplicitlyClosed) {
setIsOpen(true)
}
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed])
// Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => {
if (
hasEverStreamedRef.current &&
!isStreaming &&
isOpen &&
!hasAutoClosed
) {
const timer = setTimeout(() => {
setIsOpen(false)
setHasAutoClosed(true)
}, AUTO_CLOSE_DELAY)
return () => clearTimeout(timer)
}
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed])
const handleOpenChange = useCallback(
(newOpen: boolean) => {
setIsOpen(newOpen)
},
[setIsOpen]
)
const contextValue = useMemo(
() => ({ duration, isOpen, isStreaming, setIsOpen }),
[duration, isOpen, isStreaming, setIsOpen]
)
return (
<ReasoningContext.Provider value={contextValue}>
<Collapsible
className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
)
}
)
export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode
}
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>
}
return <p>Thought for {duration} seconds</p>
}
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning()
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</>
)}
</CollapsibleTrigger>
)
}
)
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string
}
const streamdownPlugins = { cjk, code, math, mermaid }
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<Streamdown plugins={streamdownPlugins} {...props}>
{children}
</Streamdown>
</CollapsibleContent>
)
)
Reasoning.displayName = "Reasoning"
ReasoningTrigger.displayName = "ReasoningTrigger"
ReasoningContent.displayName = "ReasoningContent"
+80
View File
@@ -0,0 +1,80 @@
"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 = useMemo(
() => getMotionComponent(Component as keyof JSX.IntrinsicElements),
[Component]
)
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread]
)
return (
// eslint-disable-next-line react-hooks/static-components -- component is cached at module level via motionComponentCache
<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)
+311
View File
@@ -0,0 +1,311 @@
"use client"
import type { ComponentProps, HTMLAttributes } from "react"
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 { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { Shimmer } from "./shimmer"
interface TerminalContextType {
output: string
isStreaming: boolean
autoScroll: boolean
onClear?: () => void
}
const TerminalContext = createContext<TerminalContextType>({
output: "",
isStreaming: false,
autoScroll: true,
})
function normalizeTerminalOutput(output: string): string {
if (!output) return output
// Some runtimes deliver ANSI escapes as literal "\\u001b"/"\\x1b".
// Decode them so ansi-to-react can apply terminal colors.
const hasEscapedAnsi =
output.includes("\\u001b") ||
output.includes("\\u001B") ||
output.includes("\\x1b") ||
output.includes("\\x1B") ||
output.includes("\\u009b") ||
output.includes("\\u009B")
if (!hasEscapedAnsi) return output
return output
.replace(/\\u001b/gi, "\u001b")
.replace(/\\x1b/gi, "\u001b")
.replace(/\\u009b/gi, "\u009b")
}
export type TerminalProps = HTMLAttributes<HTMLDivElement> & {
output: string
isStreaming?: boolean
autoScroll?: boolean
onClear?: () => void
}
export function Terminal({
output,
isStreaming = false,
autoScroll = true,
onClear,
className,
children,
...props
}: TerminalProps) {
const contextValue = useMemo(
() => ({ output, isStreaming, autoScroll, onClear }),
[output, isStreaming, autoScroll, onClear]
)
return (
<TerminalContext.Provider value={contextValue}>
<div
className={cn(
"flex flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground",
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 function TerminalHeader({
className,
children,
...props
}: TerminalHeaderProps) {
return (
<div
className={cn(
"flex items-center justify-between border-b border-border bg-muted/30 px-4 py-2",
className
)}
{...props}
>
{children}
</div>
)
}
export type TerminalTitleProps = HTMLAttributes<HTMLDivElement>
export function TerminalTitle({
className,
children,
...props
}: TerminalTitleProps) {
return (
<div
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground",
className
)}
{...props}
>
<TerminalIcon className="size-4" />
{children ?? "Terminal"}
</div>
)
}
export type TerminalStatusProps = HTMLAttributes<HTMLDivElement>
export function 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-muted-foreground",
className
)}
{...props}
>
{children ?? <Shimmer>Running</Shimmer>}
</div>
)
}
export type TerminalActionsProps = HTMLAttributes<HTMLDivElement>
export function TerminalActions({
className,
children,
...props
}: TerminalActionsProps) {
return (
<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 function 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-muted-foreground hover:bg-accent hover:text-accent-foreground",
className
)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
)
}
export type TerminalClearButtonProps = ComponentProps<typeof Button>
export function TerminalClearButton({
children,
className,
...props
}: TerminalClearButtonProps) {
const { onClear } = useContext(TerminalContext)
if (!onClear) {
return null
}
return (
<Button
className={cn(
"size-7 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
className
)}
onClick={onClear}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Trash2Icon size={14} />}
</Button>
)
}
export type TerminalContentProps = HTMLAttributes<HTMLDivElement>
export function TerminalContent({
className,
children,
...props
}: TerminalContentProps) {
const { output, isStreaming, autoScroll } = useContext(TerminalContext)
const normalizedOutput = useMemo(
() => normalizeTerminalOutput(output),
[output]
)
const containerRef = useRef<HTMLDivElement>(null)
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>{normalizedOutput}</Ansi>
{isStreaming && (
<span className="ml-0.5 inline-block h-4 w-2 animate-pulse bg-foreground" />
)}
</pre>
)}
</div>
)
}
+359
View File
@@ -0,0 +1,359 @@
"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 mb-4 w-full rounded-md border", className)}
{...props}
/>
)
export type ToolPart = ToolUIPart | DynamicToolUIPart
export type ToolHeaderProps = {
title?: ReactNode
titleSuffix?: ReactNode
icon?: ReactNode
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,
titleSuffix,
icon,
type,
state,
toolName,
...props
}: ToolHeaderProps) => {
const derivedName =
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-")
return (
<CollapsibleTrigger
className={cn(
"flex w-full min-w-0 items-center justify-between gap-4 p-3",
className
)}
{...props}
>
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0">
{icon ?? <WrenchIcon className="size-4 text-muted-foreground" />}
</span>
<span className="min-w-0 flex-1 truncate whitespace-nowrap font-medium text-sm">
{title ?? derivedName}
</span>
{titleSuffix ? <span className="shrink-0">{titleSuffix}</span> : null}
<span className="shrink-0">{getStatusBadge(state)}</span>
</div>
<ChevronDownIcon className="size-4 shrink-0 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) => {
const formattedCode = (() => {
if (typeof input === "string") {
try {
const parsed = JSON.parse(input)
return JSON.stringify(parsed, null, 2)
} catch {
return input
}
}
return JSON.stringify(input, null, 2)
})()
return (
<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={formattedCode} language="json" />
</div>
</div>
)
}
function detectOutputLanguage(text: string) {
const trimmed = text.trimStart()
if (
(trimmed.startsWith("{") || trimmed.startsWith("[")) &&
(() => {
try {
JSON.parse(trimmed)
return true
} catch {
return false
}
})()
) {
return "json" as const
}
if (trimmed.includes("diff --git") || trimmed.includes("@@")) {
return "diff" as const
}
if (trimmed.startsWith("<")) {
return "xml" as const
}
return "log" as const
}
const ERROR_LIKE_KEYS = [
"error",
"message",
"stderr",
"detail",
"details",
"reason",
"text",
"output",
"formatted_output",
"aggregated_output",
"result",
]
function stripErrorPrefix(text: string): string {
return text
.trim()
.replace(/^error:\s*/i, "")
.trim()
}
function normalizeErrorForCompare(text: string): string {
return stripErrorPrefix(text).replace(/\s+/g, " ")
}
function collectErrorCandidates(value: unknown): string[] {
if (!value) {
return []
}
if (typeof value === "string") {
return [value]
}
if (Array.isArray(value)) {
return value.flatMap((item) => collectErrorCandidates(item))
}
if (typeof value !== "object") {
return []
}
const obj = value as Record<string, unknown>
const candidates: string[] = []
for (const key of ERROR_LIKE_KEYS) {
if (!(key in obj)) continue
candidates.push(...collectErrorCandidates(obj[key]))
}
return candidates
}
function parseJson(value: string): unknown | null {
try {
return JSON.parse(value)
} catch {
return null
}
}
function formatErrorFieldValue(value: unknown): string {
if (typeof value === "string") {
return value
}
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
function renderErrorText(errorText: string): ReactNode {
const parsed = parseJson(errorText.trim())
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const entries = Object.entries(parsed as Record<string, unknown>)
if (entries.length > 0) {
return (
<div className="space-y-2 p-3">
{entries.map(([key, value]) => (
<div key={key} className="space-y-1">
<div className="text-[11px] font-medium uppercase tracking-wide text-destructive/80">
{key}
</div>
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-destructive">
{formatErrorFieldValue(value)}
</pre>
</div>
))}
</div>
)
}
}
return (
<pre className="whitespace-pre-wrap break-words p-3 font-mono text-xs text-destructive">
{errorText}
</pre>
)
}
function isDuplicateErrorOutput(
output: ToolPart["output"],
normalizedErrorText: string | null
): boolean {
if (!normalizedErrorText || !output) {
return false
}
const rawCandidates: string[] = []
if (typeof output === "string") {
rawCandidates.push(output)
const parsed = parseJson(output)
if (parsed) {
rawCandidates.push(...collectErrorCandidates(parsed))
}
} else if (typeof output === "object" && !isValidElement(output)) {
rawCandidates.push(...collectErrorCandidates(output))
}
return rawCandidates.some((candidate) => {
const normalizedCandidate = normalizeErrorForCompare(candidate)
return (
normalizedCandidate.length > 0 &&
normalizedCandidate === normalizedErrorText
)
})
}
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolPart["output"]
errorText: ToolPart["errorText"]
}
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null
}
const normalizedErrorText =
typeof errorText === "string" ? normalizeErrorForCompare(errorText) : null
const hasDuplicateErrorOutput = isDuplicateErrorOutput(
output,
normalizedErrorText
)
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") {
const language = detectOutputLanguage(output)
Output = <CodeBlock code={output} language={language} />
}
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"
)}
>
{typeof errorText === "string" && renderErrorText(errorText)}
{!hasDuplicateErrorOutput && Output}
</div>
</div>
)
}