// ============================================================================ // StyleKro — CHAT module (split from app.jsx on 2026-06-26). // ChatPage + helpers (renderMarkdown, formatTime, TryonProgress, ImageFeedback, // ZoomableImage). Depends on symbols from common.jsx (loaded first). // ============================================================================ // ===== Chat page ===== const SUGGESTIONS = [ { key: 'date', labelKey: 'chat.suggestion1', promptKey: 'chat.suggestion1Prompt' }, { key: 'roast', labelKey: 'chat.suggestion2', promptKey: 'chat.suggestion2Prompt' }, { key: 'autumn', labelKey: 'chat.suggestion3', promptKey: 'chat.suggestion3Prompt' }, { key: 'colors', labelKey: 'chat.suggestion4', promptKey: 'chat.suggestion4Prompt' }, { key: 'suits', labelKey: 'chat.suggestion5', promptKey: 'chat.suggestion5Prompt' }, ]; // Lightweight markdown for assistant text: **bold**, *italic*, `code`, links, line breaks. function renderMarkdown(text) { if (!text) return null; const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); let html = escape(text); // links [label](url) and bare urls html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1'); html = html.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, '$1$2'); html = html.replace(/`([^`]+)`/g, '$1'); html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); html = html.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2'); html = html.replace(/\n/g, '
'); return ; } function formatTime(iso) { if (!iso) return ''; const d = new Date(iso.includes('T') || iso.includes('Z') ? iso : iso.replace(' ', 'T') + 'Z'); if (isNaN(d.getTime())) return ''; const now = new Date(); const sameDay = d.toDateString() === now.toDateString(); if (sameDay) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' · ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } // ===== Try-on progress (estimated) ===== // The image generation API has no streaming progress, so we render an // asymptotic 0 → 95% curve over `etaMs`. The component unmounts the // moment the result arrives, which feels like "snap to 100%". function TryonProgress({ etaMs = 30000, label }) { const { t } = useAuth(); const finalLabel = label || t('chat.generatingTryOn'); const [pct, setPct] = useState(0); useEffect(() => { const start = Date.now(); const compute = () => { const elapsed = Date.now() - start; setPct(95 * (1 - Math.exp(-(elapsed / etaMs) * 2.6))); }; compute(); // setInterval keeps ticking on mobile during scroll/touch (RAF is paused). const id = setInterval(compute, 200); return () => clearInterval(id); }, [etaMs]); return (
{finalLabel} {Math.round(pct)}%
); } // ===== Image feedback (thumbs up / down on generated try-ons) ===== // Single module-level cache + subscriber set so every button sees the // same ratings without each one hitting the API on mount. const __imageFeedbackCache = { messages: {}, merges: {} }; let __imageFeedbackLoaded = false; let __imageFeedbackLoading = null; const __imageFeedbackSubs = new Set(); function __notifyImageFeedback() { for (const fn of __imageFeedbackSubs) fn(); } function ImageFeedback({ messageId, mergeId, className = '', compact = false }) { const { apiCall, token } = useAuth(); const key = messageId || mergeId; const readRating = useCallback(() => { if (!key) return null; if (messageId) return __imageFeedbackCache.messages[messageId] || null; return __imageFeedbackCache.merges[mergeId] || null; }, [key, messageId, mergeId]); const [rating, setRating] = useState(readRating); const [busy, setBusy] = useState(false); useEffect(() => { const sub = () => setRating(readRating()); __imageFeedbackSubs.add(sub); return () => { __imageFeedbackSubs.delete(sub); }; }, [readRating]); useEffect(() => { if (!token || __imageFeedbackLoaded || __imageFeedbackLoading) return; __imageFeedbackLoading = (async () => { try { const data = await apiCall('/feedback/image/mine'); __imageFeedbackCache.messages = data?.messages || {}; __imageFeedbackCache.merges = data?.merges || {}; __imageFeedbackLoaded = true; __notifyImageFeedback(); } catch (_e) { /* non-fatal */ } finally { __imageFeedbackLoading = null; } })(); }, [token, apiCall]); if (!key) return null; async function send(next) { if (busy) return; const desired = rating === next ? 'clear' : next; const prev = rating; const optimistic = desired === 'clear' ? null : desired; setRating(optimistic); if (messageId) __imageFeedbackCache.messages[messageId] = optimistic; if (mergeId) __imageFeedbackCache.merges[mergeId] = optimistic; __notifyImageFeedback(); setBusy(true); try { await apiCall('/feedback/image', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message_id: messageId || null, merge_id: mergeId || null, rating: desired, }), }); } catch (_e) { setRating(prev); if (messageId) __imageFeedbackCache.messages[messageId] = prev; if (mergeId) __imageFeedbackCache.merges[mergeId] = prev; __notifyImageFeedback(); } finally { setBusy(false); } } return (
e.stopPropagation()}>
); } function ZoomableImage({ src, alt = '' }) { const ref = React.useRef(null); const state = React.useRef({ scale: 1, tx: 0, ty: 0, startDist: 0, startScale: 1, startTx: 0, startTy: 0, panning: false, panX: 0, panY: 0, }); function apply() { const el = ref.current; if (!el) return; const s = state.current; el.style.transform = `translate(${s.tx}px, ${s.ty}px) scale(${s.scale})`; } function onTouchStart(e) { const s = state.current; if (e.touches.length === 2) { const [a, b] = e.touches; s.startDist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); s.startScale = s.scale; s.startTx = s.tx; s.startTy = s.ty; e.preventDefault(); } else if (e.touches.length === 1 && s.scale > 1) { s.panning = true; s.panX = e.touches[0].clientX; s.panY = e.touches[0].clientY; s.startTx = s.tx; s.startTy = s.ty; } } function onTouchMove(e) { const s = state.current; if (e.touches.length === 2) { const [a, b] = e.touches; const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); const next = Math.min(5, Math.max(1, s.startScale * (dist / (s.startDist || 1)))); s.scale = next; apply(); e.preventDefault(); } else if (e.touches.length === 1 && s.panning) { s.tx = s.startTx + (e.touches[0].clientX - s.panX); s.ty = s.startTy + (e.touches[0].clientY - s.panY); apply(); e.preventDefault(); } } function onTouchEnd(e) { const s = state.current; if (e.touches.length < 2) s.panning = false; if (s.scale <= 1.01) { s.scale = 1; s.tx = 0; s.ty = 0; apply(); } } function onDoubleClick(e) { e.stopPropagation(); const s = state.current; if (s.scale > 1) { s.scale = 1; s.tx = 0; s.ty = 0; } else { s.scale = 2; } apply(); } return ( {alt} e.stopPropagation()} onDoubleClick={onDoubleClick} onTouchStart={onTouchStart} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd} onTouchCancel={onTouchEnd} /> ); } function ChatPage() { const { apiCall, profile, signOut, user, lang, token, t: tr, photoVersion } = useAuth(); const ensureProfilePhoto = useRequireProfilePhoto(); const confirmAction = useConfirmDialog(); const navigate = useNavigate(); const [messages, setMessages] = useState([]); const [text, setText] = useState(''); const [attached, setAttached] = useState(null); const [attachedPreview, setAttachedPreview] = useState(''); const [typing, setTyping] = useState(false); const [sending, setSending] = useState(false); const [linkBusy, setLinkBusy] = useState(false); const [historyLoading, setHistoryLoading] = useState(true); const [hasMoreHistory, setHasMoreHistory] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [lightbox, setLightbox] = useState(null); // url string const [sheet360MsgId, setSheet360MsgId] = useState(null); const fetchChatSheet360 = useCallback( () => fetchSheet360(`/chat/messages/${sheet360MsgId}/sheet360`), [sheet360MsgId], ); const [showScrollBtn, setShowScrollBtn] = useState(false); const [dragOver, setDragOver] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); const [quickOpen, setQuickOpen] = useState(false); const [quickPrompts, setQuickPrompts] = useState([]); const listRef = useRef(null); const fileRef = useRef(null); const cameraRef = useRef(null); const taRef = useRef(null); const initialScrollDone = useRef(false); // Build attachment preview when file changes. useEffect(() => { if (!attached) { setAttachedPreview(''); return; } const url = URL.createObjectURL(attached); setAttachedPreview(url); return () => URL.revokeObjectURL(url); }, [attached]); // Load quick prompts from API. useEffect(() => { let cancelled = false; apiCall('/prompts/quick') .then((data) => { if (cancelled || !data) return; const list = Array.isArray(data) ? data : (data.prompts || []); setQuickPrompts(list); }) .catch(() => { /* ignore */ }); return () => { cancelled = true; }; }, [apiCall]); // Load history. useEffect(() => { (async () => { try { const PAGE = 5; const history = await apiCall(`/chat/history?limit=${PAGE}`); if (!history.length) { setMessages([{ role: 'assistant', content: `${tr('chat.welcome.hi')} ${profile?.name || ''} — ${tr('chat.welcome.body')}`, welcome: true, created_at: new Date().toISOString() }]); } else { setMessages(history.map(m => ({ id: m.id, role: m.role, content: m.content, image_url: m.image_url ? imgUrl(m.image_url) : null, created_at: m.created_at, pending: !!m.pending }))); setHasMoreHistory(history.length >= PAGE); } } catch (e) { setMessages([{ role: 'assistant', content: `Couldn't load history: ${e.message}` }]); } finally { setHistoryLoading(false); } })(); }, []); // eslint-disable-line // While any message is pending (e.g. tryon still running after a refresh), // poll history every few seconds so the image shows up once ready. const hasPendingMessage = messages.some(m => m.pending); useEffect(() => { if (!hasPendingMessage) return; const PAGE = 5; let cancelled = false; const tick = async () => { try { const history = await apiCall(`/chat/history?limit=${PAGE}`); if (cancelled) return; const byId = new Map(history.map(m => [m.id, m])); setMessages(prev => prev.map(m => { if (!m.pending) return m; const fresh = byId.get(m.id); if (!fresh) return m; return { ...m, content: fresh.content, image_url: fresh.image_url ? imgUrl(fresh.image_url) : null, pending: !!fresh.pending, }; })); } catch { /* ignore */ } }; const id = setInterval(tick, 4000); return () => { cancelled = true; clearInterval(id); }; }, [hasPendingMessage]); // Auto-scroll on message change (only if user is near bottom). useEffect(() => { const el = listRef.current; if (!el) return; const jumpToBottom = () => { el.scrollTop = el.scrollHeight; }; // First render after messages load: always jump to the latest message. if (!initialScrollDone.current && messages.length > 0) { initialScrollDone.current = true; requestAnimationFrame(jumpToBottom); // Re-scroll as images load in (any image inside the list). [60, 200, 500, 1000, 2000].forEach((ms) => setTimeout(jumpToBottom, ms)); el.querySelectorAll('img').forEach((img) => { if (!img.complete) img.addEventListener('load', jumpToBottom, { once: true }); }); return; } const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 200; if (nearBottom) jumpToBottom(); }, [messages, typing]); // Show "scroll to bottom" pill when scrolled up. Also lazy-load older // messages from the server when the user scrolls near the top. useEffect(() => { const el = listRef.current; if (!el) return; async function loadOlder() { const oldest = messages.find((m) => m.id); if (!oldest) return; setLoadingMore(true); const prevHeight = el.scrollHeight; try { const PAGE = 5; const older = await apiCall(`/chat/history?limit=${PAGE}&before=${encodeURIComponent(oldest.id)}`); if (older.length) { setMessages((cur) => [ ...older.map(m => ({ id: m.id, role: m.role, content: m.content, image_url: m.image_url ? imgUrl(m.image_url) : null, created_at: m.created_at })), ...cur, ]); } setHasMoreHistory(older.length >= PAGE); requestAnimationFrame(() => { const delta = el.scrollHeight - prevHeight; if (delta > 0) el.scrollTop = el.scrollTop + delta; }); } catch { /* ignore */ } finally { setLoadingMore(false); } } function onScroll() { const distance = el.scrollHeight - el.scrollTop - el.clientHeight; setShowScrollBtn(distance > 240); if (el.scrollTop < 80 && hasMoreHistory && !loadingMore) { loadOlder(); } } el.addEventListener('scroll', onScroll); return () => el.removeEventListener('scroll', onScroll); }, [messages, hasMoreHistory, loadingMore]); // eslint-disable-line // Paste image from clipboard. useEffect(() => { function onPaste(e) { const items = e.clipboardData?.items || []; for (const it of items) { if (it.kind === 'file' && it.type.startsWith('image/')) { const f = it.getAsFile(); if (f) { setAttached(f); break; } } } } window.addEventListener('paste', onPaste); return () => window.removeEventListener('paste', onPaste); }, []); // Receive an image shared from another Android app (gallery, WhatsApp, // browser → "Share with Vogue"). The native shell stages the image and // exposes it through window.AndroidShare.consumePendingShare() as a // base64 data URL with mime type prefix. useEffect(() => { function dataUrlToFile(dataUrl, name) { try { const [head, b64] = dataUrl.split(','); const mime = (head.match(/data:(.*?);base64/) || [, 'image/jpeg'])[1]; const bin = atob(b64); const buf = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); const ext = mime.split('/')[1] || 'jpg'; return new File([buf], name || `shared.${ext}`, { type: mime }); } catch { return null; } } function consumeOnce() { try { const bridge = window.AndroidShare; if (!bridge || typeof bridge.consumePendingShare !== 'function') return false; const payload = bridge.consumePendingShare(); if (!payload) return false; const file = dataUrlToFile(payload, 'shared-image.jpg'); if (file) { setAttached(file); return true; } } catch { /* ignore */ } return false; } // Allow the page to be invoked from native code too. window.__styleyouOnSharedImage = (dataUrl) => { const f = dataUrlToFile(dataUrl, 'shared-image.jpg'); if (f) setAttached(f); }; // First, try right away. let consumed = consumeOnce(); // Then keep polling — a cold-start launch from "Share to Vogue" can // take 5-15s before ChatPage actually mounts (Babel + React boot + // any auth redirect chain). We poll fast for the first ~10s and then // back off, so the staged image still lands in the composer when the // page finally appears. let ticks = 0; const poll = setInterval(() => { ticks += 1; if (consumed) { clearInterval(poll); return; } if (consumeOnce()) { consumed = true; clearInterval(poll); return; } // After the initial burst, slow down to once every 2s and cap the // total polling window at ~2 minutes to avoid running forever. if (ticks === 40) { clearInterval(poll); const slow = setInterval(() => { if (consumed || consumeOnce()) { consumed = true; clearInterval(slow); } }, 2000); // Stop the slow poll after ~2 minutes total. setTimeout(() => clearInterval(slow), 110000); } }, 250); function onFocus() { if (!consumed) consumed = consumeOnce(); } window.addEventListener('focus', onFocus); document.addEventListener('visibilitychange', onFocus); return () => { clearInterval(poll); window.removeEventListener('focus', onFocus); document.removeEventListener('visibilitychange', onFocus); try { delete window.__styleyouOnSharedImage; } catch { window.__styleyouOnSharedImage = undefined; } }; }, []); // Auto-resize textarea. useEffect(() => { if (taRef.current) { taRef.current.style.height = 'auto'; taRef.current.style.height = Math.min(140, taRef.current.scrollHeight) + 'px'; } }, [text]); function onAttach(e) { const f = e.target.files[0]; setAttached(f || null); } function clearAttachment() { setAttached(null); if (fileRef.current) fileRef.current.value = ''; if (cameraRef.current) cameraRef.current.value = ''; } async function newChat() { if (!(await confirmAction(tr('chat.confirmClearHistory'), { confirmLabel: tr('common.clear'), tone: 'danger' }))) return; try { await apiCall('/chat/history', { method: 'DELETE' }); setMessages([{ role: 'assistant', content: `${tr('chat.welcome.hi')} ${profile?.name || ''} — ${tr('chat.welcome.fresh')}`, welcome: true, created_at: new Date().toISOString() }]); } catch (e) { alert(tr('chat.clearHistoryError') + ' ' + e.message); } } // Map mockup tabs -> existing taxonomy fields (category_group / subcategory). function garmentMatchesCategory(g, cat) { const grp = (g.category_group || '').toLowerCase(); const sub = (g.subcategory || '').toLowerCase(); switch (cat) { case 'Dresses': return grp.includes('dress') || sub.includes('dress') || sub.includes('gown') || sub.includes('saree') || sub.includes('lehenga') || sub.includes('salwar'); case 'Tops': return grp === 'topwear' || sub.includes('top') || sub.includes('shirt') || sub.includes('blouse') || sub.includes('kurti') || sub.includes('kurta'); case 'Bottoms': return grp === 'bottomwear' || sub.includes('jean') || sub.includes('trouser') || sub.includes('legging') || sub.includes('skirt') || sub.includes('short') || sub.includes('palazzo') || sub.includes('chino'); case 'Shoes': return grp === 'footwear' || sub.includes('shoe') || sub.includes('sneaker') || sub.includes('boot') || sub.includes('heel') || sub.includes('flat') || sub.includes('sandal') || sub.includes('slipper'); case 'Bags': return sub.includes('bag') || sub.includes('handbag') || sub.includes('wallet') || sub.includes('backpack') || sub.includes('clutch'); case 'Accessories': return grp === 'accessories' || sub.includes('watch') || sub.includes('jewel') || sub.includes('belt') || sub.includes('sunglass') || sub.includes('scarf') || sub.includes('cap') || sub.includes('hair'); default: return true; } } function onDrop(e) { e.preventDefault(); setDragOver(false); const f = e.dataTransfer?.files?.[0]; if (f && f.type.startsWith('image/')) setAttached(f); } function copyMessage(content) { navigator.clipboard?.writeText(content || ''); } function onKeyDown(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } } async function submit(overrideText, overrideImage, displayText) { const t = (typeof overrideText === 'string' ? overrideText : text).trim(); const image = overrideImage !== undefined ? overrideImage : attached; if (!t && !image) return; // If the message is just a URL (and no image attached), treat it as a try-on link. if (!image && /^https?:\/\/\S+$/i.test(t)) { const started = await tryOnLink(t); if (started) setText(''); return; } if (image && !(await ensureProfilePhoto())) return; setSending(true); const optimisticUrl = image ? URL.createObjectURL(image) : null; const now = new Date().toISOString(); const shownContent = (typeof displayText === 'string' && displayText.trim()) ? displayText.trim() : (t || '(image)'); setMessages((m) => [...m, { role: 'user', content: shownContent, image_url: optimisticUrl, created_at: now }]); setText(''); setAttached(null); if (fileRef.current) fileRef.current.value = ''; if (cameraRef.current) cameraRef.current.value = ''; setTyping(true); try { const fd = new FormData(); fd.append('text', t); if (image) fd.append('image', image); if (lang) fd.append('lang', lang); if (typeof displayText === 'string' && displayText.trim()) { fd.append('display_text', displayText.trim()); } await streamChat(fd, token, (evt, data) => { if (evt === 'assistant') { setTyping(false); setMessages((m) => [...m, { id: data.id, role: 'assistant', content: data.content, image_url: data.image_url ? imgUrl(data.image_url) : null, created_at: data.created_at, }]); } else if (evt === 'tryon_pending') { setTyping(false); setMessages((m) => [...m, { id: data.id, role: 'assistant', content: data.content, image_url: null, pending: true, created_at: data.created_at, }]); } else if (evt === 'tryon_fast' || evt === 'tryon_final') { setMessages((m) => m.map((msg) => msg.id === data.id ? { ...msg, content: data.content, image_url: data.image_url ? imgUrl(data.image_url) : msg.image_url, pending: evt === 'tryon_fast', } : msg)); } else if (evt === 'tryon_error') { setMessages((m) => m.map((msg) => msg.id === data.id ? { ...msg, content: `Try-on failed: ${data.detail || 'unknown error'}`, image_url: null, pending: false, } : msg)); } else if (evt === 'error') { setMessages((m) => [...m, { role: 'assistant', content: `Sorry, something went wrong: ${data.detail}`, created_at: new Date().toISOString() }]); } }); } catch (e) { setMessages((m) => [...m, { role: 'assistant', content: `Sorry, something went wrong: ${e.message}`, created_at: new Date().toISOString() }]); } finally { setTyping(false); setSending(false); } } function logout() { signOut(); navigate('/login', { replace: true }); } async function tryOnLink(urlArg) { const u = (urlArg || '').trim(); if (!u) return false; if (!/^https?:\/\//i.test(u)) { setMessages((m) => [...m, { role: 'assistant', content: "That doesn't look like a valid URL. Make sure it starts with http:// or https://", created_at: new Date().toISOString() }]); return false; } if (!(await ensureProfilePhoto())) return false; setLinkBusy(true); setSending(true); setMessages((m) => [...m, { role: 'user', content: `Try this on for me: ${u}`, created_at: new Date().toISOString() }]); setTyping(true); try { const res = await apiCall('/chat/tryon-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: u }), }); setMessages((m) => { const next = [...m]; if (res.user_message?.image_url) { for (let i = next.length - 1; i >= 0; i--) { if (next[i].role === 'user' && next[i].content?.startsWith('Try this on for me:')) { next[i] = { role: 'user', content: res.user_message.content, image_url: imgUrl(res.user_message.image_url), created_at: res.user_message.created_at }; break; } } } next.push({ role: 'assistant', content: res.assistant_message.content, created_at: res.assistant_message.created_at }); if (res.tryon_message) { next.push({ role: 'assistant', content: res.tryon_message.content, image_url: imgUrl(res.tryon_message.image_url), created_at: res.tryon_message.created_at }); } return next; }); } catch (e) { setMessages((m) => [...m, { role: 'assistant', content: `Couldn't try that on: ${e.message}`, created_at: new Date().toISOString() }]); } finally { setTyping(false); setLinkBusy(false); setSending(false); } return true; } return (
{sidebarOpen &&
setSidebarOpen(false)} />}
{/* Hero stage — large model image with chat bubbles overlaid */} {(() => { const lastImg = [...messages].reverse().find((m) => m.image_url)?.image_url; const heroSrc = lastImg || (profile?.photo_url ? bust(imgUrl(profile.photo_url), photoVersion) : null); return (
{!heroSrc && ( )}
{ e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={onDrop} > {historyLoading ? (
) : (<> {(loadingMore || hasMoreHistory) && ( )} {messages.map((m, i) => { const onlyImage = (!m.content || m.content === '(image)') && m.image_url; const isUser = m.role === 'user'; const cls = `bubble-row ${m.role}`; return (
{!isUser && (
V
)}
{m.content && !onlyImage && (
{isUser ? m.content : renderMarkdown(m.content)}
)} {m.image_url && ( setLightbox(m.image_url)} /> )} {m.pending && (
)}
{formatTime(m.created_at)} {m.content && !onlyImage && ( )} {m.image_url && ( )} {m.role === 'assistant' && m.image_url && !m.pending && ( )}
{isUser && (
{ if (profile?.photo_url) setLightbox(bust(imgUrl(profile.photo_url), photoVersion)); }} > {profile?.photo_url ? : (profile?.name?.[0] || user?.email?.[0] || 'U').toUpperCase()}
)}
); })} )} {typing && (
V
{(linkBusy || sending) ? ( ) : ( <> )}
)} {dragOver && (
{tr('chat.dropHint')}
)}
); })()} {attached && (
{attachedPreview && }
{attached.name || 'image.png'} {(attached.size / 1024).toFixed(0)} KB
)} {quickPrompts.length > 0 && (
{quickOpen && (
✨ Quick ideas
{quickPrompts.map((p, i) => ( ))}
)}
)}
{ e.preventDefault(); submit(); }}>