// Kayser v2 — Threads // Auto-grouped conversation threads. Owners don't create them; they exist because the entity exists. // Each thread is a "scope" — when you pick one, the chat filters to just that conversation // and the composer becomes context-scoped. There's always a "Now" thread (everything, unscoped). // // In live mode (window.KX_TOKEN present), the rail and the mobile sheet // also render user-created folders. Each non-"now" thread row has a // hover-revealed 3-dots menu (Rename · Move to ▸ · Delete) and the rail // header gets a "+ Folder" affordance. Folder list lives in app.jsx // state and is fetched from /api/v1/folders alongside /api/v1/threads. // ============ MOCK THREADS ============ // In production these are entity-derived in the API. Here we hardcode a realistic mix. // `kind` controls the small icon + sort priority. `slug` is the thread id. // `summary` is a one-line description Kayser keeps fresh ("→ awaiting approval → 4d quiet"). // `tags` filter Now-stream messages into this thread (basic substring match for the demo). const KXV2_THREADS = [ { slug: 'now', kind: 'now', name: 'Now', summary: 'Everything Kayser is watching · default home', pinned: true, unread: 4 }, // ENTITY: customers { slug: 'lina-garcia', kind: 'customer', name: 'Lina Garcia', summary: 'Reminder sent · awaiting reply · 14m', pinned: true, unread: 0, tags: ['Garcia', 'Lina', 'Maria'] }, { slug: 'patel-family', kind: 'customer', name: 'Patel family', summary: 'Exterior repaint · moved to Thursday · weather hold', pinned: true, unread: 1, tags: ['Patel'] }, { slug: 'henderson-lead',kind: 'lead', name: 'Henderson · lead', summary: 'Pier & beam quote · cold 6 days · auto-followup queued', pinned: false, unread: 1, tags: ['Henderson'] }, { slug: 'reyes-bakery', kind: 'customer', name: 'Reyes Bakery', summary: 'Site visit booked Tue · new lead Apr 25', pinned: false, unread: 0, tags: ['Reyes'] }, { slug: 'chen-kitchen', kind: 'customer', name: 'Chen kitchen', summary: 'Finishing trim · job wraps today', pinned: false, unread: 0, tags: ['Chen'] }, // ENTITY: jobs / projects { slug: 'invoice-1031', kind: 'invoice', name: 'Invoice #1031', summary: '$4,800 · 7d overdue · nudge sent', pinned: false, unread: 0, tags: ['#1031', '1031'] }, { slug: 'patel-exterior',kind: 'job', name: 'Patel exterior', summary: 'Day 1 of 3 · Thu 9 AM · 70% rain Wed', pinned: false, unread: 1, tags: ['Patel exterior', 'repaint'] }, { slug: 'williams-porch',kind: 'job', name: 'Williams porch', summary: 'Estimate today 1 PM · 442 Cedar', pinned: false, unread: 0, tags: ['Williams'] }, // ENTITY: vendors / receipts { slug: 'sherwin-receipts', kind: 'vendor', name: 'Sherwin Williams receipts', summary: '3 logged this month · auto-matched to jobs', pinned: false, unread: 0, tags: ['Sherwin', 'paint'] }, // ENTITY: cross-cutting (Kayser proposes these — user confirmed) { slug: 'cashflow', kind: 'topic', name: 'Cashflow (this month)', summary: '$11,200 outstanding · 3 invoices · auto-nudge plan drafted', pinned: false, unread: 2, tags: ['overdue', 'invoice', 'cashflow', '#1029', '#1028'] }, ]; // Sort order: pinned first, then unread > 0 (most unread first), then alpha function kxv2SortThreads(threads) { return [...threads].sort((a, b) => { if (a.slug === 'now') return -1; if (b.slug === 'now') return 1; if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; const aU = a.unread || 0, bU = b.unread || 0; if (aU !== bU) return bU - aU; return a.name.localeCompare(b.name); }); } // Filter messages from the master stream into a given thread. // // Live messages (from /api/v1/...) all carry a `thread` field on every // envelope. When ANY message has `thread` set we treat that as // authoritative — return only the rows that match (or are unattributed // system bubbles like typing dots). This is the only path that // matters for Brothers. // // The fall-through (tag-substring matching against KXV2_THREADS) only // fires in the standalone prototype demo where messages come from // data.jsx mocks without `thread` fields. function kxv2FilterMessages(messages, threadSlug) { const liveTagged = messages.some(m => m.thread); if (liveTagged) { return messages.filter(m => !m.thread || m.thread === threadSlug); } // Prototype demo path if (!threadSlug || threadSlug === 'now') return messages; const t = KXV2_THREADS.find(x => x.slug === threadSlug); if (!t || !t.tags) return []; const tags = t.tags.map(x => x.toLowerCase()); return messages.filter(m => { const haystack = [ m.text || '', m.card?.title || '', m.card?.subtitle || '', m.card?.body || '', m.card?.meta || '', ].join(' ').toLowerCase(); return tags.some(tag => haystack.includes(tag)); }); } // ============ THREAD ICON ============ function ThreadIcon({ kind, size = 28 }) { const map = { now: { bg: 'rgba(124,58,237,.25)', fg: '#c4b5fd', glyph: '◉' }, customer: { bg: 'rgba(96,165,250,.20)', fg: '#bfdbfe', glyph: 'C' }, lead: { bg: 'rgba(251,191,36,.20)', fg: '#fde68a', glyph: 'L' }, job: { bg: 'rgba(52,211,153,.20)', fg: '#a7f3d0', glyph: 'J' }, invoice: { bg: 'rgba(251,113,133,.20)', fg: '#fda4af', glyph: '$' }, vendor: { bg: 'rgba(255,255,255,.10)', fg: 'rgba(255,255,255,.7)', glyph: 'V' }, topic: { bg: 'rgba(124,58,237,.20)', fg: '#c4b5fd', glyph: '◆' }, }[kind] || { bg: 'rgba(255,255,255,.08)', fg: 'rgba(255,255,255,.5)', glyph: '·' }; return (
{map.glyph}
); } // ============ THREAD ROW MENU ============ // Small popover anchored to the 3-dots. Stays open until the user picks // an action or clicks outside. Two views: the root list (Rename / Move / // Delete) and the move-to-folder picker — both rendered inline (no true // submenu) so we don't have to deal with portal positioning math. function ThreadRowMenu({ thread, folders, onClose, onRename, onMove, onDelete, }) { const [view, setView] = React.useState('root'); // 'root' | 'move' const ref = React.useRef(null); React.useEffect(() => { const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose?.(); }; const onKey = (e) => { if (e.key === 'Escape') onClose?.(); }; document.addEventListener('mousedown', onDown); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); }; }, [onClose]); const itemStyle = { display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderRadius: 8, background: 'transparent', border: 'none', cursor: 'pointer', fontFamily: KX2.fontUI, fontSize: 13, color: KX2.ink, width: '100%', textAlign: 'left', }; return (
e.stopPropagation()} style={{ position: 'absolute', right: 8, top: 36, zIndex: 50, ...kx2Glass({ borderRadius: 12 }), padding: 4, minWidth: 180, maxWidth: 240, boxShadow: '0 10px 30px rgba(0,0,0,.4)', }}> {view === 'root' ? ( <>
) : ( <>
{(folders || []).map((f) => ( ))} {thread.folder_id && ( <>
)} {(!folders || !folders.length) && (
No folders yet. Create one from the sidebar.
)} )}
); } // ============ THREAD ROW ============ // Renders one thread. If `onMenu` is provided (live mode only), shows a // hover-revealed 3-dots button on the right. The menu itself is owned // by the rail/sheet — the row just emits the open intent. function ThreadRow({ thread, active, onClick, dense, menuOpen, onMenuToggle, folders, onRename, onMove, onDelete, }) { const [hover, setHover] = React.useState(false); // Hide the 3-dots menu for special pseudo-threads: the always-on // "now" thread (no row exists, can't rename/move/delete) and the // local "__draft__" sentinel (also no row, vanishes on send). const isSpecial = thread.slug === 'now' || thread.slug === '__draft__'; const showMenuButton = !!onMenuToggle && !isSpecial; const showButton = showMenuButton && (hover || menuOpen); // We render the row as a div (not )}
{menuOpen && ( onMenuToggle?.(null)} onRename={onRename} onMove={onMove} onDelete={onDelete} /> )}
); } // ============ FOLDER SECTION ============ // A collapsible group inside the rail. Header shows the chevron, the // folder name (inline-editable on rename), the count, and a tiny menu // button for rename/delete. function FolderSection({ folder, threads, activeSlug, dense, defaultOpen = true, menuThreadSlug, onMenuToggle, folders, onRenameThread, onMoveThread, onDeleteThread, onRenameFolder, onDeleteFolder, onPickThread, }) { const [open, setOpen] = React.useState(defaultOpen); const [folderMenu, setFolderMenu] = React.useState(false); const [editing, setEditing] = React.useState(false); const [draft, setDraft] = React.useState(folder.name); React.useEffect(() => { setDraft(folder.name); }, [folder.name]); const folderMenuRef = React.useRef(null); React.useEffect(() => { if (!folderMenu) return; const onDown = (e) => { if (folderMenuRef.current && !folderMenuRef.current.contains(e.target)) { setFolderMenu(false); } }; document.addEventListener('mousedown', onDown); return () => document.removeEventListener('mousedown', onDown); }, [folderMenu]); const commitRename = () => { const name = draft.trim(); setEditing(false); if (name && name !== folder.name) onRenameFolder?.(folder, name); else setDraft(folder.name); }; return (
{editing ? ( setDraft(e.target.value)} onBlur={commitRename} onKeyDown={(e) => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') { setEditing(false); setDraft(folder.name); } }} style={{ flex: 1, background: KX2.chipBg, border: `1px solid ${KX2.glassLine}`, outline: 'none', borderRadius: 6, padding: '2px 6px', fontFamily: KX2.fontMono, fontSize: 10, letterSpacing: 0.6, color: KX2.ink, textTransform: 'uppercase', }} /> ) : ( )} {folderMenu && (
)}
{open && (
{threads.map((t) => ( onPickThread?.(t.slug)} menuOpen={menuThreadSlug === t.slug} onMenuToggle={onMenuToggle} folders={folders} onRename={onRenameThread} onMove={onMoveThread} onDelete={onDeleteThread} /> ))} {threads.length === 0 && (
Empty folder
)}
)}
); } // ============ THREADS SHEET (mobile / tablet) ============ function KxV2ThreadsSheet({ open, onClose, activeSlug, onPick, threads, onNew, folders, onNewFolder, onRenameFolder, onDeleteFolder, onRenameThread, onMoveThread, onDeleteThread, }) { const [q, setQ] = React.useState(''); const [menuSlug, setMenuSlug] = React.useState(null); React.useEffect(() => { if (open) { setQ(''); setMenuSlug(null); } }, [open]); // Real threads (from window.__kxThreads / kxApi('/threads')) override // the mock list when present. A signed-in operator (KX_TOKEN set) is // ALWAYS in live mode — even if `threads` is briefly empty during the // first fetch — so they never see fake customer names like "Patel // family" / "Lina Garcia" rendered as if they were real. The mock list // is reserved for the standalone/file:// prototype with no token. const liveMode = !!window.KX_TOKEN || !!(threads && threads.length); const newDisabled = activeSlug === '__draft__'; const source = liveMode ? (threads || []) : KXV2_THREADS; const filtered = source.filter(t => !q || t.name.toLowerCase().includes(q.toLowerCase()) || (t.summary || '').toLowerCase().includes(q.toLowerCase()) ); const sorted = liveMode ? filtered : kxv2SortThreads(filtered); const groups = [ { id: 'now', label: '', match: t => t.slug === 'now' }, { id: 'pinned', label: 'Pinned', match: t => t.slug !== 'now' && t.pinned }, { id: 'recent', label: 'Recent', match: t => t.slug !== 'now' && !t.pinned }, ]; const onMenuToggle = (t) => setMenuSlug(t ? (menuSlug === t.slug ? null : t.slug) : null); const onPickThread = (slug) => { onPick?.(slug); onClose?.(); }; // Bucket live-mode threads by folder for the sheet rendering. const liveFolders = liveMode ? (folders || []) : []; const liveFolderIds = new Set(liveFolders.map((f) => String(f.id))); const nowThread = liveMode ? sorted.find((t) => t.slug === 'now') : null; const nonNow = liveMode ? sorted.filter((t) => t.slug !== 'now') : []; const rootThreads = nonNow.filter((t) => !t.folder_id || !liveFolderIds.has(String(t.folder_id))); const folderBuckets = liveFolders.map((f) => ({ folder: f, threads: nonNow.filter((t) => String(t.folder_id) === String(f.id)), })); return (
setQ(e.target.value)} placeholder="Search conversations…" style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', color: KX2.ink, fontSize: 14, fontFamily: KX2.fontUI, }} />
{onNew && ( )}
{liveMode && onNewFolder && (
)}
{liveMode ? ( <> {nowThread && ( onPickThread(nowThread.slug)} /> )} {folderBuckets.map(({ folder, threads }) => ( ))} {rootThreads.length > 0 && (
{folderBuckets.length > 0 && (
Conversations
)}
{rootThreads.map(t => ( onPickThread(t.slug)} menuOpen={menuSlug === t.slug} onMenuToggle={onMenuToggle} folders={liveFolders} onRename={onRenameThread} onMove={onMoveThread} onDelete={onDeleteThread} /> ))}
)} ) : ( groups.map(g => { const items = sorted.filter(g.match); if (!items.length) return null; return (
{g.label && (
{g.label}
)}
{items.map(t => ( { onPick?.(t.slug); onClose?.(); }} /> ))}
); }) )}
); } // ============ THREADS RAIL (desktop left rail) ============ function KxV2ThreadsRail({ activeSlug, onPick, onCollapse, threads, onNew, folders, onNewFolder, onRenameFolder, onDeleteFolder, onRenameThread, onMoveThread, onDeleteThread, }) { const [q, setQ] = React.useState(''); const [menuSlug, setMenuSlug] = React.useState(null); const [searchHits, setSearchHits] = React.useState([]); // A signed-in operator (KX_TOKEN set) is always in live mode — see // the matching comment on KxV2ThreadsSheet above. The mock list is // reserved for the standalone/file:// prototype with no token. const liveMode = !!window.KX_TOKEN || !!(threads && threads.length); // "+ New conversation" is a no-op when the user is already sitting on // an unsaved draft. Dim the button so they see why nothing happens. const newDisabled = activeSlug === '__draft__'; const source = liveMode ? (threads || []) : KXV2_THREADS; const filtered = source.filter(t => !q || t.name.toLowerCase().includes(q.toLowerCase()) || (t.summary || '').toLowerCase().includes(q.toLowerCase()) ); // Debounced /search call so the rail finds "that thing I said about // Patel" — fires only in live mode, only on queries ≥2 chars. The // results are message-content hits (snippets + thread title) that // render below the thread-name matches. React.useEffect(() => { if (!liveMode || !window.kxApi || q.trim().length < 2) { setSearchHits([]); return; } const t = setTimeout(async () => { try { const hits = await window.kxApi('/search?q=' + encodeURIComponent(q.trim()) + '&limit=10'); setSearchHits(Array.isArray(hits) ? hits : []); } catch (_) { setSearchHits([]); } }, 250); return () => clearTimeout(t); }, [q, liveMode]); const searching = q.trim().length >= 2 && liveMode; // Live mode: server already orders by GREATEST(last_message_ts, // created_at) DESC, so new threads sit at the top. Re-sorting // alphabetically (kxv2SortThreads) would bury them. Mock mode // keeps the original demo grouping. const sorted = liveMode ? filtered : kxv2SortThreads(filtered); const groups = [ { id: 'now', label: '', match: t => t.slug === 'now' }, { id: 'pinned', label: 'Pinned', match: t => t.slug !== 'now' && t.pinned }, { id: 'recent', label: 'Active', match: t => t.slug !== 'now' && !t.pinned }, ]; const onMenuToggle = (t) => setMenuSlug(t ? (menuSlug === t.slug ? null : t.slug) : null); // Bucket live-mode threads by folder. Archived folders (filtered out // server-side anyway) won't appear here; if a thread carries a // folder_id we don't know about (e.g. stale local state), we fall // back to rendering it at root. const liveFolders = liveMode ? (folders || []) : []; const liveFolderIds = new Set(liveFolders.map((f) => String(f.id))); const nowThread = liveMode ? sorted.find((t) => t.slug === 'now') : null; const nonNow = liveMode ? sorted.filter((t) => t.slug !== 'now') : []; const rootThreads = nonNow.filter( (t) => !t.folder_id || !liveFolderIds.has(String(t.folder_id)) ); const folderBuckets = liveFolders.map((f) => ({ folder: f, threads: nonNow.filter((t) => String(t.folder_id) === String(f.id)), })); return (
setQ(e.target.value)} placeholder="Search conversations…" style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', color: KX2.ink, fontSize: 12.5, fontFamily: KX2.fontUI, }} /> {q && ( )}
{(onNew || onNewFolder) && (
{onNew && ( )} {liveMode && onNewFolder && ( )}
)}
{searching && (
Matches {searchHits.length ? `(${searchHits.length})` : ''}
{searchHits.length === 0 ? (
No message matches.
) : ( searchHits.map((h, i) => ( )) )}
Threads
)} {liveMode ? ( <> {nowThread && ( onPick?.(nowThread.slug)} /> )} {folderBuckets.map(({ folder, threads }) => ( onPick?.(slug)} /> ))} {rootThreads.length > 0 && (
{folderBuckets.length > 0 && (
Conversations
)} {rootThreads.map(t => ( onPick?.(t.slug)} menuOpen={menuSlug === t.slug} onMenuToggle={onMenuToggle} folders={liveFolders} onRename={onRenameThread} onMove={onMoveThread} onDelete={onDeleteThread} /> ))}
)} ) : ( groups.map(g => { const items = sorted.filter(g.match); if (!items.length) return null; return (
{g.label && (
{g.label}
)} {items.map(t => ( onPick?.(t.slug)} /> ))}
); }) )}
); } // ============ SCOPED HEADER (shown above chat when not on "now") ============ function KxV2ScopedHeader({ thread, onBack }) { if (!thread || thread.slug === 'now' || thread.slug === '__draft__') return null; const seenKey = `kxv2_scoped_seen_${thread.slug}`; const [showHint, setShowHint] = React.useState(() => { try { return !localStorage.getItem(seenKey); } catch { return true; } }); React.useEffect(() => { // Show again if you switch into a thread you haven't seen this scope tip for try { setShowHint(!localStorage.getItem(seenKey)); } catch {} }, [thread.slug, seenKey]); const dismiss = () => { setShowHint(false); try { localStorage.setItem(seenKey, '1'); } catch {} }; return ( <>
·
{thread.name}
{thread.summary}
{showHint && (
💡
Just {thread.name} now. Tap Now at the top to see everything again.
)} ); } Object.assign(window, { KXV2_THREADS, kxv2SortThreads, kxv2FilterMessages, KxV2ThreadsSheet, KxV2ThreadsRail, KxV2ScopedHeader, ThreadIcon, ThreadRow, ThreadRowMenu, FolderSection, });