picoclaw/web/frontend/src/components/agent/skills/detail-sheet.tsx
LC 28ec5793a8
feat(web): add line numbers and wrap toggle for code blocks (#2933)
* feat(web): add line numbers and wrap toggle for code blocks

* fix(web): preserve markdown code block copy semantics
2026-05-26 14:57:52 +08:00

256 lines
9.3 KiB
TypeScript

import {
IconFileCode,
IconSparkles,
IconWorld,
IconX,
} from "@tabler/icons-react"
import type { ReactNode } from "react"
import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown"
import rehypeHighlight from "rehype-highlight"
import rehypeRaw from "rehype-raw"
import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm"
import type { SkillDetailResponse, SkillSupportItem } from "@/api/skills"
import {
MarkdownCodeBlock,
MessageCodeBlock,
} from "@/components/chat/message-code-block"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import { OriginBadge } from "./origin-badge"
import { getOriginLabel, getSkillOriginKind } from "./origin-utils"
import type { SkillDetailView } from "./types"
const DETAIL_VIEWS = [
"preview",
"raw",
"meta",
] as const satisfies SkillDetailView[]
interface DetailSheetProps {
open: boolean
selectedSkill: SkillSupportItem | null
selectedSkillDetail?: SkillDetailResponse
isLoading: boolean
error: unknown
detailView: SkillDetailView
onDetailViewChange: (view: SkillDetailView) => void
onOpenChange: (open: boolean) => void
}
export function DetailSheet({
open,
selectedSkill,
selectedSkillDetail,
isLoading,
error,
detailView,
onDetailViewChange,
onOpenChange,
}: DetailSheetProps) {
const { t } = useTranslation()
const activeSkillDetail = selectedSkillDetail ?? selectedSkill
const activeSkillOrigin = activeSkillDetail
? getSkillOriginKind(activeSkillDetail)
: null
const detailLineCount = selectedSkillDetail
? selectedSkillDetail.content.split("\n").length
: 0
const detailCharacterCount = selectedSkillDetail?.content.length ?? 0
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 p-0 shadow-2xl data-[side=right]:!w-full data-[side=right]:sm:!w-[720px] data-[side=right]:sm:!max-w-[720px]"
>
<SheetHeader className="bg-muted/10 border-b px-6 py-6">
<div className="flex items-center gap-3">
<div className="bg-primary/10 string-1 ring-primary/20 text-primary flex size-10 items-center justify-center rounded-xl">
{activeSkillDetail?.origin_kind === "builtin" ? (
<IconSparkles className="size-5" />
) : activeSkillDetail?.registry_name ? (
<IconWorld className="size-5" />
) : (
<IconFileCode className="size-5" />
)}
</div>
<div className="min-w-0 flex-1 space-y-1 text-left">
<SheetTitle className="truncate text-xl font-bold tracking-tight">
{activeSkillDetail?.name ||
t("pages.agent.skills.viewer_title")}
</SheetTitle>
<SheetDescription className="line-clamp-2">
{activeSkillDetail?.description ||
t("pages.agent.skills.viewer_description")}
</SheetDescription>
</div>
</div>
</SheetHeader>
<div className="flex-1 overflow-x-hidden overflow-y-scroll px-6 py-6">
{isLoading ? (
<div className="space-y-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-[400px] w-full rounded-xl" />
</div>
) : error ? (
<div className="text-destructive border-destructive/20 bg-destructive/5 flex h-40 flex-col items-center justify-center gap-3 rounded-xl border">
<IconX className="size-6 opacity-80" />
<span className="text-sm font-medium">
{t("pages.agent.skills.load_detail_error")}
</span>
</div>
) : selectedSkillDetail ? (
<div className="space-y-6">
{activeSkillOrigin === "third_party" ? (
<div className="border-border/40 bg-card/40 space-y-4 rounded-xl border p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-2 px-1">
<OriginBadge
origin={activeSkillOrigin}
label={getOriginLabel(activeSkillOrigin, t)}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{selectedSkillDetail.registry_name ? (
<MetadataItem
label={t("pages.agent.skills.metadata.registry")}
value={selectedSkillDetail.registry_name}
/>
) : null}
{selectedSkillDetail.installed_version ? (
<MetadataItem
label={t("pages.agent.skills.metadata.version")}
value={selectedSkillDetail.installed_version}
/>
) : null}
{selectedSkillDetail.registry_url ? (
<MetadataItem
label={t("pages.agent.skills.metadata.url")}
value={
<a
href={selectedSkillDetail.registry_url}
target="_blank"
rel="noreferrer"
className="text-primary hover:text-primary/80 inline break-all underline-offset-4 hover:underline"
>
{selectedSkillDetail.registry_url}
</a>
}
mono
/>
) : null}
</div>
</div>
) : null}
<div className="border-border/70 bg-muted/20 inline-flex rounded-lg border p-1 shadow-sm">
{DETAIL_VIEWS.map((view) => (
<button
key={view}
type="button"
className={cn(
"rounded-md px-4 py-1.5 text-xs font-medium transition-all duration-200",
detailView === view
? "bg-background text-foreground ring-border/30 shadow-[0_1px_3px_rgba(0,0,0,0.1)] ring-1"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50",
)}
onClick={() => onDetailViewChange(view)}
>
{t(`pages.agent.skills.detail_tabs.${view}`)}
</button>
))}
</div>
{detailView === "preview" ? (
<div className="prose prose-zinc dark:prose-invert prose-sm sm:prose-base prose-pre:rounded-xl prose-pre:border prose-pre:border-border/40 prose-pre:bg-zinc-100 prose-pre:p-0 prose-pre:shadow-sm dark:prose-pre:bg-zinc-950/90 prose-headings:tracking-tight prose-a:text-primary prose-a:no-underline hover:prose-a:underline max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
components={{
pre: MarkdownCodeBlock,
}}
>
{selectedSkillDetail.content}
</ReactMarkdown>
</div>
) : null}
{detailView === "raw" ? (
<MessageCodeBlock
code={selectedSkillDetail.content}
label={t("pages.agent.skills.detail_tabs.raw")}
className="my-0"
bodyClassName="text-[13px] leading-relaxed"
/>
) : null}
{detailView === "meta" ? (
<div className="grid gap-4 sm:grid-cols-2">
<MetadataItem
label={t("pages.agent.skills.metadata.name")}
value={selectedSkillDetail.name}
/>
<MetadataItem
label={t("pages.agent.skills.metadata.description")}
value={
selectedSkillDetail.description ||
t("pages.agent.skills.no_description")
}
/>
<MetadataItem
label={t("pages.agent.skills.metadata.lines")}
value={String(detailLineCount)}
/>
<MetadataItem
label={t("pages.agent.skills.metadata.characters")}
value={String(detailCharacterCount)}
/>
</div>
) : null}
</div>
) : null}
</div>
</SheetContent>
</Sheet>
)
}
function MetadataItem({
label,
value,
mono = false,
}: {
label: string
value: ReactNode
mono?: boolean
}) {
return (
<div className="border-border/70 bg-muted/20 rounded-xl border px-4 py-3">
<div className="text-muted-foreground text-[11px] font-semibold tracking-[0.18em] uppercase">
{label}
</div>
<div
className={cn(
"text-foreground mt-2 text-sm leading-6 break-all",
mono && "font-mono text-xs",
)}
>
{value}
</div>
</div>
)
}