/** * — local-first chat with channels, threads, DMs, reactions. * * Single web component per space, loaded on /{space}/rchats. * Subscribes to Automerge CRDT docs for real-time sync. */ import { chatChannelSchema, chatsDirectorySchema, chatsDirectoryDocId, chatChannelDocId, dmChannelDocId, type ChatChannelDoc, type ChatsDirectoryDoc, type ChatMessage, type ChannelInfo, type Transclusion, } from '../schemas'; import { startPresenceHeartbeat } from '../../../shared/collab-presence'; // ── Helpers ── function timeAgo(ts: number): string { const diff = Date.now() - ts; if (diff < 60_000) return 'just now'; if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; return new Date(ts).toLocaleDateString(); } function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } /** Simple markdown: **bold**, *italic*, `code`, [text](url) */ function renderMarkdown(text: string): string { let html = escapeHtml(text); html = html.replace(/\*\*(.+?)\*\*/g, '$1'); html = html.replace(/\*(.+?)\*/g, '$1'); html = html.replace(/`(.+?)`/g, '$1'); html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); html = html.replace(/@([a-zA-Z0-9_.-]+)/g, '@$1'); return html; } function getAuthToken(): string { try { const stored = localStorage.getItem('rspace_auth'); if (stored) { const parsed = JSON.parse(stored); return parsed.token || ''; } } catch {} return ''; } function getMyDid(): string { try { const stored = localStorage.getItem('rspace_auth'); if (stored) { const parsed = JSON.parse(stored); return parsed.did || parsed.userId || ''; } } catch {} return ''; } function getMyName(): string { try { const stored = localStorage.getItem('rspace_auth'); if (stored) { const parsed = JSON.parse(stored); return parsed.displayName || parsed.username || 'Anonymous'; } } catch {} return 'Anonymous'; } // ── Component ── class FolkChatApp extends HTMLElement { private shadow: ShadowRoot; private space = ''; private _offlineUnsubs: (() => void)[] = []; private _stopPresence: (() => void) | null = null; // State private channels: ChannelInfo[] = []; private activeChannelId = ''; private messages: ChatMessage[] = []; private threadRootId: string | null = null; private threadMessages: ChatMessage[] = []; private dmChannels: ChannelInfo[] = []; private isDmView = false; private activeDmDocId = ''; private loading = false; private composerText = ''; private threadComposerText = ''; private showCreateChannel = false; private showEmojiPicker: string | null = null; // msgId private typingUsers: string[] = []; private _typingTimeout: ReturnType | null = null; constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); } connectedCallback() { this.space = this.getAttribute('space') || 'demo'; // Parse URL params for deep links const params = new URLSearchParams(window.location.search); const channelParam = params.get('channel'); const dmParam = params.get('dm'); const threadParam = params.get('thread'); if (dmParam) { this.isDmView = true; } this.render(); this.loadChannels().then(() => { if (channelParam && this.channels.find(c => c.id === channelParam)) { this.selectChannel(channelParam); } else if (this.channels.length > 0) { this.selectChannel(this.channels[0].id); } if (threadParam) { this.openThread(threadParam); } }); this.loadDmChannels(); this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rchats', context: this.activeChannelId || 'channels', })); } disconnectedCallback() { for (const unsub of this._offlineUnsubs) unsub(); this._offlineUnsubs = []; this._stopPresence?.(); } // ── Data Loading ── private async loadChannels() { try { const token = getAuthToken(); const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; const res = await fetch(`/${this.space}/rchats/api/channels`, { headers }); const data = await res.json(); this.channels = (data.channels || []).filter((c: ChannelInfo) => !c.isDm); this.render(); } catch { this.channels = []; } } private async loadDmChannels() { try { const token = getAuthToken(); if (!token) return; const res = await fetch(`/${this.space}/rchats/api/dm`, { headers: { 'Authorization': `Bearer ${token}` }, }); const data = await res.json(); this.dmChannels = data.channels || []; this.render(); } catch {} } private async loadMessages(channelId: string) { this.loading = true; this.render(); try { const token = getAuthToken(); const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; const res = await fetch(`/${this.space}/rchats/api/channels/${channelId}/messages`, { headers }); const data = await res.json(); this.messages = data.messages || []; } catch { this.messages = []; } this.loading = false; this.render(); this.scrollToBottom(); } private async loadThreadMessages(threadId: string) { try { const token = getAuthToken(); const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; const res = await fetch(`/${this.space}/rchats/api/channels/${this.activeChannelId}/threads/${threadId}`, { headers }); const data = await res.json(); this.threadMessages = data.replies || []; this.render(); } catch {} } // ── Actions ── private selectChannel(channelId: string) { this.activeChannelId = channelId; this.isDmView = false; this.threadRootId = null; this.threadMessages = []; this.loadMessages(channelId); // Subscribe to offline runtime for real-time sync this.subscribeToChannel(channelId); } private async subscribeToChannel(channelId: string) { const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; try { const docId = `${this.space}:chats:channel:${channelId}`; await runtime.subscribe(docId, chatChannelSchema); const unsub = runtime.onChange(docId, (doc: ChatChannelDoc) => { this.messages = Object.values(doc.messages || {}) .filter(m => !m.threadId) .sort((a, b) => a.createdAt - b.createdAt); this.render(); this.scrollToBottom(); }); this._offlineUnsubs.push(unsub); } catch {} } private openThread(msgId: string) { this.threadRootId = msgId; this.threadComposerText = ''; this.loadThreadMessages(msgId); } private closeThread() { this.threadRootId = null; this.threadMessages = []; this.threadComposerText = ''; this.render(); } private async sendMessage() { const content = this.composerText.trim(); if (!content) return; const token = getAuthToken(); if (!token) return; this.composerText = ''; this.render(); try { await fetch(`/${this.space}/rchats/api/channels/${this.activeChannelId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ content }), }); await this.loadMessages(this.activeChannelId); } catch {} } private async sendThreadReply() { const content = this.threadComposerText.trim(); if (!content || !this.threadRootId) return; const token = getAuthToken(); if (!token) return; this.threadComposerText = ''; this.render(); try { await fetch(`/${this.space}/rchats/api/channels/${this.activeChannelId}/messages/${this.threadRootId}/thread`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ content }), }); await this.loadThreadMessages(this.threadRootId); } catch {} } private async toggleReaction(msgId: string, emoji: string) { const token = getAuthToken(); if (!token) return; try { await fetch(`/${this.space}/rchats/api/channels/${this.activeChannelId}/messages/${msgId}/react`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ emoji }), }); await this.loadMessages(this.activeChannelId); } catch {} } private async createChannel() { const nameInput = this.shadow.querySelector('#new-channel-name') as HTMLInputElement; const descInput = this.shadow.querySelector('#new-channel-desc') as HTMLInputElement; if (!nameInput?.value.trim()) return; const token = getAuthToken(); if (!token) return; try { const res = await fetch(`/${this.space}/rchats/api/channels`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ name: nameInput.value.trim(), description: descInput?.value || '' }), }); if (res.ok) { const channel = await res.json(); this.showCreateChannel = false; await this.loadChannels(); this.selectChannel(channel.id); } } catch {} } private async joinChannel(channelId: string) { const token = getAuthToken(); if (!token) return; try { await fetch(`/${this.space}/rchats/api/channels/${channelId}/join`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, }); } catch {} } private async deleteMessage(msgId: string) { const token = getAuthToken(); if (!token) return; try { await fetch(`/${this.space}/rchats/api/channels/${this.activeChannelId}/messages/${msgId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` }, }); await this.loadMessages(this.activeChannelId); } catch {} } private async togglePin(msgId: string) { const token = getAuthToken(); if (!token) return; try { await fetch(`/${this.space}/rchats/api/channels/${this.activeChannelId}/pins`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ messageId: msgId }), }); await this.loadMessages(this.activeChannelId); } catch {} } // ── Scroll ── private scrollToBottom() { requestAnimationFrame(() => { const feed = this.shadow.querySelector('.message-feed'); if (feed) feed.scrollTop = feed.scrollHeight; }); } // ── Rendering ── private render() { const myDid = getMyDid(); const hasAuth = !!getAuthToken(); this.shadow.innerHTML = `
${this.renderSidebar()} ${this.renderMainArea(myDid, hasAuth)} ${this.threadRootId ? this.renderThreadPanel(myDid, hasAuth) : ''}
`; this.attachEventListeners(); } private renderSidebar(): string { return ` `; } private renderMainArea(myDid: string, hasAuth: boolean): string { const activeChannel = this.channels.find(c => c.id === this.activeChannelId); const channelName = activeChannel?.name || this.activeChannelId || 'Select a channel'; return `

