Initial commit
This commit is contained in:
@@ -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} />
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client"
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react"
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cjk } from "@streamdown/cjk"
|
||||
import { code } from "@streamdown/code"
|
||||
import { math } from "@streamdown/math"
|
||||
import { mermaid } from "@streamdown/mermaid"
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react"
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { Streamdown } from "streamdown"
|
||||
|
||||
import { Shimmer } from "./shimmer"
|
||||
|
||||
interface ReasoningContextValue {
|
||||
isStreaming: boolean
|
||||
isOpen: boolean
|
||||
setIsOpen: (open: boolean) => void
|
||||
duration: number | undefined
|
||||
}
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null)
|
||||
|
||||
export const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext)
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
duration?: number
|
||||
}
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000
|
||||
const MS_IN_S = 1000
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const resolvedDefaultOpen = defaultOpen ?? isStreaming
|
||||
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
|
||||
const isExplicitlyClosed = defaultOpen === false
|
||||
|
||||
const [isOpen, setIsOpen] = useControllableState<boolean>({
|
||||
defaultProp: resolvedDefaultOpen,
|
||||
onChange: onOpenChange,
|
||||
prop: open,
|
||||
})
|
||||
const [duration, setDuration] = useControllableState<number | undefined>({
|
||||
defaultProp: undefined,
|
||||
prop: durationProp,
|
||||
})
|
||||
|
||||
const hasEverStreamedRef = useRef(isStreaming)
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false)
|
||||
const startTimeRef = useRef<number | null>(null)
|
||||
|
||||
// Track when streaming starts and compute duration
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
hasEverStreamedRef.current = true
|
||||
if (startTimeRef.current === null) {
|
||||
startTimeRef.current = Date.now()
|
||||
}
|
||||
} else if (startTimeRef.current !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S))
|
||||
startTimeRef.current = null
|
||||
}
|
||||
}, [isStreaming, setDuration])
|
||||
|
||||
// Auto-open when streaming starts (unless explicitly closed)
|
||||
useEffect(() => {
|
||||
if (isStreaming && !isOpen && !isExplicitlyClosed) {
|
||||
setIsOpen(true)
|
||||
}
|
||||
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed])
|
||||
|
||||
// Auto-close when streaming ends (once only, and only if it ever streamed)
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasEverStreamedRef.current &&
|
||||
!isStreaming &&
|
||||
isOpen &&
|
||||
!hasAutoClosed
|
||||
) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false)
|
||||
setHasAutoClosed(true)
|
||||
}, AUTO_CLOSE_DELAY)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
setIsOpen(newOpen)
|
||||
},
|
||||
[setIsOpen]
|
||||
)
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ duration, isOpen, isStreaming, setIsOpen }),
|
||||
[duration, isOpen, isStreaming, setIsOpen]
|
||||
)
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider value={contextValue}>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode
|
||||
}
|
||||
|
||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>Thought for a few seconds</p>
|
||||
}
|
||||
return <p>Thought for {duration} seconds</p>
|
||||
}
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
children,
|
||||
getThinkingMessage = defaultGetThinkingMessage,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning()
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string
|
||||
}
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid }
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown plugins={streamdownPlugins} {...props}>
|
||||
{children}
|
||||
</Streamdown>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
)
|
||||
|
||||
Reasoning.displayName = "Reasoning"
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger"
|
||||
ReasoningContent.displayName = "ReasoningContent"
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user