feat(web): 智能体对话重做(AGUI-3)
- 会话流:用户消息 → 一行进度 → 结论正文 → 没做的事 → 一行实际结果。进度行把一段里的工具调用、思考和过程中的话 聚成一行(1 次也折叠),按能力 verb/noun 人话计数(「查了站点、2 个下载任务和媒体库 · 1 项没做 · 3 秒」), 展开每步一行;工具名、调用 ID、幂等键、输入输出进技术信息。去掉回合头、「模型说的」重复卡和系统核实大卡。 - agent.plan / agent.propose_options 不作为工具调用出现(旧事件回放同样跳过);被拒调用不出红卡, 回合末尾一行「没做:检测站点,需要你批准 [批准并重试]」(非计划类能力建一次性持续授权后重试)。 - 方案卡:每个方案一个按钮,点一下就选定;推荐项第一位、主按钮;按钮写服务端给的动作要点;风险折叠; 「都不要…」点开才出原因框;选完收成一行。审批卡只留摘要和批准 / 拒绝,计划 ID、hash、版本、到期进技术信息。 - 会话页去掉右侧栏和页头目标,步骤 / 子会话 / 模型 / 预算 / ID 进顶部概况条;输入区只有输入框和发送 / 停止, / 指令 /先给方案、/重跑(「先看方案再执行」开关原本只是新建时的本地状态,不需要迁移)。 - 抽屉去掉「当前 / 历史」Tab:头部「历史」弹层(最近 20 条)+「新对话」;上下文显示成名称标签,可去掉。 - 会话列表:搜索 + 全部 / 等你处理 / 失败;等你处理置顶,子会话收在父会话下,正常结束不加徽章。 - 入口页与空态给 3 个快捷目标。mock 加审查报告 3 个示例的演示数据;smoke 改测历史弹层。 - 组件测试:聚合、隐藏、拒绝一行、方案一键选定、历史置顶与子会话、指令。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2ab6d452dd
commit
6fcf82afb0
17 files changed
+2381
-1846
No files matched your search
@@ -1,11 +1,18 @@
|
||||
import { type AgentPlanStep, CAPABILITY_CATALOG, type KnownCapabilityId, type RiskLevel } from "@umt/contracts";
|
||||
import { ListChecks, MessageCircleQuestion, Star } from "lucide-react";
|
||||
import {
|
||||
type AgentOptionView,
|
||||
type AgentPlanStep,
|
||||
CAPABILITY_CATALOG,
|
||||
type KnownCapabilityId,
|
||||
type RiskLevel,
|
||||
} from "@umt/contracts";
|
||||
import { ChevronRight, ListChecks, LoaderCircle, MessageCircleQuestion } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useApproval, useChooseOption, useDecideApproval, useIsAdminSession } from "~/api/agent.ts";
|
||||
import { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/approval-card.tsx";
|
||||
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
|
||||
import { type TodoItem, type TodoItemStatus, TodoList } from "~/components/agentui/todo-list.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
@@ -28,7 +35,8 @@ export function planTodoItems(steps: AgentPlanStep[]): TodoItem[] {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
status: STEP_STATUS[s.status],
|
||||
...(s.note ? { detail: <span title={s.note}>{s.note}</span> } : {}),
|
||||
// 备注只在失败时显示(失败原因);其余备注是模型的过程说明,不上屏
|
||||
...(s.note && s.status === "failed" ? { detail: <span title={s.note}>{s.note}</span> } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -90,163 +98,206 @@ export function RunPlan({
|
||||
|
||||
// ── 方案选择 ──
|
||||
|
||||
const NONE = "__none__";
|
||||
/** 方案排序:推荐项放第一位 */
|
||||
export function orderOptions<T extends { id: string }>(options: T[], recommendedId: string | null): T[] {
|
||||
const rec = options.find((o) => o.id === recommendedId);
|
||||
return rec ? [rec, ...options.filter((o) => o !== rec)] : options;
|
||||
}
|
||||
|
||||
/** agent.propose_options 的选择卡:单选一个方案,或"都不要"并写原因;选定后会话继续 */
|
||||
/**
|
||||
* 方案按钮上的动作要点:「提交下载 · 《沙丘:第二部》· 12.0 GB」;标题已经是动作名时只写对象和大小。
|
||||
* 没有动作时用模型给的估算代价。
|
||||
*/
|
||||
export function optionPoint(o: AgentOptionView): string | null {
|
||||
if (o.action) {
|
||||
const label = o.title.includes(o.action.label) ? null : o.action.label;
|
||||
return [label, o.action.detail].filter(Boolean).join(" · ") || null;
|
||||
}
|
||||
return o.estimatedCost ?? null;
|
||||
}
|
||||
|
||||
/** 超时说明里的时间:当天只写时分 */
|
||||
function deadlineText(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toDateString() === new Date().toDateString()
|
||||
? d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })
|
||||
: formatDateTime(iso);
|
||||
}
|
||||
|
||||
/**
|
||||
* agent.propose_options 的方案卡(AGUI-3):每个方案一个按钮,点一下就选定;推荐项在第一位、是主按钮。
|
||||
* 绑定了计划的方案写明动作要点,管理员选定即批准(服务端核对绑定)。风险折叠;「都不要…」点开才出原因框。
|
||||
*/
|
||||
export function OptionsCard({ runId, view, waiting }: { runId: string; view: OptionsView; waiting: boolean }) {
|
||||
const name = useId();
|
||||
const choose = useChooseOption();
|
||||
const [picked, setPicked] = useState<string>(view.recommendedId ?? "");
|
||||
const riskId = useId();
|
||||
const [riskOpen, setRiskOpen] = useState(false);
|
||||
const [noneOpen, setNoneOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [picking, setPicking] = useState<string | null>(null);
|
||||
const [sent, setSent] = useState<{ optionId: string | null; reason: string } | null>(null);
|
||||
const resolved = view.resolved;
|
||||
// 已作答但会话还没恢复(事件里没有 options_resolved):按本地提交结果显示
|
||||
const closed = !!resolved || !!sent || !waiting;
|
||||
const chosenId = resolved ? resolved.choice : sent ? sent.optionId : null;
|
||||
const submit = () => {
|
||||
const optionId = picked === NONE ? null : picked;
|
||||
if (!picked) return;
|
||||
const options = orderOptions(view.options, view.recommendedId);
|
||||
const risks = options.flatMap((o) => o.risks.map((r) => ({ key: `${o.id}:${r}`, option: o.title, text: r })));
|
||||
const submit = (optionId: string | null) => {
|
||||
if (optionId === null && !reason.trim()) {
|
||||
toast.error("写一句不要的原因,智能体按它调整");
|
||||
return;
|
||||
}
|
||||
setPicking(optionId ?? "__none__");
|
||||
choose.mutate(
|
||||
{ runId, decisionId: view.decisionId, optionId, ...(optionId === null ? { reason: reason.trim() } : {}) },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSent({ optionId, reason: reason.trim() });
|
||||
toast.success(optionId ? "已选定,智能体继续执行" : "已退回,智能体调整方案");
|
||||
const o = view.options.find((x) => x.id === optionId);
|
||||
toast.success(optionId ? (o?.action?.approves ? "已选定并批准" : "已选定") : "已退回,智能体调整方案");
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
onSettled: () => setPicking(null),
|
||||
},
|
||||
);
|
||||
};
|
||||
const label = (id: string | null) => {
|
||||
const o = view.options.find((x) => x.id === id);
|
||||
if (!o) return id ?? "";
|
||||
const point = optionPoint(o);
|
||||
return point && o.action ? `${o.title} · ${point}` : o.title;
|
||||
};
|
||||
const outcome = resolved
|
||||
? resolved.outcome === "chosen"
|
||||
? `${resolved.by === "timeout" ? "超时自动选择" : "已选择"}:${view.options.find((o) => o.id === resolved.choice)?.title ?? resolved.choice}`
|
||||
? `${resolved.by === "timeout" ? "超时自动选了" : "已选"}:${label(resolved.choice)}`
|
||||
: resolved.outcome === "stop"
|
||||
? "超时未选,按设置停止"
|
||||
? "超时没选,已停止"
|
||||
: `都不要${resolved.reason ? `:${resolved.reason}` : ""}`
|
||||
: sent
|
||||
? sent.optionId
|
||||
? `已选择:${view.options.find((o) => o.id === sent.optionId)?.title ?? sent.optionId},等待智能体继续`
|
||||
: `都不要:${sent.reason},等待智能体调整`
|
||||
? `已选:${label(sent.optionId)}`
|
||||
: `都不要:${sent.reason}`
|
||||
: !waiting
|
||||
? "会话已不在等待这个选择"
|
||||
? "已不再等这个选择"
|
||||
: null;
|
||||
|
||||
if (closed)
|
||||
return (
|
||||
<p
|
||||
data-slot="options-resolved"
|
||||
className="flex w-full min-w-0 items-start gap-2 rounded-xl border border-border/70 px-3 py-2 text-caption text-muted-foreground"
|
||||
>
|
||||
<MessageCircleQuestion aria-hidden className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span className="min-w-0 break-words">
|
||||
<span className="text-foreground/85">{view.question}</span> · {outcome}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="选择方案"
|
||||
className={cn(
|
||||
"w-full min-w-0 rounded-xl border p-3",
|
||||
closed ? "border-border/70" : "border-warning/40 bg-warning/[0.06]",
|
||||
)}
|
||||
>
|
||||
<section aria-label="选择方案" className="w-full min-w-0 rounded-xl border border-warning/40 bg-warning/[0.06] p-3">
|
||||
<p className="flex items-start gap-2 text-caption font-medium text-foreground">
|
||||
<MessageCircleQuestion aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 break-words">{view.question}</span>
|
||||
</p>
|
||||
<fieldset className="mt-2.5 space-y-1.5" disabled={closed || choose.isPending}>
|
||||
<legend className="sr-only">{view.question}</legend>
|
||||
{view.options.map((o) => {
|
||||
const selected = closed ? chosenId === o.id : picked === o.id;
|
||||
<ul className="mt-2.5 flex flex-col gap-1.5">
|
||||
{options.map((o) => {
|
||||
const recommended = o.id === view.recommendedId;
|
||||
const point = optionPoint(o);
|
||||
return (
|
||||
<label
|
||||
key={o.id}
|
||||
className={cn(
|
||||
"flex min-w-0 cursor-pointer gap-2.5 rounded-lg border border-border/70 px-3 py-2 transition-colors duration-micro has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
|
||||
selected && "border-foreground/40 bg-foreground/[0.04]",
|
||||
closed && !selected && "opacity-60",
|
||||
closed && "cursor-default",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={o.id}
|
||||
checked={selected}
|
||||
onChange={() => setPicked(o.id)}
|
||||
className="mt-1 size-3.5 shrink-0 accent-foreground"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-caption font-medium text-foreground">{o.title}</span>
|
||||
{view.recommendedId === o.id ? (
|
||||
<StatusBadge>
|
||||
<Star className="size-3" />
|
||||
推荐
|
||||
</StatusBadge>
|
||||
<li key={o.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={choose.isPending}
|
||||
onClick={() => submit(o.id)}
|
||||
data-recommended={recommended || undefined}
|
||||
className={cn(
|
||||
"flex w-full min-w-0 cursor-pointer flex-col gap-0.5 rounded-lg border px-3 py-2 text-left outline-none transition-colors duration-micro focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-60",
|
||||
recommended
|
||||
? "pill-primary border-transparent"
|
||||
: "border-border/70 bg-background/40 hover:bg-foreground/[0.04]",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-caption font-medium">
|
||||
{picking === o.id ? (
|
||||
<LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{o.estimatedCost ? <StatusBadge>估算 {o.estimatedCost}</StatusBadge> : null}
|
||||
<span className="min-w-0 break-words">{o.title}</span>
|
||||
{point ? <span className="font-normal opacity-80">· {point}</span> : null}
|
||||
{recommended ? <span className="font-normal opacity-80">(推荐)</span> : null}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-meta break-words text-muted-foreground">{o.summary}</span>
|
||||
{o.risks.length ? (
|
||||
<ul className="mt-1 space-y-0.5 text-meta text-warning">
|
||||
{o.risks.map((r) => (
|
||||
<li key={r} className="break-words">
|
||||
风险:{r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
<span className={cn("text-meta break-words", recommended ? "opacity-80" : "text-muted-foreground")}>
|
||||
{o.summary}
|
||||
{o.action ? (o.action.approves ? "。选定即执行。" : "。执行前还会再确认。") : ""}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{closed ? null : (
|
||||
<label
|
||||
className={cn(
|
||||
"flex min-w-0 cursor-pointer gap-2.5 rounded-lg border border-dashed border-border px-3 py-2 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
|
||||
picked === NONE && "border-foreground/40",
|
||||
)}
|
||||
</ul>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground">
|
||||
{risks.length ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={riskOpen}
|
||||
aria-controls={riskId}
|
||||
onClick={() => setRiskOpen((v) => !v)}
|
||||
className="inline-flex cursor-pointer items-center gap-0.5 rounded-md outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={NONE}
|
||||
checked={picked === NONE}
|
||||
onChange={() => setPicked(NONE)}
|
||||
className="mt-1 size-3.5 shrink-0 accent-foreground"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 text-caption text-foreground">都不要,说明原因</span>
|
||||
</label>
|
||||
)}
|
||||
</fieldset>
|
||||
{!closed && picked === NONE ? (
|
||||
<Textarea
|
||||
className="mt-2 min-h-16 text-caption"
|
||||
value={reason}
|
||||
maxLength={1000}
|
||||
aria-label="不要的原因"
|
||||
placeholder="例如:体积太大,只要 WEB-DL"
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
{closed ? (
|
||||
<p className="mt-2 text-meta text-muted-foreground">{outcome}</p>
|
||||
) : (
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" onClick={submit} disabled={!picked || choose.isPending}>
|
||||
{picked === NONE ? "退回" : "选定"}
|
||||
{risks.length} 条风险
|
||||
<ChevronRight aria-hidden className={cn("size-3.5", CHEVRON_CLASS, riskOpen && "rotate-90")} />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={noneOpen}
|
||||
onClick={() => setNoneOpen((v) => !v)}
|
||||
className="cursor-pointer rounded-md underline-offset-2 outline-none hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
都不要…
|
||||
</button>
|
||||
<span className="ml-auto">
|
||||
{deadlineText(view.expiresAt)} 前没选,
|
||||
{view.timeoutAction === "recommended" ? "自动选推荐项" : "智能体停止"}
|
||||
</span>
|
||||
</div>
|
||||
<Disclosure id={riskId} open={riskOpen}>
|
||||
<ul className="mt-1.5 space-y-0.5 text-meta text-warning">
|
||||
{risks.map((r) => (
|
||||
<li key={r.key} className="break-words">
|
||||
{r.option}:{r.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Disclosure>
|
||||
{noneOpen ? (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<Textarea
|
||||
className="min-h-16 text-caption"
|
||||
value={reason}
|
||||
maxLength={1000}
|
||||
aria-label="不要的原因"
|
||||
placeholder="例如:体积太大,只要 WEB-DL"
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="self-start"
|
||||
disabled={!reason.trim() || choose.isPending}
|
||||
onClick={() => submit(null)}
|
||||
>
|
||||
退回
|
||||
</Button>
|
||||
<span className="text-meta text-muted-foreground">
|
||||
{formatDateTime(view.expiresAt)} 前没选,
|
||||
{view.timeoutAction === "recommended" ? "自动选推荐项" : "智能体停止"}。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 步骤处的审批卡 ──
|
||||
|
||||
const RISK: Record<RiskLevel, { label: string; tone: StatusTone }> = {
|
||||
low: { label: "低风险", tone: "neutral" },
|
||||
medium: { label: "中风险", tone: "warning" },
|
||||
high: { label: "高风险", tone: "destructive" },
|
||||
};
|
||||
const RISK_LABEL: Record<RiskLevel, string> = { low: "低", medium: "中", high: "高" };
|
||||
const PLAN_LABEL: Record<string, string> = {
|
||||
acquisition_plan: "下载计划",
|
||||
organize_plan: "整理计划",
|
||||
@@ -261,11 +312,12 @@ const RESOLVED_STATUS: Record<string, ApprovalCardStatus> = {
|
||||
consumed: "consumed",
|
||||
};
|
||||
|
||||
/** 需审批的能力调用:在对应步骤处出审批卡(计划摘要、payload hash、风险、到期);批准 / 拒绝后会话继续 */
|
||||
/** 需审批的能力调用:只显示摘要和【批准】【拒绝】;计划 ID、payload hash、版本、到期进技术信息 */
|
||||
export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTitle?: string | null }) {
|
||||
const admin = useIsAdminSession();
|
||||
const live = useApproval(view.approvalId);
|
||||
const decide = useDecideApproval();
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const a = live.data;
|
||||
const expired = new Date(view.expiresAt).getTime() < Date.now();
|
||||
@@ -295,15 +347,10 @@ export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTi
|
||||
status={status}
|
||||
title={`批准「${cap?.title ?? view.capabilityId}」?`}
|
||||
description={view.summary}
|
||||
badges={
|
||||
<>
|
||||
<StatusBadge tone={RISK[view.riskLevel].tone}>{RISK[view.riskLevel].label}</StatusBadge>
|
||||
{stepTitle ? <StatusBadge>步骤 · {stepTitle}</StatusBadge> : null}
|
||||
</>
|
||||
}
|
||||
onApprove={() => act("approved")}
|
||||
onReject={() => act("rejected")}
|
||||
disabledReason={admin ? undefined : "只有控制台管理员会话能批准或拒绝"}
|
||||
onReject={() => (rejecting ? act("rejected") : setRejecting(true))}
|
||||
rejectLabel={rejecting ? "确认拒绝" : "拒绝"}
|
||||
disabledReason={admin ? undefined : "只有管理员能批准或拒绝"}
|
||||
result={
|
||||
status === "rejected" && decidedReason
|
||||
? `已拒绝:${decidedReason}`
|
||||
@@ -312,30 +359,27 @@ export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTi
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
|
||||
<dt className="text-muted-foreground">计划</dt>
|
||||
<dd className="min-w-0 font-mono break-all text-foreground/85">
|
||||
{PLAN_LABEL[view.planType] ?? view.planType} · {view.planId}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">payload hash</dt>
|
||||
<dd className="min-w-0 font-mono break-all text-foreground/85">{view.payloadHash}</dd>
|
||||
<dt className="text-muted-foreground">版本</dt>
|
||||
<dd className="text-foreground/85 tabular-nums">r{view.expectedRevision}</dd>
|
||||
<dt className="text-muted-foreground">到期</dt>
|
||||
<dd className={cn("text-foreground/85", expired && status === "expired" && "text-destructive")}>
|
||||
{formatDateTime(view.expiresAt)}
|
||||
</dd>
|
||||
</dl>
|
||||
{status === "pending" && admin ? (
|
||||
{rejecting && status === "pending" ? (
|
||||
<Textarea
|
||||
className="mt-2 min-h-14 text-caption"
|
||||
className="mb-2 min-h-14 text-caption"
|
||||
value={reason}
|
||||
maxLength={2000}
|
||||
aria-label="拒绝理由"
|
||||
placeholder="拒绝时写理由或建议(可选),智能体按它调整"
|
||||
placeholder="理由或建议(可选),智能体按它调整"
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
<TechInfo
|
||||
items={[
|
||||
{ label: "步骤", value: stepTitle ?? null },
|
||||
{ label: "风险", value: RISK_LABEL[view.riskLevel] },
|
||||
{ label: "计划", value: `${PLAN_LABEL[view.planType] ?? view.planType} · ${view.planId}`, wide: true },
|
||||
{ label: "payload hash", value: view.payloadHash, wide: true },
|
||||
{ label: "版本", value: `r${view.expectedRevision}` },
|
||||
{ label: "到期", value: formatDateTime(view.expiresAt) },
|
||||
{ label: "审批 ID", value: view.approvalId, wide: true },
|
||||
]}
|
||||
/>
|
||||
</ApprovalCard>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user