// 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' ? (
<>
{ onRename?.(thread); onClose?.(); }}
onMouseEnter={(e) => { e.currentTarget.style.background = KX2.chipBg; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
✎ Rename
setView('move')}
onMouseEnter={(e) => { e.currentTarget.style.background = KX2.chipBg; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
▸
Move to folder
›
{ onDelete?.(thread); onClose?.(); }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(248,113,113,.12)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
🗑 Delete
>
) : (
<>
setView('root')}
onMouseEnter={(e) => { e.currentTarget.style.background = KX2.chipBg; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
‹ BACK
{(folders || []).map((f) => (
{ onMove?.(thread, f.id); onClose?.(); }}
onMouseEnter={(e) => { e.currentTarget.style.background = KX2.chipBg; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
▦
{f.name}
{String(thread.folder_id) === String(f.id) && (
✓
)}
))}
{thread.folder_id && (
<>
{ onMove?.(thread, null); onClose?.(); }}
onMouseEnter={(e) => { e.currentTarget.style.background = KX2.chipBg; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
↺ Remove from folder
>
)}
{(!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 ) so the menu can be a
// sibling without nesting interactive elements. Click on the row body
// selects; click on the 3-dots stops propagation and opens the menu.
return (
setHover(true)}
onMouseLeave={() => setHover(false)}
>
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick?.(); } }}
style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
padding: dense ? '8px 10px' : '10px 12px',
borderRadius: 10, cursor: 'pointer', textAlign: 'left',
border: 'none',
background: active ? KX2.chipBgActive : (hover ? KX2.chipBg : 'transparent'),
fontFamily: KX2.fontUI, color: KX2.ink, marginBottom: 2,
}}
>
{thread.name}
{thread.pinned && (
PIN
)}
{thread.summary}
{thread.unread > 0 && !showButton && (
{thread.unread}
)}
{showButton && (
{ e.stopPropagation(); onMenuToggle?.(thread); }}
style={{
width: 24, height: 24, borderRadius: 6, border: 'none',
background: menuOpen ? KX2.chipBgActive : 'transparent',
color: KX2.ink2, cursor: 'pointer', flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 16, lineHeight: 1, padding: 0,
}}
onMouseEnter={(e) => { e.currentTarget.style.background = KX2.chipBgActive; }}
onMouseLeave={(e) => {
e.currentTarget.style.background = menuOpen ? KX2.chipBgActive : 'transparent';
}}>⋯
)}
{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 (
setOpen((o) => !o)}
aria-label={open ? 'Collapse folder' : 'Expand folder'}
style={{
width: 16, height: 16, border: 'none', background: 'transparent',
color: KX2.ink3, cursor: 'pointer', padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
transition: 'transform .12s ease',
}}>
{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',
}}
/>
) : (
setOpen((o) => !o)}
onDoubleClick={() => setEditing(true)}
style={{
flex: 1, background: 'transparent', border: 'none',
fontFamily: KX2.fontMono, fontSize: 10, color: KX2.ink3,
letterSpacing: 0.6, textTransform: 'uppercase',
textAlign: 'left', padding: 0, cursor: 'pointer',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{folder.name}
{threads.length}
)}
setFolderMenu((v) => !v)}
style={{
width: 22, height: 22, borderRadius: 6, border: 'none',
background: folderMenu ? KX2.chipBgActive : 'transparent',
color: KX2.ink3, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, lineHeight: 1, padding: 0,
}}>⋯
{folderMenu && (
{ setEditing(true); setFolderMenu(false); }}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '8px 12px', borderRadius: 8, border: 'none',
background: 'transparent', cursor: 'pointer',
fontFamily: KX2.fontUI, fontSize: 13, color: KX2.ink, textAlign: 'left',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = KX2.chipBg; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
✎ Rename
{ onDeleteFolder?.(folder); setFolderMenu(false); }}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '8px 12px', borderRadius: 8, border: 'none',
background: 'transparent', cursor: 'pointer',
fontFamily: KX2.fontUI, fontSize: 13, color: '#fca5a5', textAlign: 'left',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(248,113,113,.12)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
🗑 Delete folder
)}
{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 && (
{ onClose?.(); onNew(); }}
disabled={newDisabled}
title={newDisabled ? "You're already in a new conversation — type to start." : 'Start a new conversation'}
style={{
padding: '8px 14px', borderRadius: 12, border: 'none',
background: newDisabled ? KX2.chipBg : KX2.brand,
color: newDisabled ? KX2.ink3 : '#fff',
cursor: newDisabled ? 'not-allowed' : 'pointer',
opacity: newDisabled ? 0.6 : 1,
fontFamily: KX2.fontUI, fontSize: 13, fontWeight: 600,
boxShadow: newDisabled ? 'none' : '0 6px 18px rgba(124,58,237,.35)',
}}>+ New
)}
{liveMode && onNewFolder && (
+ New folder
)}
{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 && (
setQ('')}
aria-label="Clear search"
title="Clear"
style={{
width: 16, height: 16, border: 'none', padding: 0,
background: 'transparent', color: KX2.ink3, cursor: 'pointer',
fontSize: 12, lineHeight: 1,
}}>×
)}
{(onNew || onNewFolder) && (
{onNew && (
New conversation
)}
{liveMode && onNewFolder && (
New folder
)}
)}
{searching && (
Matches {searchHits.length ? `(${searchHits.length})` : ''}
{searchHits.length === 0 ? (
No message matches.
) : (
searchHits.map((h, i) => (
onPick?.(h.thread_id)}
title={h.snippet}
style={{
display: 'block', width: '100%', textAlign: 'left',
background: 'transparent', border: 'none',
padding: '6px 10px', borderRadius: 8, cursor: 'pointer',
marginBottom: 2,
}}
onMouseEnter={(e) => { e.currentTarget.style.background = KX2.chipBg; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
{h.thread_title}
{h.role === 'user' ? 'YOU' : 'AGENT'}
{h.snippet}
))
)}
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 (
<>
Now
·
{thread.name}
{thread.summary}
{showHint && (
💡
Just {thread.name} now. Tap Now at the top to see everything again.
Got it
)}
>
);
}
Object.assign(window, {
KXV2_THREADS, kxv2SortThreads, kxv2FilterMessages,
KxV2ThreadsSheet, KxV2ThreadsRail, KxV2ScopedHeader, ThreadIcon, ThreadRow,
ThreadRowMenu, FolderSection,
});