);
}
// ============ COMPOSER ============
//
// Attachments
// -----------
// The composer maintains a local queue of attachment objects, each
// shaped { id?, name, size, type, status, file?, error? }. Files added
// via the +button / drag-drop / paste are uploaded eagerly to
// POST /uploads — the server returns an attachment id; the queue chip
// flips from "uploading" to "ready". On send, attachment ids ride
// alongside the text via the onSend signature
// onSend(text, { attachment_ids: number[] }).
//
// All UI is debounce-free: attachments upload as you pick them, send is
// disabled while any attachment is still uploading.
function KxV2Composer({ onCmd, onSend, onVoice, placeholder, threadSlug }) {
const [val, setVal] = React.useState('');
const [listening, setListening] = React.useState(false);
const [waveTick, setWaveTick] = React.useState(0);
const [attachments, setAttachments] = React.useState([]); // {tmpId, id?, name, size, type, status, error?}
const [dragHover, setDragHover] = React.useState(false);
const fileInputRef = React.useRef(null);
React.useEffect(() => {
if (!listening) return;
let lastVoice = Date.now();
let stopped = false;
const finish = () => {
if (stopped) return;
stopped = true;
setListening(false);
onVoice?.("What's on for tomorrow?");
};
const id = setInterval(() => {
setWaveTick(t => t + 1);
// Mock voice activity — assume 70% of ticks are "speech"
if (Math.random() < 0.7) lastVoice = Date.now();
// Auto-stop on 10s of silence
if (Date.now() - lastVoice > 10000) finish();
}, 120);
// Hard cap at 30s for the demo
const cap = setTimeout(finish, 30000);
return () => { clearInterval(id); clearTimeout(cap); };
}, [listening, onVoice]);
// Mirror server-side limit (atlas-api MAX_FILE_BYTES). Avoids round-
// tripping a 25MB blob just to get a 413 back.
const MAX_FILE_BYTES = 25 * 1024 * 1024;
const fmtBytes = (n) => {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
};
// Upload one file. Pushes the chip immediately (status='uploading'),
// then flips to 'ready' with the server-assigned id or 'failed' with
// the error.
const uploadFile = React.useCallback(async (file) => {
const tmpId = 'a_' + Math.random().toString(36).slice(2, 10);
// For images we generate a local object-URL so the preview shows
// instantly, before the upload completes. Revoked when the
// attachment is removed (see KxV2PendingAttachmentRow's effect).
const localPreview = (file.type || '').startsWith('image/')
? URL.createObjectURL(file)
: null;
const stub = {
tmpId, name: file.name, size: file.size, type: file.type,
file, localPreview,
status: file.size > MAX_FILE_BYTES ? 'failed' : 'uploading',
error: file.size > MAX_FILE_BYTES ? `Over ${fmtBytes(MAX_FILE_BYTES)}` : undefined,
};
setAttachments((prev) => [...prev, stub]);
if (stub.status === 'failed') return;
if (!window.kxApi) {
// Mock mode — no real upload available.
setAttachments((prev) => prev.map((a) =>
a.tmpId === tmpId ? { ...a, status: 'failed', error: 'Demo mode — uploads disabled.' } : a,
));
return;
}
try {
const fd = new FormData();
fd.append('file', file, file.name);
const path = '/uploads' + (threadSlug ? `?thread=${encodeURIComponent(threadSlug)}` : '');
const resp = await window.kxApi(path, { method: 'POST', body: fd });
if (resp && resp.id != null) {
setAttachments((prev) => prev.map((a) =>
a.tmpId === tmpId ? { ...a, id: resp.id, status: 'ready' } : a,
));
} else {
setAttachments((prev) => prev.map((a) =>
a.tmpId === tmpId ? { ...a, status: 'failed', error: 'Upload failed.' } : a,
));
}
} catch (e) {
setAttachments((prev) => prev.map((a) =>
a.tmpId === tmpId ? { ...a, status: 'failed', error: 'Upload failed.' } : a,
));
}
}, [threadSlug]);
const handleFiles = React.useCallback((fileList) => {
if (!fileList || !fileList.length) return;
[...fileList].forEach((f) => uploadFile(f));
}, [uploadFile]);
// Paste handler — attach files dropped from clipboard (screenshots
// pasted from the OS, files copied from another app, etc.).
const onPaste = React.useCallback((e) => {
const files = e.clipboardData?.files;
if (files && files.length) {
e.preventDefault();
handleFiles(files);
}
}, [handleFiles]);
const removeAttachment = (tmpId) => {
setAttachments((prev) => prev.filter((a) => a.tmpId !== tmpId));
};
const hasPending = attachments.some((a) => a.status === 'uploading');
const hasReady = attachments.some((a) => a.status === 'ready');
const canSend = val.trim().length > 0 || hasReady;
const sendBlocked = hasPending;
const doSend = () => {
if (sendBlocked || !canSend) return;
const ids = attachments.filter((a) => a.status === 'ready' && a.id != null).map((a) => a.id);
onSend?.(val, { attachment_ids: ids });
setVal('');
setAttachments([]);
};
// Drag-and-drop on the whole composer container.
const onDragOver = (e) => { e.preventDefault(); setDragHover(true); };
const onDragLeave = (e) => {
// Only clear when the drag actually leaves the container (not when
// crossing internal child boundaries).
if (e.currentTarget.contains(e.relatedTarget)) return;
setDragHover(false);
};
const onDrop = (e) => {
e.preventDefault();
setDragHover(false);
handleFiles(e.dataTransfer?.files);
};
return (
{/* Drag overlay — appears whenever a file is being dragged over
the composer (or its parent thread area when we lift this
higher in a later iteration). */}
{dragHover && (
Drop to attach
)}
{/* Hidden file input — clicked by the paperclip button below. */}
{
handleFiles(e.target.files);
// Reset so picking the same file twice still fires onChange.
e.target.value = '';
}}
/>
{/* Attachment queue — one row per pending attachment, richer than
a name-only chip so the user can see what they're sending. The
thumbnail uses the local File object via ObjectURL (no server
round-trip needed for the just-picked image). For non-images
we render the same file-card shape we use in chat bubbles. */}
{attachments.length > 0 && (