| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583 |
- "use client";
- import { useCallback, useRef, useState } from "react";
- import { useChat } from "@ai-sdk/react";
- import { DefaultChatTransport, type UIMessage } from "ai";
- import {
- SPEC_DATA_PART,
- SPEC_DATA_PART_TYPE,
- type SpecDataPart,
- } from "@json-render/core";
- import { useJsonRenderMessage } from "@json-render/react";
- import {
- ArrowUp,
- Bug,
- ChevronRight,
- FilePen,
- FilePlus,
- FileText,
- FolderGit2,
- FolderSearch,
- FolderTree,
- Gauge,
- Globe,
- Hammer,
- ListChecks,
- Loader2,
- Search,
- SquareChevronRight,
- Wrench,
- type LucideIcon,
- } from "lucide-react";
- import { Streamdown } from "streamdown";
- import { code } from "@streamdown/code";
- import { ReportRenderer } from "@/lib/render/renderer";
- import {
- AGENT_IDS,
- AGENTS,
- type AgentId,
- DEFAULT_AGENT_ID,
- } from "@/lib/agents";
- type AppDataParts = { [SPEC_DATA_PART]: SpecDataPart };
- type AppMessage = UIMessage<unknown, AppDataParts>;
- const transport = new DefaultChatTransport({ api: "/api/agent" });
- const SUGGESTIONS: Array<{
- label: string;
- description: string;
- icon: LucideIcon;
- prompt: string;
- }> = [
- {
- label: "Build & test a library",
- description: "Scaffold a TS package with vitest and run the suite.",
- icon: Hammer,
- prompt:
- "Scaffold a tiny TypeScript semver-parsing library with vitest tests, run the tests, and report the results.",
- },
- {
- label: "Fix failing code",
- description: "Plant a subtle bug, then debug it end to end.",
- icon: Bug,
- prompt:
- "Create a small JS module with a subtle off-by-one bug and a failing test, then diagnose and fix it like a real debugging session.",
- },
- {
- label: "Benchmark something",
- description: "Measure two approaches and chart the numbers.",
- icon: Gauge,
- prompt:
- "Write and run a quick benchmark comparing JSON.parse vs a streaming JSON parser on a 5MB file, and report the numbers as a bar chart.",
- },
- {
- label: "Explore a repo",
- description: "Clone a project and map out what each part does.",
- icon: FolderGit2,
- prompt:
- "Clone github.com/vercel-labs/json-render, explore the package structure, and report what each package does.",
- },
- ];
- /** Per-tool icon + readable [running, done] labels (labels used for a11y). */
- const TOOL_META: Record<
- string,
- { icon: LucideIcon; labels: [string, string] }
- > = {
- bash: {
- icon: SquareChevronRight,
- labels: ["Running command", "Ran command"],
- },
- read: { icon: FileText, labels: ["Reading file", "Read file"] },
- write: { icon: FilePlus, labels: ["Writing file", "Wrote file"] },
- edit: { icon: FilePen, labels: ["Editing file", "Edited file"] },
- grep: { icon: Search, labels: ["Searching code", "Searched code"] },
- glob: { icon: FolderSearch, labels: ["Listing files", "Listed files"] },
- ls: { icon: FolderTree, labels: ["Listing directory", "Listed directory"] },
- webSearch: { icon: Globe, labels: ["Searching the web", "Searched the web"] },
- WebFetch: { icon: Globe, labels: ["Fetching page", "Fetched page"] },
- TodoWrite: { icon: ListChecks, labels: ["Updating plan", "Updated plan"] },
- };
- /** Pull a one-line human hint out of a tool input (command, path, query). */
- function toolInputHint(input: unknown): string | null {
- if (input == null || typeof input !== "object") return null;
- const record = input as Record<string, unknown>;
- const hint =
- record.command ?? record.file_path ?? record.pattern ?? record.query;
- return typeof hint === "string" ? hint : null;
- }
- function ToolCallDisplay({
- toolName,
- state,
- input,
- output,
- }: {
- toolName: string;
- state: string;
- input: unknown;
- output: unknown;
- }) {
- const [expanded, setExpanded] = useState(false);
- const isLoading =
- state !== "output-available" &&
- state !== "output-error" &&
- state !== "output-denied";
- const meta = TOOL_META[toolName];
- const Icon = meta?.icon ?? Wrench;
- const label = meta ? meta.labels[isLoading ? 0 : 1] : toolName;
- const hint = toolInputHint(input);
- return (
- <div className="group rounded-lg border bg-card px-3 py-2 text-sm shadow-subtle">
- <button
- type="button"
- title={label}
- aria-label={label}
- className="flex w-full max-w-full items-center gap-2 text-left"
- onClick={() => setExpanded((e) => !e)}
- >
- <Icon
- className={`size-3.5 shrink-0 text-muted-foreground ${
- isLoading && !hint ? "animate-pulse" : ""
- }`}
- strokeWidth={1.75}
- />
- {hint && (
- <code
- className={`truncate font-mono text-xs text-muted-foreground/70 ${
- isLoading ? "animate-shimmer" : ""
- }`}
- >
- {hint}
- </code>
- )}
- {!isLoading && output != null && (
- <ChevronRight
- className={`ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground/40 transition-transform group-hover:text-muted-foreground ${expanded ? "rotate-90" : ""}`}
- />
- )}
- </button>
- {expanded && !isLoading && output != null && (
- <pre className="mt-2 max-h-64 overflow-auto border-t pt-2 text-xs text-muted-foreground whitespace-pre-wrap break-all">
- {typeof output === "string"
- ? output
- : JSON.stringify(output, null, 2)}
- </pre>
- )}
- </div>
- );
- }
- /**
- * Monochrome agent marks. Claude (svgl) and OpenAI (svgl) are single-path
- * brand glyphs forced to `currentColor`; Pi uses its namesake π since it has
- * no published logo. All inherit the surrounding text color.
- */
- type MarkProps = { className?: string };
- function ClaudeMark({ className }: MarkProps) {
- return (
- <svg viewBox="0 0 256 257" className={className} aria-hidden="true">
- <path
- fill="currentColor"
- d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"
- />
- </svg>
- );
- }
- function OpenAIMark({ className }: MarkProps) {
- return (
- <svg viewBox="0 0 256 260" className={className} aria-hidden="true">
- <path
- fill="currentColor"
- d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z"
- />
- </svg>
- );
- }
- function PiMark({ className }: MarkProps) {
- // pi.dev/logo-auto.svg — blocky "Pi" mark. The viewBox is padded a little
- // past the mark's bounds so it reads slightly smaller than the other marks.
- return (
- <svg
- viewBox="120 120 560 560"
- className={className}
- fill="currentColor"
- aria-hidden="true"
- >
- <path
- fillRule="evenodd"
- d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"
- />
- <path d="M517.36 400H634.72V634.72H517.36Z" />
- </svg>
- );
- }
- const AGENT_MARKS: Record<AgentId, (props: MarkProps) => React.ReactNode> = {
- "claude-code": ClaudeMark,
- codex: OpenAIMark,
- pi: PiMark,
- };
- /** Segmented control for picking the coding agent before a chat starts. */
- function AgentSelector({
- value,
- onChange,
- }: {
- value: AgentId;
- onChange: (id: AgentId) => void;
- }) {
- return (
- <div className="inline-flex items-center gap-0.5 rounded-lg border bg-card p-0.5">
- {AGENT_IDS.map((id) => {
- const Mark = AGENT_MARKS[id];
- return (
- <button
- key={id}
- type="button"
- onClick={() => onChange(id)}
- aria-pressed={value === id}
- className={`inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
- value === id
- ? "bg-foreground text-background"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- <Mark className="h-3.5 w-3.5" />
- {AGENTS[id].label}
- </button>
- );
- })}
- </div>
- );
- }
- /** Shimmering status line shown while we wait for the agent to produce output. */
- function PendingLine({ label }: { label: string }) {
- return (
- <div className="text-sm text-muted-foreground animate-shimmer">{label}</div>
- );
- }
- function MessageBubble({
- message,
- isLast,
- isStreaming,
- pendingLabel,
- }: {
- message: AppMessage;
- isLast: boolean;
- isStreaming: boolean;
- pendingLabel: string;
- }) {
- const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
- if (message.role === "user") {
- return (
- <div className="flex justify-end">
- <div className="max-w-[85%] rounded-2xl rounded-tr-sm bg-foreground px-3.5 py-2 text-sm leading-relaxed whitespace-pre-wrap text-background">
- {text}
- </div>
- </div>
- );
- }
- // Ordered segments: adjacent text merged, adjacent tool calls grouped,
- // the spec rendered inline where the agent emitted it.
- const segments: Array<
- | { kind: "text"; text: string }
- | {
- kind: "tools";
- tools: Array<{
- toolCallId: string;
- toolName: string;
- state: string;
- input: unknown;
- output: unknown;
- }>;
- }
- | { kind: "spec" }
- > = [];
- let specInserted = false;
- for (const part of message.parts) {
- if (part.type === "text") {
- if (!part.text.trim()) continue;
- const last = segments[segments.length - 1];
- if (last?.kind === "text") last.text += part.text;
- else segments.push({ kind: "text", text: part.text });
- } else if (part.type.startsWith("tool-")) {
- const tp = part as {
- type: string;
- toolCallId: string;
- state: string;
- input?: unknown;
- output?: unknown;
- };
- const tool = {
- toolCallId: tp.toolCallId,
- toolName: tp.type.replace(/^tool-/, ""),
- state: tp.state,
- input: tp.input,
- output: tp.output,
- };
- const last = segments[segments.length - 1];
- if (last?.kind === "tools") last.tools.push(tool);
- else segments.push({ kind: "tools", tools: [tool] });
- } else if (part.type === SPEC_DATA_PART_TYPE && !specInserted) {
- segments.push({ kind: "spec" });
- specInserted = true;
- }
- }
- const showLoader = isLast && isStreaming && segments.length === 0 && !hasSpec;
- return (
- <div className="flex w-full flex-col gap-3">
- {segments.map((seg, i) => {
- if (seg.kind === "text") {
- return (
- <div key={`text-${i}`} className="markdown text-sm leading-relaxed">
- <Streamdown
- plugins={{ code }}
- animated={isLast && isStreaming && i === segments.length - 1}
- >
- {seg.text}
- </Streamdown>
- </div>
- );
- }
- if (seg.kind === "spec") {
- if (!hasSpec) return null;
- return (
- <div key="spec" className="w-full">
- <ReportRenderer spec={spec} loading={isLast && isStreaming} />
- </div>
- );
- }
- return (
- <div key={`tools-${i}`} className="flex flex-col gap-1.5">
- {seg.tools.map((t) => (
- <ToolCallDisplay
- key={t.toolCallId}
- toolName={t.toolName}
- state={t.state}
- input={t.input}
- output={t.output}
- />
- ))}
- </div>
- );
- })}
- {showLoader && <PendingLine label={pendingLabel} />}
- {hasSpec && !specInserted && (
- <div className="w-full">
- <ReportRenderer spec={spec} loading={isLast && isStreaming} />
- </div>
- )}
- </div>
- );
- }
- export default function HarnessChatPage() {
- const [input, setInput] = useState("");
- const [agentId, setAgentId] = useState<AgentId>(DEFAULT_AGENT_ID);
- const [chatId, setChatId] = useState(() => crypto.randomUUID());
- const [isResetting, setIsResetting] = useState(false);
- const inputRef = useRef<HTMLTextAreaElement>(null);
- const { messages, sendMessage, setMessages, status, error, id, stop } =
- useChat<AppMessage>({ transport, id: chatId });
- const isStreaming = status === "streaming" || status === "submitted";
- const isBusy = isStreaming || isResetting;
- const handleSubmit = useCallback(
- async (text?: string) => {
- const message = text || input;
- if (!message.trim() || isBusy) return;
- setInput("");
- // The server locks the agent to the chat on the first message; sending
- // it every turn is harmless and keeps follow-ups consistent.
- await sendMessage({ text: message.trim() }, { body: { agent: agentId } });
- },
- [input, isBusy, sendMessage, agentId],
- );
- const handleClear = useCallback(async () => {
- if (isResetting) return;
- setIsResetting(true);
- stop();
- // Drop the server-side harness session (and its sandbox) for this chat.
- try {
- await fetch(`/api/agent?id=${encodeURIComponent(id)}`, {
- method: "DELETE",
- });
- } finally {
- setMessages([]);
- setChatId(crypto.randomUUID());
- setInput("");
- setIsResetting(false);
- inputRef.current?.focus();
- }
- }, [id, isResetting, setMessages, stop]);
- const isEmpty = messages.length === 0;
- return (
- <div className="flex h-screen flex-col overflow-hidden">
- {/* The header only appears once a chat has started; the first screen is
- headerless so the brand title carries it. */}
- {!isEmpty && (
- <header className="sticky top-0 z-10 flex h-14 shrink-0 items-center justify-between border-b bg-background/80 px-5 backdrop-blur-md">
- {/* Left: active agent */}
- {(() => {
- const Mark = AGENT_MARKS[agentId];
- return (
- <span className="flex items-center gap-1.5 text-sm text-muted-foreground">
- <Mark className="h-3.5 w-3.5" />
- {AGENTS[agentId].label}
- </span>
- );
- })()}
- {/* Center: brand, absolutely centered so side widths can't shift it */}
- <h1 className="pointer-events-none absolute left-1/2 -translate-x-1/2 text-sm font-medium tracking-tight whitespace-nowrap text-muted-foreground">
- AI SDK <span className="font-mono">HarnessAgent</span>
- <span className="mx-1.5 font-normal">+</span>
- <span className="font-mono">json-render</span>
- </h1>
- {/* Right: reset */}
- <button
- onClick={handleClear}
- disabled={isResetting}
- className="-mr-1.5 rounded-md px-2.5 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50"
- >
- Start over
- </button>
- </header>
- )}
- <main className="flex flex-1 flex-col overflow-auto">
- {isEmpty ? (
- <div className="flex flex-1 flex-col items-center justify-center px-6">
- <div className="w-full max-w-3xl">
- <div className="space-y-3">
- <h2 className="text-3xl font-semibold tracking-tight">
- AI SDK <span className="font-mono">HarnessAgent</span>
- <span className="mx-1.5 font-normal text-muted-foreground">
- +
- </span>
- <span className="font-mono">json-render</span>
- </h2>
- <p className="max-w-md text-[15px] leading-relaxed text-muted-foreground">
- A coding agent works in a live sandbox, then reports back as
- rendered UI — steps, diffs, terminal output, tests, and charts
- — instead of a wall of markdown.
- </p>
- </div>
- <div className="mt-8 grid grid-cols-1 gap-px overflow-hidden rounded-xl border bg-border sm:grid-cols-2">
- {SUGGESTIONS.map((s) => {
- const Icon = s.icon;
- return (
- <button
- key={s.label}
- onClick={() => handleSubmit(s.prompt)}
- disabled={isBusy}
- className="group flex items-start gap-3 bg-card p-4 text-left transition-colors hover:bg-accent"
- >
- <Icon
- className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground"
- strokeWidth={1.75}
- />
- <span className="min-w-0 space-y-0.5">
- <span className="block text-sm font-medium tracking-tight">
- {s.label}
- </span>
- <span className="block text-[13px] leading-snug text-muted-foreground">
- {s.description}
- </span>
- </span>
- </button>
- );
- })}
- </div>
- </div>
- </div>
- ) : (
- <div className="mx-auto w-full max-w-3xl space-y-6 px-6 py-6">
- {messages.map((message, index) => (
- <MessageBubble
- key={message.id}
- message={message}
- isLast={index === messages.length - 1}
- isStreaming={isStreaming}
- pendingLabel={index <= 1 ? "Starting sandbox…" : "Working…"}
- />
- ))}
- {isStreaming && messages[messages.length - 1]?.role === "user" && (
- <PendingLine
- label={messages.length <= 1 ? "Starting sandbox…" : "Working…"}
- />
- )}
- {error && (
- <div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
- {error.message}
- </div>
- )}
- </div>
- )}
- </main>
- <div className="shrink-0 px-6 pb-5">
- {isEmpty && (
- <div className="mx-auto mb-2.5 flex max-w-3xl items-center gap-2">
- <span className="text-xs text-muted-foreground">Agent</span>
- <AgentSelector value={agentId} onChange={setAgentId} />
- </div>
- )}
- <div className="group relative mx-auto max-w-3xl rounded-xl border bg-card shadow-subtle transition-colors focus-within:border-ring">
- <textarea
- ref={inputRef}
- value={input}
- onChange={(e) => setInput(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleSubmit();
- }
- }}
- placeholder={
- isEmpty
- ? "Scaffold a TypeScript library and run its tests…"
- : "Ask a follow-up…"
- }
- rows={2}
- className="w-full resize-none bg-transparent px-3.5 py-3 pr-12 text-sm leading-relaxed placeholder:text-muted-foreground focus-visible:outline-none"
- autoFocus
- />
- <button
- onClick={() => handleSubmit()}
- disabled={!input.trim() || isBusy}
- className="absolute right-2.5 bottom-2.5 flex h-7 w-7 items-center justify-center rounded-lg bg-foreground text-background transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-25"
- >
- {isBusy ? (
- <Loader2 className="h-3.5 w-3.5 animate-spin" />
- ) : (
- <ArrowUp className="h-3.5 w-3.5" strokeWidth={2.25} />
- )}
- </button>
- </div>
- </div>
- </div>
- );
- }
|