- beautifului.dev 组件直连:Sidebar Nav、Tool Chips、Approval Card(抽屉化)、Streaming/Thinking、Code Block、Selection Actions、Prompt Bar、Search
19 lines
15 KiB
JSON
19 lines
15 KiB
JSON
{
|
||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||
"name": "tool-chips",
|
||
"type": "registry:component",
|
||
"title": "Tool Chips",
|
||
"description": "Code edits and tool calls as compact chips.",
|
||
"registryDependencies": [
|
||
"https://www.beautifului.dev/r/foundation.json"
|
||
],
|
||
"files": [
|
||
{
|
||
"path": "components/primitives/ToolChips.tsx",
|
||
"content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\n\n/* ─────────────────────────────────────────────────────────\n * TOOL CHIPS\n * An agent run as compact rows: tool calls with inline\n * chips, then file-diff chips summarizing the edits.\n * Hover a row to reveal its chevron; every row expands\n * to show what the tool actually did.\n * ───────────────────────────────────────────────────────── */\n\nconst STEP_MS = 700;\n\nconst Icons: Record<string, React.ReactNode> = {\n think: <path d=\"M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z\" />,\n write: <g fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><path d=\"M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z\" /></g>,\n run: <g fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><path d=\"M4 17l6-5-6-5M12 19h8\" /></g>,\n read: <g fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\" /><path d=\"M14 2v6h6\" /></g>,\n};\n\nexport type ToolDetailLine = { text: string; tone?: \"add\" };\n\nexport type ToolStep = {\n icon: string;\n label: string;\n chip: string;\n mono: boolean;\n detailMono: boolean;\n detail: ToolDetailLine[];\n};\n\nexport type ToolDiff = { file: string; add: number; del: number };\n\nexport type ToolDiffLine = { text: string; tone: \"add\" | \"del\" | \"ctx\" };\n\nexport type ToolChipsLabels = {\n header: string;\n more: string;\n};\n\nconst DEFAULT_LABELS: ToolChipsLabels = {\n header: \"4 tool calls, 2 messages\",\n more: \"+2 more\",\n};\n\nconst ROWS: ToolStep[] = [\n {\n icon: \"think\", label: \"Thinking\", chip: \"Planning the churn schedule…\", mono: false, detailMono: false,\n detail: [\n { text: \"Weekend demand carries pistachio, so it churns first.\" },\n { text: \"Batch capacity leaves two evening freezer windows.\" },\n ],\n },\n {\n icon: \"write\", label: \"Write 204 lines\", chip: \"ChurnSchedule.tsx\", mono: true, detailMono: true,\n detail: [\n { text: \"+ const windows = slots.filter((s) => s.temp <= -12)\", tone: \"add\" },\n { text: \"+ return schedule(windows, { hero: \\\"pistachio\\\" })\", tone: \"add\" },\n ],\n },\n {\n icon: \"run\", label: \"Rebuild and verify\", chip: \"npm run freeze\", mono: true, detailMono: true,\n detail: [\n { text: \"✓ built in 1.2s\" },\n { text: \"✓ 34 checks passed\" },\n ],\n },\n {\n icon: \"read\", label: \"Read image\", chip: \"flavor-chart.png\", mono: true, detailMono: false,\n detail: [\n { text: \"1280 × 720 · line chart, three summers.\" },\n { text: \"Mint chip trends up 12% through July.\" },\n ],\n },\n];\n\nconst DIFFS: ToolDiff[] = [\n { file: \"flavors.css\", add: 13, del: 0 },\n { file: \"ChurnSchedule.tsx\", add: 74, del: 41 },\n { file: \"menu.ts\", add: 8, del: 2 },\n];\n\n/* hovering a file chip opens its diff — green added, red removed */\nconst DIFF_LINES: Record<string, ToolDiffLine[]> = {\n \"flavors.css\": [\n { text: \".scoop-card {\", tone: \"ctx\" },\n { text: \" gap: 14px;\", tone: \"del\" },\n { text: \" gap: 12px;\", tone: \"add\" },\n { text: \" container-type: inline-size;\", tone: \"add\" },\n { text: \"}\", tone: \"ctx\" },\n ],\n \"ChurnSchedule.tsx\": [\n { text: \"const slots = coldSlots(week);\", tone: \"ctx\" },\n { text: \"const windows = slots;\", tone: \"del\" },\n { text: \"const windows = slots.filter(\", tone: \"add\" },\n { text: \" (s) => s.temp <= -12,\", tone: \"add\" },\n { text: \");\", tone: \"add\" },\n ],\n \"menu.ts\": [\n { text: \"export const hero = \\\"mint-chip\\\";\", tone: \"del\" },\n { text: \"export const hero = \\\"pistachio\\\";\", tone: \"add\" },\n ],\n};\n\nexport default function ToolChips({\n steps = ROWS,\n diffs = DIFFS,\n diffLines = DIFF_LINES,\n labels,\n className,\n onOpenChange,\n onToggleRow,\n}: {\n /** Accepted for gallery/registry parity; ToolChips has no visual variants. */\n variant?: string;\n steps?: ToolStep[];\n diffs?: ToolDiff[];\n diffLines?: Record<string, ToolDiffLine[]>;\n labels?: Partial<ToolChipsLabels>;\n className?: string;\n onOpenChange?: (open: boolean) => void;\n onToggleRow?: (label: string, open: boolean) => void;\n} = {}) {\n const copy = { ...DEFAULT_LABELS, ...labels };\n const [step, setStep] = useState(0);\n const [open, setOpen] = useState(true);\n const [openRows, setOpenRows] = useState<Set<string>>(new Set());\n /* Rendered in a body portal so animated/translated reply wrappers cannot\n * redefine the fixed-position coordinate system. */\n const [preview, setPreview] = useState<{\n file: string;\n x: number;\n top?: number;\n bottom?: number;\n } | null>(null);\n const openPreview = (file: string) => (event: React.SyntheticEvent) => {\n const rect = (event.currentTarget as Element).closest(\"[data-diffchip]\")!.getBoundingClientRect();\n const previewHeight = 38 + (diffLines[file]?.length ?? 0) * 19;\n const fitsBelow = rect.bottom + 6 + previewHeight <= window.innerHeight - 12;\n setPreview({\n file,\n x: Math.max(12, Math.min(rect.left, window.innerWidth - 300)),\n ...(fitsBelow\n ? { top: rect.bottom + 6 }\n : { bottom: window.innerHeight - rect.top + 6 }),\n });\n };\n const closePreview = (file: string) => () =>\n setPreview((current) => (current?.file === file ? null : current));\n const total = steps.length + 1; // rows, then diff chips\n\n useEffect(() => {\n if (step >= total) return;\n const t = setTimeout(() => setStep((s) => s + 1), STEP_MS);\n return () => clearTimeout(t);\n }, [step, total]);\n\n const toggleRow = (label: string) =>\n setOpenRows((current) => {\n const next = new Set(current);\n next.has(label) ? next.delete(label) : next.add(label);\n onToggleRow?.(label, next.has(label));\n return next;\n });\n\n return (\n <div className={`min-h-[220px] w-full max-w-80 pb-1${className ? ` ${className}` : \"\"}`}>\n {/* collapsed run header */}\n <button\n type=\"button\"\n aria-expanded={open}\n onClick={() =>\n setOpen((current) => {\n onOpenChange?.(!current);\n return !current;\n })\n }\n className=\"-mx-1.5 flex w-fit items-center gap-1.5 rounded-control px-1.5 py-1 text-[12.5px] text-ink-2 transition-colors duration-100 hover:bg-hover-2\"\n >\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"transition-transform duration-200\" style={{ transform: open ? \"rotate(0deg)\" : \"rotate(-90deg)\" }}>\n <path d=\"M6 9l6 6 6-6\" />\n </svg>\n <span className=\"tabular-nums\">{copy.header}</span>\n </button>\n\n {/* tool call rows */}\n <div className=\"grid transition-[grid-template-rows,opacity] duration-300\" style={{ gridTemplateRows: open ? \"1fr\" : \"0fr\", opacity: open ? 1 : 0 }}>\n {/* -mx-1 + px-1.5 keeps content at the same x while giving the\n row hover pills room inside this overflow-hidden clip box */}\n <div className=\"-mx-1 overflow-hidden px-1.5 pb-1\">\n <div className=\"mt-1.5 flex flex-col gap-1\">\n {steps.slice(0, step).map((row) => {\n const rowOpen = openRows.has(row.label);\n return (\n <div key={row.label} style={{ animation: \"fade-up 300ms cubic-bezier(0.23,1,0.32,1) both\" }}>\n <button\n type=\"button\"\n aria-expanded={rowOpen}\n onClick={() => toggleRow(row.label)}\n className=\"group/row -mx-[3px] flex h-7 w-[calc(100%+6px)] min-w-0 items-center gap-2 rounded-control px-[3px] text-left transition-colors duration-100 hover:bg-hover-2\"\n >\n <span className=\"relative flex size-4 shrink-0 items-center justify-center text-ink-3\">\n <svg\n width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill={row.icon === \"think\" ? \"currentColor\" : \"none\"} stroke=\"currentColor\"\n className={`transition-opacity duration-100 group-hover/row:opacity-0 ${rowOpen ? \"opacity-0\" : \"\"}`}\n >\n {Icons[row.icon]}\n </svg>\n <svg\n width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"\n className={`absolute transition-[opacity,transform] duration-150 group-hover/row:opacity-100 ${rowOpen ? \"opacity-100\" : \"opacity-0\"}`}\n style={{ transform: rowOpen ? \"rotate(0deg)\" : \"rotate(-90deg)\" }}\n >\n <path d=\"M6 9l6 6 6-6\" />\n </svg>\n </span>\n <span className=\"shrink-0 text-[12.5px] font-medium text-ink\">{row.label}</span>\n <span\n className={`inline-flex h-5.5 min-w-0 flex-1 cursor-pointer items-center truncate rounded-chip bg-field px-1.5\n text-[11.5px] text-ink-2 shadow-hairline transition-colors duration-100 hover:bg-hover-2\n ${row.mono ? \"font-mono\" : \"\"}`}\n >\n {row.chip}\n </span>\n </button>\n\n {/* expanded detail */}\n <div\n className=\"grid transition-[grid-template-rows,opacity] duration-300\"\n style={{ gridTemplateRows: rowOpen ? \"1fr\" : \"0fr\", opacity: rowOpen ? 1 : 0, transitionTimingFunction: \"cubic-bezier(0.23, 1, 0.32, 1)\" }}\n >\n <div className=\"min-h-0 overflow-hidden\">\n <div className=\"mt-0.5 mb-1 ml-2 flex flex-col gap-0.5 border-l border-line py-0.5 pl-3.5\">\n {row.detail.map((line) => (\n <span\n key={line.text}\n className={`truncate text-[11.5px] leading-[1.6] ${row.detailMono ? \"font-mono\" : \"\"} ${line.tone === \"add\" ? \"text-green\" : \"text-ink-2\"}`}\n >\n {line.text}\n </span>\n ))}\n </div>\n </div>\n </div>\n </div>\n );\n })}\n </div>\n\n {/* file-diff chips */}\n {step >= total && (\n <div className=\"mt-2.5 flex max-w-full flex-wrap gap-1.5 border-t border-line pt-2.5\">\n {diffs.map((d, i) => (\n <span\n key={d.file}\n data-diffchip\n className=\"relative\"\n onMouseEnter={openPreview(d.file)}\n onMouseLeave={closePreview(d.file)}\n >\n <button\n type=\"button\"\n aria-expanded={preview?.file === d.file}\n aria-label={`Show diff for ${d.file}`}\n onFocus={openPreview(d.file)}\n onBlur={closePreview(d.file)}\n className=\"inline-flex h-7 max-w-full items-center gap-2 rounded-chip\n bg-surface px-2 font-mono text-[11.5px] text-ink shadow-btn\n transition-colors duration-100 hover:bg-hover\"\n style={{ animation: `pop-in 250ms cubic-bezier(0.23,1,0.32,1) ${i * 80}ms both` }}\n >\n <span className=\"min-w-0 truncate\">{d.file}</span>\n <span className=\"shrink-0 text-green tabular-nums\">+{d.add}</span>\n {d.del > 0 && <span className=\"shrink-0 text-red tabular-nums\">−{d.del}</span>}\n </button>\n\n </span>\n ))}\n <button\n type=\"button\"\n className=\"inline-flex h-7 items-center rounded-chip px-1.5 font-mono text-[11.5px] text-ink-3\n underline decoration-transparent underline-offset-2 transition-colors duration-100\n hover:text-ink-2 hover:decoration-current\"\n style={{ animation: `fade-in 300ms ease-out ${diffs.length * 80}ms both` }}\n >\n {copy.more}\n </button>\n </div>\n )}\n </div>\n </div>\n {preview && typeof document !== \"undefined\" && createPortal(\n <div\n className=\"fixed z-50 w-72 overflow-hidden rounded-[10px] bg-surface shadow-overlay\"\n style={{\n left: preview.x,\n top: preview.top,\n bottom: preview.bottom,\n animation: \"pop-in 160ms cubic-bezier(0.23,1,0.32,1) both\",\n transformOrigin: preview.top === undefined ? \"bottom left\" : \"top left\",\n }}\n >\n <div className=\"flex items-center justify-between border-b border-line px-2.5 py-1.5 font-mono text-[11px]\">\n <span className=\"min-w-0 truncate text-ink-2\">{preview.file}</span>\n <span className=\"shrink-0 tabular-nums\">\n <span className=\"text-green\">+{diffs.find((diff) => diff.file === preview.file)?.add}</span>\n {(diffs.find((diff) => diff.file === preview.file)?.del ?? 0) > 0 && (\n <span className=\"text-red\"> −{diffs.find((diff) => diff.file === preview.file)?.del}</span>\n )}\n </span>\n </div>\n <div className=\"py-1 font-mono text-[11px] leading-[1.8]\">\n {(diffLines[preview.file] ?? []).map((line, index) => (\n <div\n key={index}\n className={`flex gap-2 px-2.5 whitespace-pre ${\n line.tone === \"add\"\n ? \"bg-green-tint text-green\"\n : line.tone === \"del\"\n ? \"bg-red-tint text-red\"\n : \"text-ink-2\"\n }`}\n >\n <span className=\"w-3 shrink-0 select-none\">{line.tone === \"add\" ? \"+\" : line.tone === \"del\" ? \"−\" : \" \"}</span>\n <span className=\"min-w-0 truncate\">{line.text}</span>\n </div>\n ))}\n </div>\n </div>,\n document.body,\n )}\n </div>\n );\n}\n",
|
||
"type": "registry:component",
|
||
"target": "components/primitives/ToolChips.tsx"
|
||
}
|
||
]
|
||
}
|