Files
picoclaw/web/frontend/src/components/chat/message-code-block.tsx
T
LC 789f907f6d feat(chat): add independent code block copy and collapse controls (#2882)
* feat(chat): add independent copy and collapse controls for code blocks

* fix(chat): unify code block rendering styles

* fix(chat): refine code block labels

* feat(chat): highlight tool call code blocks as json
2026-05-18 10:01:39 +08:00

166 lines
4.9 KiB
TypeScript

import {
IconCheck,
IconChevronDown,
IconCopy,
} from "@tabler/icons-react"
import hljs from "highlight.js/lib/core"
import json from "highlight.js/lib/languages/json"
import { type ComponentProps, type ReactNode, useState } from "react"
import { useTranslation } from "react-i18next"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { cn } from "@/lib/utils"
import {
extractCodeBlockFromPreNode,
type MarkdownNode,
} from "./message-code-block.utils"
import { Button } from "@/components/ui/button"
const CODE_LABEL_FONT_FAMILY =
'ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", monospace'
hljs.registerLanguage("json", json)
interface MessageCodeBlockProps {
code: string
language?: string | null
label?: string
children?: ReactNode
className?: string
bodyClassName?: string
wrapLongLines?: boolean
}
interface MarkdownCodeBlockProps extends ComponentProps<"pre"> {
node?: MarkdownNode
}
function getHighlightedHtml(code: string, language?: string | null) {
if (!language) {
return null
}
try {
return hljs.highlight(code, { language }).value
} catch {
return null
}
}
export function MessageCodeBlock({
code,
language = null,
label,
children,
className,
bodyClassName,
wrapLongLines = false,
}: MessageCodeBlockProps) {
const { t } = useTranslation()
const { copy, isCopied } = useCopyToClipboard()
const [isExpanded, setIsExpanded] = useState(true)
const blockLabel =
label ??
(language
? language.toLocaleLowerCase()
: t("chat.codeLabel").toLocaleLowerCase())
const copyLabel = isCopied ? t("chat.copiedLabel") : t("chat.copyCode")
const expandLabel = isExpanded ? t("chat.collapseCode") : t("chat.expandCode")
const highlightedHtml = !children ? getHighlightedHtml(code, language) : null
return (
<div
data-picoclaw-code-block=""
className={cn(
"not-prose my-4 overflow-hidden rounded-lg border border-[#d0d7de] bg-[#f6f8fa] text-[#24292f] shadow-xs dark:border-[#30363d] dark:bg-[#0d1117] dark:text-[#c9d1d9]",
className,
)}
>
<div className="flex items-center justify-between gap-2 border-b border-[#d0d7de] bg-black/[0.03] px-3 py-2 dark:border-[#30363d] dark:bg-white/[0.03]">
<span
className="text-[11px] font-medium text-zinc-600 dark:text-zinc-400"
style={{ fontFamily: CODE_LABEL_FONT_FAMILY }}
>
{blockLabel}
</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="xs"
className="h-7 text-zinc-600 hover:bg-zinc-300/70 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
onClick={() => void copy(code)}
aria-label={copyLabel}
title={copyLabel}
>
{isCopied ? (
<IconCheck className="text-green-500" />
) : (
<IconCopy />
)}
<span className="hidden sm:inline">{copyLabel}</span>
</Button>
<Button
type="button"
variant="ghost"
size="xs"
className="h-7 text-zinc-600 hover:bg-zinc-300/70 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
onClick={() => setIsExpanded((expanded) => !expanded)}
aria-expanded={isExpanded}
aria-label={expandLabel}
title={expandLabel}
>
<IconChevronDown
className={cn("transition-transform duration-200", isExpanded && "rotate-180")}
/>
<span className="hidden sm:inline">{expandLabel}</span>
</Button>
</div>
</div>
{isExpanded && (
<pre
className={cn(
"m-0 overflow-x-auto bg-transparent px-4 py-3 font-mono text-[13px] leading-6 [&_code]:block [&_code]:bg-transparent [&_code]:p-0 [&_code]:text-inherit",
wrapLongLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
bodyClassName,
)}
>
{children ?? (
highlightedHtml ? (
<code
className={cn("hljs", language && `language-${language}`)}
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
) : (
<code className={language ? `language-${language}` : undefined}>
{code}
</code>
)
)}
</pre>
)}
</div>
)
}
export function MarkdownCodeBlock({
children,
className,
node,
}: MarkdownCodeBlockProps) {
const { code, language } = extractCodeBlockFromPreNode(node)
return (
<MessageCodeBlock
code={code}
language={language}
bodyClassName={className}
>
{children}
</MessageCodeBlock>
)
}