// ============================================================================
// 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 (
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) && (
{loadingMore ? tr('chat.loadingOlder') : tr('chat.scrollOlder')}
)}
{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 && (
{tr('chat.refiningLook')}
)}
{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) => (
))}
)}
)}
{tr('chat.composerHint')}
{lightbox && (
setLightbox(null)}>
{
const m = /\/chat\/messages\/([^/?]+)\/image/.exec(lightbox);
return m ? (e) => { e.stopPropagation(); setLightbox(null); setSheet360MsgId(m[1]); } : null;
})()}
/>
)}
setSheet360MsgId(null)}
/>
);
}