feat: sendToDock — external-send bus into the global dock
Any feature surface can open the dock and send a message as the user
("present this record", "ask about this file"). Goes through the
normal send path; supports display≠wire for long payloads; buffers one
message across mount races and queues until the agent resolves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
287
src/index.tsx
287
src/index.tsx
@@ -7,22 +7,24 @@
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// EXPORTS
|
// EXPORTS
|
||||||
// Component: AgentDock
|
// Component: AgentDock
|
||||||
// Types: AgentDockProps, AgentDockTransport, AgentRef, PageContext
|
// Functions: sendToDock
|
||||||
|
// Types: AgentDockProps, AgentDockTransport, AgentRef, PageContext,
|
||||||
|
// DockMessage
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Loader2, Maximize2, Plus, Send, Sparkles, X } from "lucide-react"
|
import { Loader2, Maximize2, Plus, Send, Sparkles, X } from "lucide-react";
|
||||||
|
|
||||||
import { MessageBody } from "@crema/agent-ui"
|
import { MessageBody } from "@crema/agent-ui";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Public types */
|
/* Public types */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export type AgentRef = {
|
export type AgentRef = {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
/** Optional "try saying…" prompts shown to the user above the empty
|
/** Optional "try saying…" prompts shown to the user above the empty
|
||||||
* composer on first chat. Click sends the prompt verbatim. The dock
|
* composer on first chat. Click sends the prompt verbatim. The dock
|
||||||
* doesn't curate or persist these — the host app decides what to
|
* doesn't curate or persist these — the host app decides what to
|
||||||
@@ -30,8 +32,8 @@ export type AgentRef = {
|
|||||||
*
|
*
|
||||||
* Limit ~4 to keep the panel light; the dock won't truncate but
|
* Limit ~4 to keep the panel light; the dock won't truncate but
|
||||||
* more rows make the chat feel cluttered. */
|
* more rows make the chat feel cluttered. */
|
||||||
starter_suggestions?: string[]
|
starter_suggestions?: string[];
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Structured page-awareness signal. Travels to the platform as its own
|
/** Structured page-awareness signal. Travels to the platform as its own
|
||||||
* field — never concatenated into the user message — so page content
|
* field — never concatenated into the user message — so page content
|
||||||
@@ -39,12 +41,12 @@ export type AgentRef = {
|
|||||||
* scraped DOM text. */
|
* scraped DOM text. */
|
||||||
export type PageContext = {
|
export type PageContext = {
|
||||||
/** App route the user is currently viewing, e.g. `/invoices`. */
|
/** App route the user is currently viewing, e.g. `/invoices`. */
|
||||||
route: string
|
route: string;
|
||||||
/** Optional stable resource identifier — type + id, app-authored. */
|
/** Optional stable resource identifier — type + id, app-authored. */
|
||||||
resource?: { type: string; id: string }
|
resource?: { type: string; id: string };
|
||||||
/** Optional short, app-authored label (a stable descriptor). */
|
/** Optional short, app-authored label (a stable descriptor). */
|
||||||
label?: string
|
label?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
/** The single platform call the dock needs. The app implements this —
|
/** The single platform call the dock needs. The app implements this —
|
||||||
* typically by delegating to its `@crema/arcadia-agents-client` instance
|
* typically by delegating to its `@crema/arcadia-agents-client` instance
|
||||||
@@ -54,37 +56,63 @@ export interface AgentDockTransport {
|
|||||||
chat(
|
chat(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
req: {
|
req: {
|
||||||
user_message: string
|
user_message: string;
|
||||||
conversation_id?: string
|
conversation_id?: string;
|
||||||
page_context?: PageContext
|
page_context?: PageContext;
|
||||||
},
|
},
|
||||||
): Promise<{
|
): Promise<{
|
||||||
message: string
|
message: string;
|
||||||
conversation_id: string
|
conversation_id: string;
|
||||||
citations?: { chunk_id: string }[]
|
citations?: { chunk_id: string }[];
|
||||||
}>
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A message pushed into the dock from elsewhere in the app. `display` is
|
||||||
|
* what the user bubble shows; `wire` is what the agent receives — they
|
||||||
|
* differ when the payload is long (e.g. a presented record: a short label
|
||||||
|
* on screen, the full Markdown on the wire). */
|
||||||
|
export type DockMessage = { display: string; wire: string };
|
||||||
|
|
||||||
|
/* External-send bus: lets any feature surface push a message into the
|
||||||
|
* global dock ("present this record", "ask about this file"). The dock
|
||||||
|
* opens and the message goes through its normal send path — page context,
|
||||||
|
* threading and rich-block rendering all apply. One message is buffered
|
||||||
|
* if no dock is mounted yet (mount races during navigation). */
|
||||||
|
const dockListeners = new Set<(m: DockMessage) => void>();
|
||||||
|
let pendingDockMessage: DockMessage | null = null;
|
||||||
|
|
||||||
|
/** Open the global dock and send `message` as the user. Accepts a plain
|
||||||
|
* string (shown verbatim) or a `{display, wire}` pair. */
|
||||||
|
export function sendToDock(message: string | DockMessage): void {
|
||||||
|
const m =
|
||||||
|
typeof message === "string" ? { display: message, wire: message } : message;
|
||||||
|
if (dockListeners.size === 0) {
|
||||||
|
pendingDockMessage = m;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const l of dockListeners) l(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentDockProps {
|
export interface AgentDockProps {
|
||||||
/** Platform transport — auth is baked in here by the app. */
|
/** Platform transport — auth is baked in here by the app. */
|
||||||
transport: AgentDockTransport
|
transport: AgentDockTransport;
|
||||||
/** Resolve the agent(s) the user can talk to. The dock renders a picker
|
/** Resolve the agent(s) the user can talk to. The dock renders a picker
|
||||||
* iff more than one is returned; the first (or `defaultAgentId`) is the
|
* iff more than one is returned; the first (or `defaultAgentId`) is the
|
||||||
* default. A rejected promise disables the dock (renders nothing) —
|
* default. A rejected promise disables the dock (renders nothing) —
|
||||||
* e.g. a tenant with no personal-agent template. */
|
* e.g. a tenant with no personal-agent template. */
|
||||||
resolveAgents: () => Promise<AgentRef[]>
|
resolveAgents: () => Promise<AgentRef[]>;
|
||||||
/** Preferred default agent id when `resolveAgents` returns several. */
|
/** Preferred default agent id when `resolveAgents` returns several. */
|
||||||
defaultAgentId?: string
|
defaultAgentId?: string;
|
||||||
/** Called on every send. Return the structured page context to attach,
|
/** Called on every send. Return the structured page context to attach,
|
||||||
* or null to send none. The app decides what counts as "context". */
|
* or null to send none. The app decides what counts as "context". */
|
||||||
getPageContext?: () => PageContext | null
|
getPageContext?: () => PageContext | null;
|
||||||
/** ⤢ handler. Omit to hide the expand button. The app owns what expand
|
/** ⤢ handler. Omit to hide the expand button. The app owns what expand
|
||||||
* means — navigate to a full chat route, mount its own modal, etc. */
|
* means — navigate to a full chat route, mount its own modal, etc. */
|
||||||
onExpand?: (a: { agentId: string; conversationId: string | null }) => void
|
onExpand?: (a: { agentId: string; conversationId: string | null }) => void;
|
||||||
/** App-computed per-route suppression — the app knows its own routes. */
|
/** App-computed per-route suppression — the app knows its own routes. */
|
||||||
hidden?: boolean
|
hidden?: boolean;
|
||||||
/** localStorage namespace for the open/closed state. */
|
/** localStorage namespace for the open/closed state. */
|
||||||
storageKey?: string
|
storageKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -93,16 +121,24 @@ export interface AgentDockProps {
|
|||||||
|
|
||||||
type Bubble =
|
type Bubble =
|
||||||
| { role: "user"; text: string }
|
| { role: "user"; text: string }
|
||||||
| { role: "assistant"; text: string; finished: boolean; error: string | null }
|
| {
|
||||||
|
role: "assistant";
|
||||||
|
text: string;
|
||||||
|
finished: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_STORAGE_KEY = "crema.agent-dock.open"
|
const DEFAULT_STORAGE_KEY = "crema.agent-dock.open";
|
||||||
|
|
||||||
function pickDefault(agents: AgentRef[], preferredId?: string): AgentRef | null {
|
function pickDefault(
|
||||||
|
agents: AgentRef[],
|
||||||
|
preferredId?: string,
|
||||||
|
): AgentRef | null {
|
||||||
if (preferredId) {
|
if (preferredId) {
|
||||||
const match = agents.find((a) => a.id === preferredId)
|
const match = agents.find((a) => a.id === preferredId);
|
||||||
if (match) return match
|
if (match) return match;
|
||||||
}
|
}
|
||||||
return agents[0] ?? null
|
return agents[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -119,122 +155,154 @@ export function AgentDock({
|
|||||||
storageKey = DEFAULT_STORAGE_KEY,
|
storageKey = DEFAULT_STORAGE_KEY,
|
||||||
}: AgentDockProps) {
|
}: AgentDockProps) {
|
||||||
const [open, setOpen] = useState<boolean>(() => {
|
const [open, setOpen] = useState<boolean>(() => {
|
||||||
if (typeof window === "undefined") return false
|
if (typeof window === "undefined") return false;
|
||||||
return localStorage.getItem(storageKey) === "1"
|
return localStorage.getItem(storageKey) === "1";
|
||||||
})
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(storageKey, open ? "1" : "0")
|
localStorage.setItem(storageKey, open ? "1" : "0");
|
||||||
}, [open, storageKey])
|
}, [open, storageKey]);
|
||||||
|
|
||||||
const [agents, setAgents] = useState<AgentRef[] | null>(null)
|
const [agents, setAgents] = useState<AgentRef[] | null>(null);
|
||||||
const [agentId, setAgentId] = useState<string | null>(null)
|
const [agentId, setAgentId] = useState<string | null>(null);
|
||||||
const [resolveFailed, setResolveFailed] = useState(false)
|
const [resolveFailed, setResolveFailed] = useState(false);
|
||||||
const [bubbles, setBubbles] = useState<Bubble[]>([])
|
const [bubbles, setBubbles] = useState<Bubble[]>([]);
|
||||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||||
const [draft, setDraft] = useState("")
|
const [draft, setDraft] = useState("");
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false;
|
||||||
// Clear any prior failure so a retry (e.g. tenant id arrives after
|
// Clear any prior failure so a retry (e.g. tenant id arrives after
|
||||||
// the first render) gets a clean slate — otherwise the flag latches
|
// the first render) gets a clean slate — otherwise the flag latches
|
||||||
// and the FAB stays hidden forever even once resolution succeeds.
|
// and the FAB stays hidden forever even once resolution succeeds.
|
||||||
setResolveFailed(false)
|
setResolveFailed(false);
|
||||||
resolveAgents()
|
resolveAgents()
|
||||||
.then((list) => {
|
.then((list) => {
|
||||||
if (cancelled) return
|
if (cancelled) return;
|
||||||
setAgents(list)
|
setAgents(list);
|
||||||
setAgentId((cur) => cur ?? pickDefault(list, defaultAgentId)?.id ?? null)
|
setAgentId(
|
||||||
|
(cur) => cur ?? pickDefault(list, defaultAgentId)?.id ?? null,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setResolveFailed(true)
|
if (!cancelled) setResolveFailed(true);
|
||||||
})
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true;
|
||||||
}
|
};
|
||||||
}, [resolveAgents, defaultAgentId])
|
}, [resolveAgents, defaultAgentId]);
|
||||||
|
|
||||||
// Switching agent starts a fresh thread — don't cross-contaminate the
|
// Switching agent starts a fresh thread — don't cross-contaminate the
|
||||||
// assistant's context across agents.
|
// assistant's context across agents.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBubbles([])
|
setBubbles([]);
|
||||||
setConversationId(null)
|
setConversationId(null);
|
||||||
}, [agentId])
|
}, [agentId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollRef.current?.scrollTo({
|
scrollRef.current?.scrollTo({
|
||||||
top: scrollRef.current.scrollHeight,
|
top: scrollRef.current.scrollHeight,
|
||||||
behavior: "auto",
|
behavior: "auto",
|
||||||
})
|
});
|
||||||
}, [bubbles])
|
}, [bubbles]);
|
||||||
|
|
||||||
// `display` is what shows in the user bubble; `wire` is what the agent
|
// `display` is what shows in the user bubble; `wire` is what the agent
|
||||||
// receives. They differ only for rich-block action clicks, where the
|
// receives. They differ only for rich-block action clicks, where the
|
||||||
// button label is shown but the action's `value` is sent.
|
// button label is shown but the action's `value` is sent.
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
async (display: string, wire: string) => {
|
async (display: string, wire: string) => {
|
||||||
if (!agentId || sending) return
|
if (!agentId || sending) return;
|
||||||
const trimmed = wire.trim()
|
const trimmed = wire.trim();
|
||||||
if (!trimmed) return
|
if (!trimmed) return;
|
||||||
setBubbles((bs) => [
|
setBubbles((bs) => [
|
||||||
...bs,
|
...bs,
|
||||||
{ role: "user", text: display },
|
{ role: "user", text: display },
|
||||||
{ role: "assistant", text: "", finished: false, error: null },
|
{ role: "assistant", text: "", finished: false, error: null },
|
||||||
])
|
]);
|
||||||
setSending(true)
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
// Non-streaming on purpose: the streaming path injects
|
// Non-streaming on purpose: the streaming path injects
|
||||||
// stream_options.include_usage, which DeepSeek/Qwen/LM Studio
|
// stream_options.include_usage, which DeepSeek/Qwen/LM Studio
|
||||||
// reject. Dock answers are short; ⤢ opens the full chat surface.
|
// reject. Dock answers are short; ⤢ opens the full chat surface.
|
||||||
const pc = getPageContext?.() ?? undefined
|
const pc = getPageContext?.() ?? undefined;
|
||||||
const r = await transport.chat(agentId, {
|
const r = await transport.chat(agentId, {
|
||||||
user_message: trimmed,
|
user_message: trimmed,
|
||||||
conversation_id: conversationId ?? undefined,
|
conversation_id: conversationId ?? undefined,
|
||||||
page_context: pc ?? undefined,
|
page_context: pc ?? undefined,
|
||||||
})
|
});
|
||||||
if (r.conversation_id && !conversationId)
|
if (r.conversation_id && !conversationId)
|
||||||
setConversationId(r.conversation_id)
|
setConversationId(r.conversation_id);
|
||||||
setBubbles((bs) => {
|
setBubbles((bs) => {
|
||||||
const last = bs[bs.length - 1]
|
const last = bs[bs.length - 1];
|
||||||
if (!last || last.role !== "assistant") return bs
|
if (!last || last.role !== "assistant") return bs;
|
||||||
const next = [...bs]
|
const next = [...bs];
|
||||||
next[next.length - 1] = { ...last, text: r.message, finished: true }
|
next[next.length - 1] = { ...last, text: r.message, finished: true };
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e)
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
setBubbles((bs) => {
|
setBubbles((bs) => {
|
||||||
const last = bs[bs.length - 1]
|
const last = bs[bs.length - 1];
|
||||||
if (!last || last.role !== "assistant") return bs
|
if (!last || last.role !== "assistant") return bs;
|
||||||
const next = [...bs]
|
const next = [...bs];
|
||||||
next[next.length - 1] = { ...last, error: msg, finished: true }
|
next[next.length - 1] = { ...last, error: msg, finished: true };
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false)
|
setSending(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[agentId, sending, conversationId, transport, getPageContext],
|
[agentId, sending, conversationId, transport, getPageContext],
|
||||||
)
|
);
|
||||||
|
|
||||||
const submitDraft = useCallback(() => {
|
const submitDraft = useCallback(() => {
|
||||||
const text = draft.trim()
|
const text = draft.trim();
|
||||||
if (!text) return
|
if (!text) return;
|
||||||
setDraft("")
|
setDraft("");
|
||||||
void sendMessage(text, text)
|
void sendMessage(text, text);
|
||||||
}, [draft, sendMessage])
|
}, [draft, sendMessage]);
|
||||||
|
|
||||||
|
// External sends (`sendToDock`): open the panel and run the message
|
||||||
|
// through the normal send path. Messages arriving before the agent has
|
||||||
|
// resolved (or while a send is in flight) queue locally and flush as
|
||||||
|
// soon as sendMessage can act.
|
||||||
|
const externalQueue = useRef<DockMessage[]>([]);
|
||||||
|
const [externalTick, setExternalTick] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onExternal = (m: DockMessage) => {
|
||||||
|
setOpen(true);
|
||||||
|
externalQueue.current.push(m);
|
||||||
|
setExternalTick((t) => t + 1);
|
||||||
|
};
|
||||||
|
dockListeners.add(onExternal);
|
||||||
|
if (pendingDockMessage) {
|
||||||
|
const m = pendingDockMessage;
|
||||||
|
pendingDockMessage = null;
|
||||||
|
onExternal(m);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
dockListeners.delete(onExternal);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!agentId || sending) return;
|
||||||
|
const next = externalQueue.current.shift();
|
||||||
|
if (next) void sendMessage(next.display, next.wire);
|
||||||
|
}, [externalTick, agentId, sending, sendMessage]);
|
||||||
|
|
||||||
function newThread() {
|
function newThread() {
|
||||||
if (sending) return
|
if (sending) return;
|
||||||
setBubbles([])
|
setBubbles([]);
|
||||||
setConversationId(null)
|
setConversationId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hidden || resolveFailed) return null
|
if (hidden || resolveFailed) return null;
|
||||||
|
|
||||||
const currentAgent = agents?.find((a) => a.id === agentId) ?? null
|
const currentAgent = agents?.find((a) => a.id === agentId) ?? null;
|
||||||
const title = currentAgent?.name ?? "Assistant"
|
const title = currentAgent?.name ?? "Assistant";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -304,9 +372,9 @@ export function AgentDock({
|
|||||||
dataAction="assistant-dock-expand"
|
dataAction="assistant-dock-expand"
|
||||||
disabled={!agentId}
|
disabled={!agentId}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!agentId) return
|
if (!agentId) return;
|
||||||
onExpand({ agentId, conversationId })
|
onExpand({ agentId, conversationId });
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Maximize2 className="size-4" />
|
<Maximize2 className="size-4" />
|
||||||
@@ -323,7 +391,10 @@ export function AgentDock({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex-1 space-y-4 overflow-y-auto p-4"
|
||||||
|
>
|
||||||
{bubbles.length === 0 ? (
|
{bubbles.length === 0 ? (
|
||||||
<div className="space-y-4 pt-6">
|
<div className="space-y-4 pt-6">
|
||||||
<p className="text-center text-sm text-[var(--foreground)]/80">
|
<p className="text-center text-sm text-[var(--foreground)]/80">
|
||||||
@@ -408,8 +479,8 @@ export function AgentDock({
|
|||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
submitDraft()
|
submitDraft();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={agentId ? "Message…" : "No agent selected"}
|
placeholder={agentId ? "Message…" : "No agent selected"}
|
||||||
@@ -438,7 +509,7 @@ export function AgentDock({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function IconButton({
|
function IconButton({
|
||||||
@@ -448,11 +519,11 @@ function IconButton({
|
|||||||
onClick,
|
onClick,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string;
|
||||||
dataAction: string
|
dataAction: string;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
onClick: () => void
|
onClick: () => void;
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -466,5 +537,5 @@ function IconButton({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user