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
This commit is contained in:
LC
2026-05-18 10:01:39 +08:00
committed by GitHub
parent feacd84b84
commit 789f907f6d
11 changed files with 485 additions and 64 deletions
@@ -15,8 +15,13 @@ import rehypeRaw from "rehype-raw"
import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm"
import {
MessageCodeBlock,
MarkdownCodeBlock,
} from "@/components/chat/message-code-block"
import { Button } from "@/components/ui/button"
import { formatMessageTime } from "@/hooks/use-pico-chat"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { cn } from "@/lib/utils"
import {
type AssistantMessageKind,
@@ -40,7 +45,7 @@ export function AssistantMessage({
timestamp = "",
}: AssistantMessageProps) {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState(false)
const { copy, isCopied } = useCopyToClipboard()
const isThought = kind === "thought"
const isToolCalls = kind === "tool_calls"
const isCollapsedBlock = isThought || isToolCalls
@@ -55,44 +60,12 @@ export function AssistantMessage({
const [isExpanded, setIsExpanded] = useState(true)
const formattedTimestamp =
timestamp !== "" ? formatMessageTime(timestamp) : ""
const handleCopy = async () => {
const markCopied = () => {
setIsCopied(true)
setTimeout(() => setIsCopied(false), 2000)
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(content)
markCopied()
return
}
} catch {
// HTTP 或受限环境下可能不支持 Clipboard API,继续走降级方案
}
const textArea = document.createElement("textarea")
textArea.value = content
textArea.setAttribute("readonly", "")
textArea.style.position = "fixed"
textArea.style.left = "-9999px"
document.body.appendChild(textArea)
textArea.select()
try {
const copied = document.execCommand("copy")
if (copied) {
markCopied()
}
} finally {
document.body.removeChild(textArea)
}
}
const collapsedLabel = isThought
? t("chat.reasoningLabel")
: t("chat.toolCallsLabel")
const copyMessageLabel = isCopied
? t("chat.copiedLabel")
: t("chat.copyMessage")
return (
<div className="group flex w-full flex-col gap-1.5">
@@ -174,6 +147,9 @@ export function AssistantMessage({
rehypeSanitize,
rehypeHighlight,
]}
components={{
pre: MarkdownCodeBlock,
}}
>
{explanation}
</ReactMarkdown>
@@ -192,15 +168,20 @@ export function AssistantMessage({
{t("chat.toolCallFunctionLabel")}
</div>
<div className="bg-background/55 border-border/25 space-y-2 rounded-lg border px-3 py-2.5">
{toolName && (
{toolName && !toolArguments && (
<div className="text-foreground/75 font-mono text-[12px] font-semibold">
{toolName}
</div>
)}
{toolArguments && (
<pre className="text-muted-foreground/75 overflow-x-auto font-mono text-[12px] leading-relaxed break-words whitespace-pre-wrap">
{toolArguments}
</pre>
<MessageCodeBlock
code={toolArguments}
language="json"
label={toolName || t("chat.toolCallArgumentsLabel")}
className="my-0 shadow-none"
bodyClassName="px-3 py-2 text-[12px] leading-relaxed"
wrapLongLines
/>
)}
</div>
</div>
@@ -222,6 +203,9 @@ export function AssistantMessage({
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
components={{
pre: MarkdownCodeBlock,
}}
>
{content}
</ReactMarkdown>
@@ -235,7 +219,9 @@ export function AssistantMessage({
className={cn(
"bg-background/50 hover:bg-background/80 absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100",
)}
onClick={handleCopy}
onClick={() => void copy(content)}
aria-label={copyMessageLabel}
title={copyMessageLabel}
>
{isCopied ? (
<IconCheck className="h-4 w-4 text-green-500" />
@@ -0,0 +1,165 @@
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>
)
}
@@ -0,0 +1,85 @@
export interface MarkdownNode {
type?: string
value?: string
tagName?: string
properties?: Record<string, unknown>
children?: MarkdownNode[]
}
function toClassNameTokens(className: unknown): string[] {
if (typeof className === "string") {
return className.split(/\s+/).filter(Boolean)
}
if (Array.isArray(className)) {
return className.filter(
(token): token is string => typeof token === "string" && token.length > 0,
)
}
return []
}
function findFirstDescendantByTagName(
node: MarkdownNode | undefined,
tagName: string,
): MarkdownNode | undefined {
if (!node) {
return undefined
}
if (node.tagName === tagName) {
return node
}
if (!Array.isArray(node.children)) {
return undefined
}
for (const child of node.children) {
const match = findFirstDescendantByTagName(child, tagName)
if (match) {
return match
}
}
return undefined
}
export function extractTextFromMarkdownNode(
node: MarkdownNode | undefined,
): string {
if (!node) {
return ""
}
if (node.type === "text") {
return typeof node.value === "string" ? node.value : ""
}
if (!Array.isArray(node.children)) {
return ""
}
return node.children.map(extractTextFromMarkdownNode).join("")
}
export function extractCodeBlockLanguage(className: unknown): string | null {
const languageToken = toClassNameTokens(className).find(
(token) => token.startsWith("language-") && token.length > "language-".length,
)
return languageToken ? languageToken.slice("language-".length) : null
}
export function extractCodeBlockFromPreNode(node: MarkdownNode | undefined): {
code: string
language: string | null
} {
const codeNode = findFirstDescendantByTagName(node, "code")
return {
code: extractTextFromMarkdownNode(codeNode ?? node),
language: extractCodeBlockLanguage(codeNode?.properties?.className),
}
}
@@ -1,5 +1,10 @@
import { IconCheck, IconCopy } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { cn } from "@/lib/utils"
import type { ChatAttachment } from "@/store/chat"
import { useTranslation } from "react-i18next"
interface UserMessageProps {
content: string
@@ -7,14 +12,19 @@ interface UserMessageProps {
}
export function UserMessage({ content, attachments = [] }: UserMessageProps) {
const { t } = useTranslation()
const { copy, isCopied } = useCopyToClipboard()
const hasText = content.trim().length > 0
const isCommand = content.trim().startsWith("/")
const imageAttachments = attachments.filter(
(attachment) => attachment.type === "image",
)
const copyMessageLabel = isCopied
? t("chat.copiedLabel")
: t("chat.copyMessage")
return (
<div className="flex w-full flex-col items-end gap-1.5">
<div className="group flex w-full flex-col items-end gap-1.5">
{imageAttachments.length > 0 && (
<div className="flex max-w-[70%] flex-wrap justify-end gap-2">
{imageAttachments.map((attachment, index) => (
@@ -29,24 +39,46 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) {
)}
{hasText && (
<div
className={cn(
"max-w-[70%] wrap-break-word whitespace-pre-wrap",
isCommand
? "rounded-xl border border-zinc-200 bg-transparent px-4 py-3 font-mono text-[14px] text-zinc-800 dark:border-zinc-800/60 dark:bg-[#121212] dark:text-zinc-200 dark:shadow-sm"
: "rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm",
)}
>
{isCommand ? (
<div className="flex items-start gap-2.5">
<span className="font-bold text-emerald-600 select-none dark:text-emerald-400">
</span>
<span className="mt-[1px]">{content}</span>
</div>
) : (
content
)}
<div className="relative max-w-[70%]">
<div
className={cn(
"wrap-break-word whitespace-pre-wrap",
isCommand
? "rounded-xl border border-zinc-200 bg-transparent px-4 py-3 font-mono text-[14px] text-zinc-800 dark:border-zinc-800/60 dark:bg-[#121212] dark:text-zinc-200 dark:shadow-sm"
: "rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm",
)}
>
{isCommand ? (
<div className="flex items-start gap-2.5">
<span className="font-bold text-emerald-600 select-none dark:text-emerald-400">
</span>
<span className="mt-[1px]">{content}</span>
</div>
) : (
content
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"bg-background/75 hover:bg-background absolute top-2 right-2 h-7 w-7 opacity-0 shadow-xs transition-opacity group-hover:opacity-100",
isCommand
? "text-zinc-700 dark:text-zinc-200"
: "text-violet-700 dark:text-violet-100",
)}
onClick={() => void copy(content)}
aria-label={copyMessageLabel}
title={copyMessageLabel}
>
{isCopied ? (
<IconCheck className="h-4 w-4 text-green-500" />
) : (
<IconCopy className="h-4 w-4" />
)}
</Button>
</div>
)}
</div>
@@ -95,9 +95,9 @@ export function ProviderCombobox({
return (
<Popover
open={open}
onOpenChange={(v) => {
setOpen(v)
if (!v) setCustomMode(false)
onOpenChange={(isOpen: boolean) => {
setOpen(isOpen)
if (!isOpen) setCustomMode(false)
}}
>
<PopoverTrigger asChild>
@@ -0,0 +1,45 @@
import { useEffect, useRef, useState } from "react"
import { copyText } from "@/lib/clipboard"
const DEFAULT_RESET_DELAY_MS = 2000
export function useCopyToClipboard(
resetDelayMs: number = DEFAULT_RESET_DELAY_MS,
) {
const [isCopied, setIsCopied] = useState(false)
const resetTimerRef = useRef<number | null>(null)
useEffect(() => {
return () => {
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current)
}
}
}, [])
const markCopied = () => {
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current)
}
setIsCopied(true)
resetTimerRef.current = window.setTimeout(() => {
setIsCopied(false)
resetTimerRef.current = null
}, resetDelayMs)
}
const copy = async (text: string) => {
const didCopy = await copyText(text)
if (didCopy) {
markCopied()
}
return didCopy
}
return {
copy,
isCopied,
}
}
+12 -1
View File
@@ -7,6 +7,17 @@ const THEME_STYLE_OWNER_ATTR = "data-picoclaw-highlight-theme"
const THEME_STYLE_OWNER_VALUE = "true"
const MANAGED_THEME_STYLE_SELECTOR = `style[${THEME_STYLE_OWNER_ATTR}="${THEME_STYLE_OWNER_VALUE}"]`
const ID_THEME_STYLE_SELECTOR = `style#${THEME_STYLE_ID}`
const CHAT_CODE_BLOCK_OVERRIDES = `
[data-picoclaw-code-block] .hljs {
background: transparent !important;
}
[data-picoclaw-code-block] pre code.hljs,
[data-picoclaw-code-block] code.hljs {
padding: 0 !important;
background: transparent !important;
}
`
function getOrCreateThemeStyleElement(): HTMLStyleElement {
const managedStyleElement = document.head.querySelector<HTMLStyleElement>(
@@ -49,7 +60,7 @@ export function useHighlightTheme() {
const nextThemeCss = root.classList.contains("dark")
? githubDarkCss
: githubLightCss
styleElement.textContent = nextThemeCss
styleElement.textContent = `${nextThemeCss}\n${CHAT_CODE_BLOCK_OVERRIDES}`
}
applyTheme()
+7
View File
@@ -63,8 +63,15 @@
"toolCallsLabel": "Tool calls",
"toolCallExplanationLabel": "Call note",
"toolCallFunctionLabel": "Call summary",
"toolCallArgumentsLabel": "Arguments",
"showAssistantDetails": "Show reasoning and tool calls",
"toolLabel": "Tool",
"codeLabel": "Code",
"copyMessage": "Copy message",
"copyCode": "Copy code",
"copiedLabel": "Copied",
"expandCode": "Expand code",
"collapseCode": "Collapse code",
"history": "History",
"noHistory": "No chat history yet",
"historyLoadFailed": "Failed to load chat history",
+7
View File
@@ -63,8 +63,15 @@
"toolCallsLabel": "Chamadas de ferramentas",
"toolCallExplanationLabel": "Nota da chamada",
"toolCallFunctionLabel": "Resumo da chamada",
"toolCallArgumentsLabel": "Argumentos",
"showAssistantDetails": "Mostrar raciocínio e chamadas de ferramentas",
"toolLabel": "Ferramenta",
"codeLabel": "Código",
"copyMessage": "Copiar mensagem",
"copyCode": "Copiar código",
"copiedLabel": "Copiado",
"expandCode": "Expandir código",
"collapseCode": "Recolher código",
"history": "Histórico",
"noHistory": "Nenhum histórico de chat ainda",
"historyLoadFailed": "Falha ao carregar histórico de chat",
+7
View File
@@ -63,8 +63,15 @@
"toolCallsLabel": "工具调用",
"toolCallExplanationLabel": "调用提示",
"toolCallFunctionLabel": "调用摘要",
"toolCallArgumentsLabel": "参数",
"showAssistantDetails": "展示思考过程与工具调用",
"toolLabel": "工具",
"codeLabel": "代码",
"copyMessage": "复制消息",
"copyCode": "复制代码",
"copiedLabel": "已复制",
"expandCode": "展开代码",
"collapseCode": "折叠代码",
"history": "历史记录",
"noHistory": "暂无对话历史",
"historyLoadFailed": "加载历史记录失败",
+76
View File
@@ -0,0 +1,76 @@
interface ClipboardTextareaLike {
value: string
style: {
position: string
left: string
}
setAttribute(name: string, value: string): void
select(): void
}
interface ClipboardBodyLike {
appendChild(node: ClipboardTextareaLike): void
removeChild(node: ClipboardTextareaLike): void
}
interface ClipboardDocumentLike {
body: ClipboardBodyLike
createElement(tagName: "textarea"): ClipboardTextareaLike
execCommand(command: "copy"): boolean
}
interface ClipboardNavigatorLike {
clipboard?: {
writeText(text: string): Promise<void>
}
}
export interface ClipboardEnvironment {
document?: ClipboardDocumentLike
navigator?: ClipboardNavigatorLike
}
function getDefaultClipboardEnvironment(): ClipboardEnvironment {
return {
document:
typeof document === "undefined"
? undefined
: (document as unknown as ClipboardDocumentLike),
navigator:
typeof navigator === "undefined"
? undefined
: (navigator as unknown as ClipboardNavigatorLike),
}
}
export async function copyText(
text: string,
environment: ClipboardEnvironment = getDefaultClipboardEnvironment(),
): Promise<boolean> {
try {
if (environment.navigator?.clipboard?.writeText) {
await environment.navigator.clipboard.writeText(text)
return true
}
} catch {
// HTTP or restricted environments can reject Clipboard API writes.
}
if (!environment.document) {
return false
}
const textArea = environment.document.createElement("textarea")
textArea.value = text
textArea.setAttribute("readonly", "")
textArea.style.position = "fixed"
textArea.style.left = "-9999px"
environment.document.body.appendChild(textArea)
textArea.select()
try {
return environment.document.execCommand("copy")
} finally {
environment.document.body.removeChild(textArea)
}
}