From d225535728b9e9ee979f5d7e0655928bee68d2fb Mon Sep 17 00:00:00 2001
From: WhiteRaven11 <95498438+WhiteRaven11@users.noreply.github.com>
Date: Tue, 28 Jul 2026 17:52:07 +0300
Subject: [PATCH 1/3] Connect Design Agent generation notifications
---
packages/design-agent/src/CreativeCanvas.jsx | 48 +++++++++++++++++++-
1 file changed, 46 insertions(+), 2 deletions(-)
diff --git a/packages/design-agent/src/CreativeCanvas.jsx b/packages/design-agent/src/CreativeCanvas.jsx
index 12f1f92..998de5d 100644
--- a/packages/design-agent/src/CreativeCanvas.jsx
+++ b/packages/design-agent/src/CreativeCanvas.jsx
@@ -34,6 +34,14 @@ import Image from "next/image";
const API = "/api/v1/creative-agent";
+const GENERATION_TOOL_NAMES = new Set([
+ "generate_image",
+ "generate_video",
+ "image_to_video",
+ "edit_image",
+ "edit_video",
+ "enhance_image",
+]);
const formatTime = (dateStr) => {
if (!dateStr) return "";
@@ -80,11 +88,14 @@ export default function CreativeCanvas({
// userBalanceLabel: string like "$ 5.00" or "1200 credits" to show in the dropdown.
// If not provided, falls back to "$ {user.balance}".
userBalanceLabel = null,
+ onGenerationComplete,
+ onGenerationError,
}) {
const router = useRouter();
const searchParams = useSearchParams();
const inEmbedMode = isEmbed && !!embedCode;
const embedStorageKey = inEmbedMode ? `muapi_agent_session_${embedCode}` : null;
+ const notifiedGenerationEventsRef = useRef(new Set());
const [embedSessionId, setEmbedSessionId] = useState(() => {
if (typeof window === "undefined" || !embedStorageKey) return null;
return window.localStorage.getItem(embedStorageKey) || null;
@@ -310,7 +321,7 @@ export default function CreativeCanvas({
switch (ev.type) {
case "text": return { type: "text", content: p.content };
case "info": return { type: "info", content: p.content };
- case "error": return { type: "error", message: p.message };
+ case "error": return { type: "error", name: p.name, message: p.message };
case "tool_call": return { type: "tool_call", name: p.name, args: p.args };
case "tool_result": return { type: "tool_result", name: p.name, result: p.result, asset: p.asset };
case "plan_propose": return { type: "plan_propose", title: p.title, nodes: p.nodes, total_credits: p.total_credits };
@@ -368,7 +379,40 @@ export default function CreativeCanvas({
return arr;
});
- if (flat.type === "tool_call" && ["generate_image", "generate_video", "image_to_video", "edit_image", "edit_video", "enhance_image"].includes(flat.name)) {
+ const notificationKey = ev.id || [
+ flat.job_id,
+ flat.type,
+ flat.name,
+ flat.asset?.url,
+ flat.message,
+ ].filter(Boolean).join(":");
+
+ if (
+ notificationKey &&
+ !notifiedGenerationEventsRef.current.has(notificationKey)
+ ) {
+ if (flat.type === "error") {
+ notifiedGenerationEventsRef.current.add(notificationKey);
+ onGenerationError?.(flat.message || "Design Agent generation failed");
+ } else if (
+ flat.type === "tool_result" &&
+ GENERATION_TOOL_NAMES.has(flat.name)
+ ) {
+ notifiedGenerationEventsRef.current.add(notificationKey);
+ if (flat.result?.ok === false) {
+ onGenerationError?.(
+ flat.result?.error || "Design Agent generation failed",
+ );
+ } else {
+ onGenerationComplete?.({
+ url: flat.asset?.url || flat.result?.url || null,
+ type: flat.asset?.kind || "design",
+ });
+ }
+ }
+ }
+
+ if (flat.type === "tool_call" && GENERATION_TOOL_NAMES.has(flat.name)) {
// For edit-style tools, spawn the loader at the same spot the result
// will land at — beside the source asset (32px to its right). The
// source stays visible throughout. Keeps the loader and the final
From 80ff449ac99d178032dcaa3ba935fa96d05fb716 Mon Sep 17 00:00:00 2001
From: WhiteRaven11 <95498438+WhiteRaven11@users.noreply.github.com>
Date: Tue, 28 Jul 2026 18:17:45 +0300
Subject: [PATCH 2/3] Expose active Design Agent generation state
---
packages/design-agent/src/CreativeCanvas.jsx | 1542 +-----------------
1 file changed, 4 insertions(+), 1538 deletions(-)
diff --git a/packages/design-agent/src/CreativeCanvas.jsx b/packages/design-agent/src/CreativeCanvas.jsx
index 998de5d..5147111 100644
--- a/packages/design-agent/src/CreativeCanvas.jsx
+++ b/packages/design-agent/src/CreativeCanvas.jsx
@@ -88,6 +88,8 @@ export default function CreativeCanvas({
// userBalanceLabel: string like "$ 5.00" or "1200 credits" to show in the dropdown.
// If not provided, falls back to "$ {user.balance}".
userBalanceLabel = null,
+ onGenerationStart,
+ onGenerationEnd,
onGenerationComplete,
onGenerationError,
}) {
@@ -96,6 +98,7 @@ export default function CreativeCanvas({
const inEmbedMode = isEmbed && !!embedCode;
const embedStorageKey = inEmbedMode ? `muapi_agent_session_${embedCode}` : null;
const notifiedGenerationEventsRef = useRef(new Set());
+ const generationActivityEventIdsRef = useRef(new Set());
const [embedSessionId, setEmbedSessionId] = useState(() => {
if (typeof window === "undefined" || !embedStorageKey) return null;
return window.localStorage.getItem(embedStorageKey) || null;
@@ -382,1541 +385,4 @@ export default function CreativeCanvas({
const notificationKey = ev.id || [
flat.job_id,
flat.type,
- flat.name,
- flat.asset?.url,
- flat.message,
- ].filter(Boolean).join(":");
-
- if (
- notificationKey &&
- !notifiedGenerationEventsRef.current.has(notificationKey)
- ) {
- if (flat.type === "error") {
- notifiedGenerationEventsRef.current.add(notificationKey);
- onGenerationError?.(flat.message || "Design Agent generation failed");
- } else if (
- flat.type === "tool_result" &&
- GENERATION_TOOL_NAMES.has(flat.name)
- ) {
- notifiedGenerationEventsRef.current.add(notificationKey);
- if (flat.result?.ok === false) {
- onGenerationError?.(
- flat.result?.error || "Design Agent generation failed",
- );
- } else {
- onGenerationComplete?.({
- url: flat.asset?.url || flat.result?.url || null,
- type: flat.asset?.kind || "design",
- });
- }
- }
- }
-
- if (flat.type === "tool_call" && GENERATION_TOOL_NAMES.has(flat.name)) {
- // For edit-style tools, spawn the loader at the same spot the result
- // will land at — beside the source asset (32px to its right). The
- // source stays visible throughout. Keeps the loader and the final
- // asset position in sync — no visual jump on completion.
- //
- // generate_* (no source) keeps the default centre placement.
- let x, y;
- const a = flat.args || {};
- const srcLabel = a.image || a.video || a.audio;
- if (srcLabel && typeof srcLabel === "string" && srcLabel.startsWith("asset_")) {
- try {
- const cs = canvasRef.current?.getCanvasState?.();
- const srcNode = cs?.nodes?.find(n => n.asset_id === srcLabel);
- if (srcNode) {
- x = srcNode.x + (srcNode.w || 200) + 32;
- y = srcNode.y;
- }
- } catch {}
- }
- setActiveTasks(prev => [...prev, {
- taskId: `task-${Date.now()}-${Math.random()}`,
- modelName: flat.name,
- status: "processing",
- x, y,
- }]);
- }
-
- if (flat.type === "tool_result" || flat.type === "error") {
- setActiveTasks(prev => {
- const idx = prev.findIndex(t => t.modelName === flat.name);
- if (idx !== -1) {
- const next = [...prev];
- next.splice(idx, 1);
- return next;
- }
- return prev;
- });
-
- if (flat.asset) {
- setAssets(pa => {
- // Use a combination of label and url for reliable identification
- const idx = pa.findIndex(a =>
- (flat.asset.asset_label && a.asset_label === flat.asset.asset_label) ||
- (a.url === flat.asset.url)
- );
- if (idx !== -1) {
- const next = [...pa];
- next[idx] = { ...next[idx], ...flat.asset };
- return next;
- }
- return [...pa, flat.asset];
- });
-
- // Side-by-side placement: when a tool result carries source_asset_id,
- // drop the new asset just to the right of the source so both stay
- // visible. Source is preserved (the user can still see / branch
- // from it). Mark the new label-url as synced so the auto-sync
- // effect doesn't also drop it at canvas centre.
- const srcLabel = flat.result?.source_asset_id;
- const newLabel = flat.asset.asset_label;
- const newUrl = flat.asset.url;
- const newKind = flat.asset.kind || "image";
- const place = canvasRef.current?.placeNextToSource || canvasRef.current?.replaceAt;
- if (srcLabel && newLabel && newUrl && place) {
- place(srcLabel, newUrl, newKind, newLabel);
- syncedUrlsRef.current?.add?.(`${newLabel}-${newUrl}`);
- }
- }
- }
- };
-
- const resumePolling = async (jobId, assistantIdx) => {
- let cursor = 0;
- const POLL_INTERVAL = 1200;
- const MAX_DEAD_AIR = 6 * 60 * 1000;
- let lastProgress = Date.now();
-
- setBusy(true);
- while (true) {
- try {
- const { data } = await axios.get(`${API}/jobs/${jobId}/events`, {
- params: { since: cursor },
- headers: getHeaders(),
- });
- if (data.events?.length) {
- data.events.forEach(ev => processEvent({ ...ev, approved: data.approved }, assistantIdx));
- cursor = data.cursor || cursor;
- lastProgress = Date.now();
- }
- if (data.done) break;
- if (Date.now() - lastProgress > MAX_DEAD_AIR) throw new Error("Stalled");
- } catch (err) {
- if (Date.now() - lastProgress > MAX_DEAD_AIR) break;
- }
- await new Promise(r => setTimeout(r, POLL_INTERVAL));
- }
- setBusy(false);
- loadAssets();
- // Persist final state
- setMessages(prev => {
- const next = [...prev];
- axios.patch(`${API}/sessions/${sessionId}/messages`, { messages: next }, { headers: getHeaders() }).catch(() => {});
- return next;
- });
- };
-
- const handleJobAction = async (jobId, action) => {
- try {
- await axios.post(`${API}/jobs/${jobId}/${action}`, {}, { headers: getHeaders() });
- toast.success(`Job ${action}ed`);
-
- // Hide the approval card in the UI
- setMessages(prev => prev.map(m => ({
- ...m,
- events: (m.events || []).map(e =>
- e.job_id === jobId && (
- (e.type === "info" && (e.content?.includes("approval") || e.content?.includes("confirmation"))) ||
- (e.type === "plan_propose")
- )
- ? { ...e, handled: true }
- : e
- )
- })));
- } catch (err) {
- toast.error(err.response?.data?.detail || `Failed to ${action} job`);
- }
- };
-
- const loadHistory = async () => {
- try {
- const { data } = await axios.get(`${API}/sessions/${sessionId}/messages`, { headers: getHeaders() });
- if (data && data.length > 0) {
- // Cleanup: Hide approval cards that already have results or are for inactive jobs
- const cleaned = data.map(m => ({
- ...m,
- events: (m.events || []).map((e, idx, arr) => {
- if ((e.type === "info" && (e.content?.includes("approval") || e.content?.includes("confirmation"))) || e.type === "plan_propose") {
- const hasResult = arr.slice(idx + 1).some(next =>
- next.job_id === e.job_id && (next.type === "tool_result" || next.type === "error")
- );
- if (hasResult) return { ...e, handled: true };
- }
- return e;
- })
- }));
- setMessages(cleaned);
- checkActiveJobs(cleaned);
- } else {
- setMessages([{ role: "assistant", content: `Session ready — what shall we create?`, timestamp: new Date().toISOString() }]);
- }
- } catch {
- setMessages([{ role: "assistant", content: `Session ready — what shall we create?`, timestamp: new Date().toISOString() }]);
- }
- };
-
- const checkActiveJobs = async (currentMessages) => {
- if (!sessionId) return;
- try {
- const { data } = await axios.get(`${API}/sessions/${sessionId}/jobs`, { headers: getHeaders() });
- const active = data.find(j => (j.status === "pending" || j.status === "processing") && j.id);
- if (active) {
- // If the last message is assistant but empty/no events, it might be the one for this job.
- let aIdx = currentMessages.length - 1;
- if (aIdx < 0 || currentMessages[aIdx].role !== "assistant") {
- // No assistant bubble to resume into, create a new one.
- setMessages(prev => {
- const next = [...prev, { role: "assistant", content: "", events: [], timestamp: new Date().toISOString() }];
- resumePolling(active.id, next.length - 1);
- return next;
- });
- } else {
- resumePolling(active.id, aIdx);
- }
- }
- } catch {}
- };
-
- const loadAssets = async () => {
- if (!sessionId) return;
- try {
- const { data } = await axios.get(`${API}/sessions/${sessionId}/assets`, { headers: getHeaders() });
- setAssets(data);
- } catch {}
- };
-
- useEffect(() => {
- chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
- }, [messages, busy]);
-
- const ensureSession = async () => {
- if (sessionId) return sessionId;
- const { data } = await axios.post(`${API}/sessions`, {}, { headers: getHeaders() });
- justCreatedSessionRef.current = true;
- if (inEmbedMode) {
- setActiveEmbedSession(data.id);
- } else {
- router.replace(`?session=${data.id}`, { scroll: false });
- fetchSessions();
- }
- return data.id;
- };
-
- const processFile = async (file) => {
- if (!file) return;
-
- setUploading(true);
- setUploadProgress(0);
-
- try {
- // 0. Make sure we have a session — uploaded assets must belong to one.
- const activeSessionId = await ensureSession();
-
- // 1. Get signed URL
- const { data: signData } = await axios.get("/api/v1/get_upload_url", {
- params: { filename: file.name },
- headers: getHeaders()
- });
-
- const { url, fields } = signData;
-
- // Use the proxy for the actual binary upload to maintain consistency and avoid CORS issues
- const formData = new FormData();
- formData.append("x-proxy-target-url", url);
- Object.entries(fields).forEach(([key, value]) => {
- formData.append(key, value);
- });
- formData.append("file", file);
-
- // 2. Upload via local proxy
- await axios.post("/api/v1/upload-binary", formData, {
- headers: { "Content-Type": "multipart/form-data" },
- onUploadProgress: (pe) => {
- setUploadProgress(Math.round((pe.loaded * 100) / pe.total));
- }
- });
-
- // 3. Final URL
- const uploadedUrl = `https://cdn.muapi.ai/${fields.key}`;
-
- // 4. Register as a real session asset so the agent can address it as asset_N.
- const kind = file.type?.startsWith("video/") ? "video"
- : file.type?.startsWith("audio/") ? "audio"
- : "image";
- const { data: registered } = await axios.post(
- `${API}/sessions/${activeSessionId}/assets`,
- { url: uploadedUrl, kind, source_tool: "upload" },
- { headers: getHeaders() },
- );
-
- const att = { asset_label: registered.asset_label, url: uploadedUrl, kind };
- setAttachments(prev => [...prev, att]);
- // Reflect on the canvas immediately.
- setAssets(prev => [...prev, {
- asset_label: registered.asset_label, url: uploadedUrl, kind,
- source_tool: "upload", model: null, prompt: null,
- }]);
- toast.success(`Uploaded as ${registered.asset_label}`);
- } catch (err) {
- console.error("Upload failed", err);
- toast.error("Upload failed");
- } finally {
- setUploading(false);
- setUploadProgress(0);
- if (fileInputRef.current) fileInputRef.current.value = "";
- }
- };
-
- const handleFileUpload = (e) => {
- processFile(e.target.files?.[0]);
- };
-
- const handleDragOver = (e) => {
- e.preventDefault();
- if (busy || uploading) return;
- setIsDragging(true);
- };
-
- const handleDragLeave = (e) => {
- e.preventDefault();
- setIsDragging(false);
- };
-
- const handleDrop = (e) => {
- e.preventDefault();
- setIsDragging(false);
- if (busy || uploading) return;
- const file = e.dataTransfer.files?.[0];
- if (file) processFile(file);
- };
-
- const removeAttachment = (label) => {
- setAttachments(prev => prev.filter(a => a.asset_label !== label));
- };
-
- const sendMessage = async (textOverride = null, skillOverride = null, attachmentsOverride = null) => {
- const typed = (typeof textOverride === 'string' ? textOverride : input).trim();
- const currentAttachments = attachmentsOverride || attachments;
- if ((!typed && currentAttachments.length === 0) || busy) return;
-
- const currentSkill = skillOverride || activeSkill;
-
-
- let activeSessionId;
- try {
- activeSessionId = await ensureSession();
- } catch (err) {
- toast.error("Failed to establish session");
- return;
- }
-
- // Tell the LLM about any uploaded assets so it can call edit_image / image_to_video / etc.
- // by asset_label without us having to expose URLs in the user-visible bubble.
- const attachmentNote = currentAttachments.length
- ? "\n\n[Attached " + currentAttachments.map(a => `${a.asset_label} (${a.kind || "image"})`).join(", ") + "]"
- : "";
- const msg = typed + attachmentNote;
- const msgAttachments = [...currentAttachments];
-
- if (!attachmentsOverride) setAttachments([]);
- setInput("");
- if (textareaRef.current) textareaRef.current.style.height = "24px";
-
- const userMsg = {
- role: "user",
- content: msg,
- attachments: msgAttachments,
- timestamp: new Date().toISOString(),
- skill_name: currentSkill?.name
- };
- const updatedMessages = [...messages, userMsg];
-
- setMessages([...updatedMessages, { role: "assistant", content: "", events: [], timestamp: new Date().toISOString() }]);
- setBusy(true);
-
- const aIdx = updatedMessages.length;
-
- try {
- let canvasState = null;
- try {
- canvasState = canvasRef.current?.getCanvasState?.() || null;
- } catch {}
-
- let endpoint = `${API}/sessions/${activeSessionId}/chat`;
- let payload = {
- message: typed,
- model: "gpt-5-mini",
- messages_snapshot: updatedMessages,
- canvas_state: canvasState,
- };
-
- // If a skill is pinned, use the run-skill endpoint
- if (currentSkill) {
- endpoint = `${API}/sessions/${activeSessionId}/run-skill`;
- // Map the user input to the first required input of the skill
- const primaryInputKey = currentSkill.inputs?.[0] || "premise";
- payload = {
- skill_name: currentSkill.name,
- inputs: { [primaryInputKey]: typed },
- messages_snapshot: updatedMessages,
- model: "gpt-5-mini"
- };
- if (!skillOverride) setActiveSkill(null); // Clear skill after sending if not override
- }
-
- const enqueueRes = await axios.post(endpoint, payload, { headers: getHeaders() });
- await resumePolling(enqueueRes.data.job_id, aIdx);
- } catch (err) {
- setMessages(prev => {
- const arr = [...prev];
- if (aIdx >= 0) arr[aIdx] = { ...arr[aIdx], content: `❌ ${err.message || err}` };
- return arr;
- });
- } finally {
- setBusy(false);
- await loadAssets();
- if (activeSessionId) {
- setMessages(prev => {
- const newMsgs = [...prev];
- axios.patch(`${API}/sessions/${activeSessionId}/messages`, { messages: newMsgs }, { headers: getHeaders() }).catch(() => {});
- return newMsgs;
- });
- }
- }
- };
-
- const markdownComponents = useMemo(() => ({
- a: ({ node, ...props }) => {
- const isMedia = props.href?.match(/\.(jpeg|jpg|gif|png|webp|avif)$/i);
- const isVideo = props.href?.match(/\.(mp4|webm|mov)$/i);
- if (isMedia) {
- return (
-
-
-
-
-
-
- );
- }
- if (isVideo) {
- return (
-
-
-
- );
- }
- return ;
- },
- div: ({ node, ...props }) =>
- {children}
-
- );
- }
- }), [resolvedTheme]);
-
-
- // Sync assets to canvas once ref is ready — only push URLs not yet synced
- useEffect(() => {
- if (!sessionId || assets.length === 0) return;
-
- const newAssets = assets.filter(a => {
- const syncKey = `${a.asset_label || "no-label"}-${a.url}`;
- return !syncedUrlsRef.current.has(syncKey);
- });
- if (newAssets.length === 0) return;
-
- let attempts = 0;
- const sync = () => {
- if (canvasRef.current) {
- newAssets.forEach(a => {
- const syncKey = `${a.asset_label || "no-label"}-${a.url}`;
- if (!a.url || syncedUrlsRef.current.has(syncKey)) return;
- syncedUrlsRef.current.add(syncKey);
-
- const kind = a.kind || (a.url.match(/\.(mp4|webm|mov)$/i) ? "video" : a.url.match(/\.(mp3|wav|ogg|m4a)$/i) ? "audio" : "image");
- const label = a.asset_label || null;
- if (kind === "image") canvasRef.current.addImage(a.url, undefined, undefined, undefined, undefined, undefined, label);
- else if (kind === "video") canvasRef.current.addVideo(a.url, undefined, undefined, undefined, undefined, undefined, label);
- else if (kind === "audio") canvasRef.current.addAudio(a.url, undefined, undefined, undefined, label);
- });
- return true;
- }
- return false;
- };
-
- if (!sync()) {
- const timer = setInterval(() => {
- attempts++;
- if (sync() || attempts > 20) clearInterval(timer);
- }, 500);
- return () => clearInterval(timer);
- }
- }, [assets, sessionId]);
-
- const renameSession = async (id = null, name = null) => {
- const targetId = id || sessionId;
- const targetName = name || newName;
- const currentName = id ? (sessions.find(s => s.id === id)?.name) : currentSessionName;
-
- if (!targetId || !targetName.trim() || targetName.trim() === currentName) {
- setIsEditingName(false);
- setEditingSessionId(null);
- return;
- }
- try {
- await axios.patch(`${API}/sessions/${targetId}`, { name: targetName.trim() }, { headers: getHeaders() });
- if (targetId === sessionId) setCurrentSessionName(targetName.trim());
- setIsEditingName(false);
- setEditingSessionId(null);
- fetchSessions();
- toast.success("Session renamed");
- } catch {
- toast.error("Failed to rename session");
- setIsEditingName(false);
- setEditingSessionId(null);
- }
- };
-
- const deleteSession = async (id) => {
- // We use a simple confirm for safety, but with a premium look via toast if we had a custom one.
- // For now, standard confirm is reliable.
- if (!window.confirm("Are you sure you want to delete this session?")) return;
- try {
- await axios.delete(`${API}/sessions/${id}`, { headers: getHeaders() });
- toast.success("Session deleted");
- if (inEmbedMode) {
- if (id === sessionId) setActiveEmbedSession(null);
- } else {
- fetchSessions();
- if (id === sessionId) {
- router.push("/canvas");
- }
- }
- } catch (err) {
- toast.error("Failed to delete session");
- }
- };
-
- const handleMouseMove = useCallback((e) => {
- if (!isResizing.current) return;
- const newWidth = window.innerWidth - e.clientX;
- if (newWidth > 300 && newWidth < 800) {
- setSidebarWidth(newWidth);
- }
- }, []);
-
- const stopResizing = useCallback(() => {
- isResizing.current = false;
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", stopResizing);
- document.body.style.cursor = "default";
- document.body.style.userSelect = "auto";
- }, [handleMouseMove]);
-
- const startResizing = useCallback((e) => {
- isResizing.current = true;
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", stopResizing);
- document.body.style.cursor = "col-resize";
- document.body.style.userSelect = "none";
- }, [handleMouseMove, stopResizing]);
-
- const selectMention = (item, type) => {
- const before = input.substring(0, mentionCursorPos);
- // mentionCursorPos is where @ is. query is after @.
- const after = input.substring(textareaRef.current.selectionStart);
-
- if (type === "skill") {
- setActiveSkill(item);
- setInput(before + after);
- } else {
- const insertion = `@${item.asset_label}`;
- setInput(before + insertion + after);
- }
-
- setShowMentionPopup(false);
- setTimeout(() => textareaRef.current?.focus(), 10);
- };
-
- const copyToClipboard = async (text) => {
- if (!text) return;
- try {
- if (navigator.clipboard && window.isSecureContext) {
- await navigator.clipboard.writeText(text);
- toast.success("Copied to clipboard");
- } else {
- const textArea = document.createElement("textarea");
- textArea.value = text;
- document.body.appendChild(textArea);
- textArea.select();
- try {
- document.execCommand('copy');
- toast.success("Copied to clipboard");
- } catch (err) {
- toast.error("Failed to copy");
- }
- document.body.removeChild(textArea);
- }
- } catch (err) {
- toast.error("Failed to copy");
- }
- };
-
- const handleKey = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); } };
-
- const filteredSkills = skills.filter(s => s.name.toLowerCase().includes(mentionQuery.toLowerCase()));
- const filteredAssets = assets.filter(a => (a.asset_label || "").toLowerCase().includes(mentionQuery.toLowerCase()));
-
- if (!mounted) return null;
-
- return (
-