# ${escapeHtml(channelName)}

${activeChannel?.description ? `${escapeHtml(activeChannel.description)}` : ''}
${this.loading ? '
Loading messages...
' : ''} ${!this.loading && this.messages.length === 0 ? `
💬

No messages yet. Start the conversation!

` : ''} ${this.messages.map(m => this.renderMessage(m, myDid)).join('')}
${this.typingUsers.length > 0 ? `
${this.typingUsers.join(', ')} typing...
` : ''} ${hasAuth ? `
` : `

Sign in to send messages

`}
`; } private renderMessage(msg: ChatMessage, myDid: string): string { const isOwn = msg.authorId === myDid; const reactions = msg.reactions || {}; const reactionKeys = Object.keys(reactions); const threadMeta = this.messages.find(m => m.id === msg.id); // for thread count const hasThread = this.messages.some(m => m.replyTo === msg.id) || (msg as any)._threadReplyCount > 0; return `
${escapeHtml(msg.authorName.charAt(0).toUpperCase())}
${escapeHtml(msg.authorName)} ${timeAgo(msg.createdAt)} ${msg.editedAt ? '(edited)' : ''}
${isOwn ? `` : ''}
${renderMarkdown(msg.content)}
${this.renderTransclusions(msg.transclusions || [])} ${reactionKeys.length > 0 ? `
${reactionKeys.map(emoji => { const dids = reactions[emoji] || []; const active = dids.includes(myDid); return ``; }).join('')}
` : ''}
`; } private renderTransclusions(transclusions: Transclusion[]): string { if (!transclusions || transclusions.length === 0) return ''; return `
${transclusions.map(t => { const title = t.snapshot?.title || `${t.module}/${t.objectId || t.docId}`; const summary = t.snapshot?.summary || ''; if (t.display === 'link') { return `${escapeHtml(title)}`; } return `
${escapeHtml(t.module)}
${escapeHtml(title)}
${summary ? `
${escapeHtml(summary)}
` : ''}
`; }).join('')}
`; } private renderThreadPanel(myDid: string, hasAuth: boolean): string { const rootMsg = this.messages.find(m => m.id === this.threadRootId); return ` `; } // ── Event Listeners ── private attachEventListeners() { // Channel selection this.shadow.querySelectorAll('.channel-item[data-channel]').forEach(el => { el.addEventListener('click', () => { const channelId = (el as HTMLElement).dataset.channel!; this.selectChannel(channelId); }); }); // Create channel this.shadow.getElementById('btn-create-channel')?.addEventListener('click', () => { this.showCreateChannel = !this.showCreateChannel; this.render(); if (this.showCreateChannel) { setTimeout(() => this.shadow.getElementById('new-channel-name')?.focus(), 50); } }); this.shadow.getElementById('btn-confirm-create')?.addEventListener('click', () => this.createChannel()); this.shadow.getElementById('btn-cancel-create')?.addEventListener('click', () => { this.showCreateChannel = false; this.render(); }); // Main composer const mainComposer = this.shadow.getElementById('composer-main') as HTMLTextAreaElement; if (mainComposer) { mainComposer.addEventListener('input', () => { this.composerText = mainComposer.value; this.autoResize(mainComposer); }); mainComposer.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); } }); } this.shadow.getElementById('btn-send')?.addEventListener('click', () => this.sendMessage()); // Thread composer const threadComposer = this.shadow.getElementById('composer-thread') as HTMLTextAreaElement; if (threadComposer) { threadComposer.addEventListener('input', () => { this.threadComposerText = threadComposer.value; this.autoResize(threadComposer); }); threadComposer.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendThreadReply(); } }); } this.shadow.getElementById('btn-send-thread')?.addEventListener('click', () => this.sendThreadReply()); // Close thread this.shadow.getElementById('btn-close-thread')?.addEventListener('click', () => this.closeThread()); // Message actions this.shadow.querySelectorAll('[data-action]').forEach(el => { el.addEventListener('click', (e) => { const action = (el as HTMLElement).dataset.action!; const msgId = (el as HTMLElement).dataset.msg!; const emoji = (el as HTMLElement).dataset.emoji; switch (action) { case 'thread': this.openThread(msgId); break; case 'react': this.showQuickReactions(msgId, el as HTMLElement); break; case 'react-toggle': if (emoji) this.toggleReaction(msgId, emoji); break; case 'pin': this.togglePin(msgId); break; case 'delete': if (confirm('Delete this message?')) this.deleteMessage(msgId); break; } e.stopPropagation(); }); }); } private autoResize(textarea: HTMLTextAreaElement) { textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; } private showQuickReactions(msgId: string, anchor: HTMLElement) { const quickEmojis = ['👍', '❤️', '😂', '🎉', '🤔', '👀']; // Remove existing picker this.shadow.querySelector('.emoji-picker')?.remove(); const picker = document.createElement('div'); picker.className = 'emoji-picker'; picker.innerHTML = quickEmojis.map(e => `` ).join(''); picker.style.position = 'absolute'; const rect = anchor.getBoundingClientRect(); const shadowRect = this.shadow.host.getBoundingClientRect(); picker.style.top = (rect.bottom - shadowRect.top) + 'px'; picker.style.left = (rect.left - shadowRect.left) + 'px'; picker.querySelectorAll('.emoji-btn').forEach(btn => { btn.addEventListener('click', () => { const emoji = (btn as HTMLElement).dataset.emoji!; this.toggleReaction(msgId, emoji); picker.remove(); }); }); // Close on outside click const closeHandler = (e: Event) => { if (!picker.contains(e.target as Node)) { picker.remove(); document.removeEventListener('click', closeHandler); } }; setTimeout(() => document.addEventListener('click', closeHandler), 10); this.shadow.querySelector('.chat-layout')?.appendChild(picker); } } // ── Styles ── const STYLES = ` :host { display: block; height: calc(100vh - 60px); font-family: var(--rs-font, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif); color: var(--rs-text-primary, #e2e8f0); background: var(--rs-bg-base, #0f172a); --chat-sidebar-w: 240px; --chat-thread-w: 340px; --chat-accent: var(--rs-accent, #6366f1); --chat-surface: var(--rs-bg-surface, #1e293b); --chat-border: var(--rs-border, #334155); --chat-text-secondary: var(--rs-text-secondary, #94a3b8); } * { box-sizing: border-box; margin: 0; padding: 0; } .chat-layout { display: grid; grid-template-columns: var(--chat-sidebar-w) 1fr; height: 100%; position: relative; } .chat-layout.has-thread { grid-template-columns: var(--chat-sidebar-w) 1fr var(--chat-thread-w); } /* ── Sidebar ── */ .sidebar { background: var(--chat-surface); border-right: 1px solid var(--chat-border); display: flex; flex-direction: column; overflow-y: auto; } .sidebar-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 12px 8px; } .sidebar-header h2 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--chat-text-secondary); font-weight: 600; } .sidebar-section { padding: 16px 12px 4px; } .sidebar-section h3 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--chat-text-secondary); font-weight: 600; } .btn-icon { background: none; border: none; color: var(--chat-text-secondary); font-size: 1.2rem; cursor: pointer; padding: 2px 6px; border-radius: 4px; line-height: 1; } .btn-icon:hover { background: rgba(255,255,255,0.08); color: var(--rs-text-primary, #e2e8f0); } .channel-list { list-style: none; padding: 0 4px; } .channel-item { display: flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 0.9rem; color: var(--chat-text-secondary); transition: background 0.15s; } .channel-item:hover { background: rgba(255,255,255,0.06); color: var(--rs-text-primary, #e2e8f0); } .channel-item.active { background: rgba(99,102,241,0.15); color: var(--chat-accent); font-weight: 600; } .channel-hash { opacity: 0.5; font-weight: 700; } .channel-lock { font-size: 0.7rem; opacity: 0.5; } .channel-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dm-avatar { font-size: 0.85rem; } .create-channel-form { padding: 8px 12px; display: flex; flex-direction: column; gap: 6px; } .create-channel-form input { background: var(--rs-bg-base, #0f172a); border: 1px solid var(--chat-border); border-radius: 6px; padding: 6px 10px; color: var(--rs-text-primary, #e2e8f0); font-size: 0.85rem; outline: none; } .create-channel-form input:focus { border-color: var(--chat-accent); } .form-actions { display: flex; gap: 6px; } .btn-sm { padding: 4px 12px; border-radius: 6px; font-size: 0.8rem; cursor: pointer; border: none; } .btn-primary { background: var(--chat-accent); color: white; } .btn-primary:hover { opacity: 0.9; } .btn-ghost { background: transparent; color: var(--chat-text-secondary); border: 1px solid var(--chat-border); } .btn-ghost:hover { background: rgba(255,255,255,0.06); } /* ── Main Area ── */ .main-area { display: flex; flex-direction: column; min-width: 0; } .channel-header { display: flex; align-items: baseline; gap: 12px; padding: 12px 20px; border-bottom: 1px solid var(--chat-border); background: var(--chat-surface); } .channel-header h2 { font-size: 1.1rem; font-weight: 700; } .channel-desc { font-size: 0.82rem; color: var(--chat-text-secondary); } .message-feed { flex: 1; overflow-y: auto; padding: 16px 20px; display: flex; flex-direction: column; gap: 2px; } .loading, .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--chat-text-secondary); gap: 8px; } .empty-icon { font-size: 2.5rem; opacity: 0.4; } .empty-state p { font-size: 0.9rem; } /* ── Messages ── */ .message { display: flex; gap: 10px; padding: 6px 8px; border-radius: 6px; transition: background 0.1s; } .message:hover { background: rgba(255,255,255,0.03); } .message:hover .msg-actions { opacity: 1; } .msg-avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--chat-accent); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.85rem; color: white; flex-shrink: 0; } .msg-body { flex: 1; min-width: 0; } .msg-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 2px; } .msg-author { font-weight: 600; font-size: 0.9rem; } .msg-time { font-size: 0.75rem; color: var(--chat-text-secondary); } .msg-edited { font-size: 0.7rem; color: var(--chat-text-secondary); font-style: italic; } .msg-actions { margin-left: auto; display: flex; gap: 2px; opacity: 0; transition: opacity 0.15s; } .msg-action { background: none; border: none; cursor: pointer; padding: 2px 4px; border-radius: 4px; font-size: 0.8rem; line-height: 1; } .msg-action:hover { background: rgba(255,255,255,0.1); } .msg-content { font-size: 0.9rem; line-height: 1.5; word-break: break-word; } .msg-content code { background: rgba(0,0,0,0.3); padding: 1px 4px; border-radius: 3px; font-size: 0.85em; } .msg-content a { color: var(--chat-accent); text-decoration: none; } .msg-content a:hover { text-decoration: underline; } .msg-content .mention { color: var(--chat-accent); font-weight: 600; cursor: pointer; } /* ── Reactions ── */ .msg-reactions { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; } .reaction-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 12px; font-size: 0.8rem; cursor: pointer; background: rgba(255,255,255,0.06); border: 1px solid transparent; color: var(--rs-text-primary, #e2e8f0); transition: all 0.15s; } .reaction-chip:hover { background: rgba(255,255,255,0.1); } .reaction-chip.active { border-color: var(--chat-accent); background: rgba(99,102,241,0.15); } .emoji-picker { display: flex; gap: 4px; padding: 6px 8px; background: var(--chat-surface); border: 1px solid var(--chat-border); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 100; } .emoji-btn { background: none; border: none; font-size: 1.2rem; cursor: pointer; padding: 4px; border-radius: 4px; line-height: 1; } .emoji-btn:hover { background: rgba(255,255,255,0.1); } /* ── Transclusions ── */ .transclusions { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; } .transclusion-card { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--chat-border); background: rgba(255,255,255,0.03); max-width: 360px; } .tc-module { font-size: 0.7rem; text-transform: uppercase; color: var(--chat-accent); letter-spacing: 0.04em; margin-bottom: 2px; } .tc-title { font-size: 0.85rem; font-weight: 600; } .tc-summary { font-size: 0.8rem; color: var(--chat-text-secondary); margin-top: 2px; } .transclusion-link { color: var(--chat-accent); text-decoration: none; font-size: 0.85rem; } .transclusion-link:hover { text-decoration: underline; } /* ── Composer ── */ .composer { display: flex; align-items: flex-end; gap: 8px; padding: 12px 20px 16px; border-top: 1px solid var(--chat-border); background: var(--chat-surface); } .composer-input { flex: 1; background: var(--rs-bg-base, #0f172a); border: 1px solid var(--chat-border); border-radius: 8px; padding: 10px 14px; color: var(--rs-text-primary, #e2e8f0); font-size: 0.9rem; font-family: inherit; resize: none; outline: none; min-height: 40px; max-height: 120px; line-height: 1.4; } .composer-input:focus { border-color: var(--chat-accent); } .composer-input::placeholder { color: var(--chat-text-secondary); } .btn-send { background: var(--chat-accent); border: none; border-radius: 8px; padding: 8px 12px; cursor: pointer; color: white; display: flex; align-items: center; justify-content: center; transition: opacity 0.15s; } .btn-send:hover { opacity: 0.85; } .auth-prompt { justify-content: center; } .auth-prompt p { color: var(--chat-text-secondary); font-size: 0.85rem; } .typing-indicator { padding: 4px 20px; font-size: 0.78rem; color: var(--chat-text-secondary); font-style: italic; } /* ── Thread Panel ── */ .thread-panel { border-left: 1px solid var(--chat-border); background: var(--chat-surface); display: flex; flex-direction: column; } .thread-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--chat-border); } .thread-header h3 { font-size: 1rem; font-weight: 700; } .thread-feed { flex: 1; overflow-y: auto; padding: 12px; } .thread-divider { display: flex; align-items: center; gap: 8px; font-size: 0.75rem; color: var(--chat-text-secondary); margin: 12px 0 8px; } .thread-divider::before, .thread-divider::after { content: ''; flex: 1; height: 1px; background: var(--chat-border); } .thread-composer { border-top: 1px solid var(--chat-border); } /* ── Responsive ── */ @media (max-width: 768px) { :host { --chat-sidebar-w: 0px; } .sidebar { display: none; } .chat-layout.has-thread { grid-template-columns: 1fr; } .thread-panel { position: fixed; top: 60px; right: 0; bottom: 0; width: 100%; z-index: 50; } } `; customElements.define('folk-chat-app', FolkChatApp);