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 ( - - - Generated Asset - - - - ); - } - if (isVideo) { - return ( - - - ); - } - return ; - }, - div: ({ node, ...props }) =>
, - p: ({ node, ...props }) =>
, - pre: ({ node, ...props }) =>
, - code: ({ node, inline, className, children, ...props }) => { - const match = /language-(\w+)/.exec(className || ''); - return !inline && match ? ( - - {String(children).replace(/\n$/, '')} - - ) : ( - - {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 ( -
- -
- {/* Left Sidebar: Session List — owner only. Embed visitors don't get - a session switcher; the iframe is scoped to one localStorage-keyed - session per embed code. */} -
-
-
- - - - - Design Agent Studio - -
- -
-
- {sessions.length === 0 ? ( -
No previous sessions
- ) : ( - sessions.map((s) => ( -
setHoveredSessionId(s.id)} - onMouseLeave={() => setHoveredSessionId(null)} - className={`relative w-full flex items-center gap-3 px-4 py-3.5 cursor-pointer transition-all border-l-2 group - ${sessionId === s.id ? "border-primary bg-primary/5" : "border-transparent hover:bg-bg-card-hover"}`} - onClick={() => {router.push(`?session=${s.id}`); setShowLeftSidebar(!showLeftSidebar);}} - > -
- {editingSessionId === s.id ? ( - setEditingSessionName(e.target.value)} - onBlur={() => renameSession(s.id, editingSessionName)} - onKeyDown={(e) => { - if (e.key === "Enter") renameSession(s.id, editingSessionName); - if (e.key === "Escape") setEditingSessionId(null); - }} - onClick={(e) => e.stopPropagation()} - /> - ) : ( -
- {s.name} - - {s.asset_count} - -
- )} -
- - {/* Hover Actions */} - {hoveredSessionId === s.id && editingSessionId !== s.id && ( -
- - -
- )} -
- )) - )} -
-
-
- Total Sessions - {sessions.length} -
-
-
-
- {/* Canvas Top Bar */} -
-
- {!inEmbedMode && ( - - )} - - {!inEmbedMode && ( - - - - )} - - {inEmbedMode && ( - - )} - -
- - {currentSessionName} - -
-
- -
- {!inEmbedMode && ( -
- - {userBalanceLabel ?? `$ ${user?.balance || "0.00"}`} - -
- )} - -
{ - if (!e.currentTarget.contains(e.relatedTarget)) { - setOpenProfile(false); - } - }} - > - - {!showChat && ( - - )} - -
-
-
- - {user?.username || "User"} - - {user?.plan === "pro" ? ( - - Pro - - ) : ( - - Bronze - - )} -
- - {user?.email} - -
- {userBalanceLabel ?? `$ ${user?.balance || "0.00"}`} available -
-
- - -
-
- -
-
-
-
-
- - {/* Main Canvas View */} -
- - - {/* Floating Toolbar */} -
-
- {zoomLevel}% -
- - -
-
-
-
-
- - {/* Resizer Handle */} -
- {/*
*/} -
-
-
-
-
-
- - {/* Right Panel: Chat Sidebar */} -
- {/* Sidebar Header */} -
-
-

- Creative Agent -

- Auto Model • Multi-tool Access -
-
- - - - {sessionId && ( - - )} - -
-
- {/* Chat History */} -
- {messages.map((msg, idx) => { - if (!msg) return null; - const prevMsg = idx > 0 ? messages[idx - 1] : null; - const showDateHeader = msg.timestamp && ( - !prevMsg || - !prevMsg.timestamp || - new Date(msg.timestamp).toDateString() !== new Date(prevMsg.timestamp).toDateString() - ); - - return ( - - {showDateHeader && msg.timestamp && ( -
- - {formatDateHeader(msg.timestamp)} - -
- )} -
-
- {msg.role === "assistant" && ( -
- Agent -
- )} -
- {msg.timestamp && {formatTime(msg.timestamp)}} -
- {msg.role === "user" && msg.skill_name && ( -
- {msg.skill_name} -
- )} -
-
-
-
- - {msg.content ? ( - msg.role === "assistant" ? ( -
- - {msg.content} - -
- ) : ( -
-
- - {msg.content} - -
- {msg.attachments && msg.attachments.length > 0 && ( -
- {msg.attachments.map(att => ( -
- {att.kind === "image" && ( - {att.asset_label} - )} - {att.kind === "video" && ( -
- ))} -
- )} -
- ) - ) : (msg.role === "assistant" && busy) && ( - - )} - - {(msg.events || []).filter(e => e && ["tool_call", "tool_result", "plan_propose", "error", "info"].includes(e.type)).map((ev, i) => ( - - ))} -
- - {/* Copy Button - Hover only */} - -
-
-
-
- ); - })} - -
-
- - {/* Chat Input Area */} -
-
- {activeSkill && ( -
- -
- )} - {isDragging && ( -
-
- -
-
- )} - {showMentionPopup && ( -
-
-
- Mentions -
-
- {filteredAssets.length > 0 && ( -
Assets
- )} -
- {filteredAssets.map(asset => ( - - ))} -
- {filteredSkills.length > 0 && ( -
Skills
- )} - {filteredSkills.map(skill => ( - - ))} - {filteredSkills.length === 0 && filteredAssets.length === 0 && ( -
No matches found
- )} -
-
-
- )} -