// Quick chat overlay — a Row-list style floating composer that appears over any // pane and either (a) drops the user into a recent session or (b) mints a new // one seeded with their prompt, then closes and switches to the Chat tab. // // Design notes: // - Overlay lives as a plain div inside the app layout, not a separate // BrowserWindow. A second BrowserWindow would need its own preload + // ipcRenderer channel and a second copy of the session cache; that's a // lot of surface for a UI affordance. Using a full-screen scrim div gives // us the same visual and every existing IPC method is reachable directly. // - Global shortcut is registered in main.js via `globalShortcut`; main // sends `quickchat:toggle` on the IPC channel, which we bind below. // - `Escape` closes without sending. `Enter` sends; `Shift+Enter` newline. // - The "recent 5 sessions" list is derived from window.__dshChat state. // If that helper isn't ready yet (very early boot), the list is simply // empty — the composer still works. /* global window, document */ ;(function () { 'use strict' // Small helper reused from other modules — avoids a hard dependency on any // one of them so quick-chat can boot even if a sibling script errored. function el(tag, attrs = {}, children = []) { const node = document.createElement(tag) for (const [k, v] of Object.entries(attrs)) { if (k === 'className') node.className = v else if (k === 'text') node.textContent = v else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v) else if (v === true) node.setAttribute(k, '') else if (v === false || v == null) { /* skip */ } else node.setAttribute(k, v) } for (const c of [].concat(children)) { if (c == null || c === false) continue node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c) } return node } // Pick the N most recent sessions the sidebar knows about, prefering the // ones with real turn activity (running/live > persisted) so the list // isn't dominated by empty stubs from `New session` clicks. Empty sessions // (`hasUserMessage === false`) are dropped up front — the sidebar filters // them the same way (panels-c.filterEmptySessions), so the two lists agree. // Exported for tests via `window.__dshQuickChatInternals`. function pickRecentSessions(entries, limit = 5) { if (!Array.isArray(entries)) return [] // Filter empty stubs first so they don't take slots from real sessions. // Rows that predate the `hasUserMessage` flag pass through (undefined ≠ false). // In production panels-c.js loads first (see index.html); the test harness // doesn't load it, so we fall back to the inline predicate — same rule. const PC = window.__dshPanelsC const filtered = PC && typeof PC.filterEmptySessions === 'function' ? PC.filterEmptySessions(entries) : entries.filter((e) => e && e.hasUserMessage !== false) // Rank on three keys, in order: is-running, is-live, most-recent-activity. // Comparator returns non-zero the moment a higher-precedence key breaks // the tie, so a running session with old activity still beats a fresh // idle one — that matches user intent ("give me what I'm doing now"). const scored = filtered.slice() scored.sort((a, b) => { const runA = a.running ? 1 : 0, runB = b.running ? 1 : 0 if (runA !== runB) return runB - runA const liveA = a.live ? 1 : 0, liveB = b.live ? 1 : 0 if (liveA !== liveB) return liveB - liveA const tA = typeof a.lastEventTime === 'number' ? a.lastEventTime : 0 const tB = typeof b.lastEventTime === 'number' ? b.lastEventTime : 0 return tB - tA }) return scored.slice(0, limit) } // Expose the pure helper for `node --test` — the test file requires this // via a light shim (see test/quick-chat.test.js) so we don't have to // migrate the whole module out of the script-tag world. window.__dshQuickChatInternals = { pickRecentSessions } let overlayEl = null let inputEl = null let recentEl = null let open = false function ensureDom() { if (overlayEl) return overlayEl = el('div', { className: 'quickchat-scrim', hidden: true, 'data-quickchat': '' }) overlayEl.addEventListener('click', (e) => { // Click on the scrim (outside the card) closes; clicks inside the card // bubble past this because .quickchat-card stops them. if (e.target === overlayEl) hide() }) const card = el('div', { className: 'quickchat-card', onclick: (e) => e.stopPropagation() }) // Brand-marked head: a 16px logo anchors "this is DSH's floating chat" // without shouting. Logo is a plain so it inherits the file:// base // like the rest of the renderer; alt="" because the adjacent title says it. const logo = document.createElement('img') logo.className = 'quickchat-brand-logo' logo.src = '../../assets/logo.png' logo.width = 16 logo.height = 16 logo.alt = '' logo.setAttribute('aria-hidden', 'true') const headBar = el('div', { className: 'quickchat-head' }, [ logo, el('span', { className: 'quickchat-title', text: 'Quick chat' }), el('span', { className: 'quickchat-hint muted', text: 'Enter to send · ESC to close' }), el('button', { className: 'quickchat-close ghost small', 'aria-label': 'Close quick chat', title: 'Close (ESC)', onclick: () => hide(), }, ['×']), ]) recentEl = el('div', { className: 'quickchat-recent', 'aria-label': 'Recent sessions' }) inputEl = el('textarea', { className: 'quickchat-input', rows: '3', placeholder: 'Ask DSH anything…', 'aria-label': 'Quick chat prompt', }) inputEl.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); hide(); return } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void submit() } }) const sendBtn = el('button', { className: 'quickchat-send primary', title: 'Send (Enter)', 'aria-label': 'Send message', onclick: () => { void submit() }, }) // A rounded up-arrow — the reference design uses the same glyph in the floating // composer. Kept as inline SVG so it colors with currentColor. const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'svg') arrow.setAttribute('viewBox', '0 0 20 20') arrow.setAttribute('width', '16') arrow.setAttribute('height', '16') arrow.setAttribute('aria-hidden', 'true') arrow.innerHTML = '' sendBtn.appendChild(arrow) const composer = el('div', { className: 'quickchat-composer' }, [inputEl, sendBtn]) card.appendChild(headBar) card.appendChild(recentEl) card.appendChild(composer) overlayEl.appendChild(card) document.body.appendChild(overlayEl) } function refreshRecent() { if (!recentEl) return recentEl.innerHTML = '' const chat = window.__dshChat if (!chat) return // Prefer enriched entries (carry `hasUserMessage` so pickRecentSessions // can drop empty stubs). Older shells expose only getEntries — that's // fine, entries without the flag pass through unfiltered. let entries = [] if (typeof chat.getEnrichedEntries === 'function') { entries = chat.getEnrichedEntries() || [] } else if (typeof chat.getEntries === 'function') { entries = chat.getEntries() || [] } const rows = pickRecentSessions(entries, 5) if (rows.length === 0) { recentEl.appendChild(el('div', { className: 'quickchat-recent-empty muted', text: 'No recent sessions yet — your first prompt will start one.' })) return } // Reuse the sidebar's canonical formatter so quick-chat rows read the // same as the Recent list: real title, or `未命名 · ` for smoke // fixtures / (shortId) fallbacks. Never `Session ${sid.slice(0,8)}` — // that leaked technical noise + collided on same-prefix ids. const P = window.__dshPanelsC const nowMs = Date.now() recentEl.appendChild(el('div', { className: 'quickchat-recent-head muted', text: 'Recent sessions' })) for (const entry of rows) { const sid = entry.sessionId || entry.id const smart = P && typeof P.smartSessionTitle === 'function' ? P.smartSessionTitle({ ...entry, sessionId: sid }, nowMs) : { text: entry.title || entry.header?.title || 'Untitled', isUntitled: !entry.title } const titleClass = 'quickchat-recent-title' + (smart.isUntitled ? ' quickchat-recent-title-untitled' : '') const dot = entry.running ? el('span', { className: 'quickchat-recent-dot live' }) : null const row = el('button', { className: 'quickchat-recent-row', type: 'button', title: `Open ${smart.text}`, onclick: () => { void jumpToSession(sid) }, }, [ dot, el('span', { className: titleClass, text: smart.text }), el('span', { className: 'quickchat-recent-id muted', text: String(sid).slice(0, 8) }), ]) recentEl.appendChild(row) } } async function jumpToSession(sid) { hide() try { if (window.__dshTabs && typeof window.__dshTabs.switchTo === 'function') { window.__dshTabs.switchTo('chat') } if (window.__dshChat && typeof window.__dshChat.selectSession === 'function') { await window.__dshChat.selectSession(sid) } } catch (err) { console.error('quick-chat jump failed:', err) } } async function submit() { if (!inputEl) return const text = inputEl.value.trim() if (!text) return inputEl.value = '' hide() // Mint a fresh session and route the prompt through it. We deliberately // do NOT reuse the currently-open session in the main chat pane — the reference design's // quick chat is understood as "a new thread"; overloading an active one // would surprise users who wanted a side channel. try { const { id } = await window.dsh.newSession() if (window.__dshTabs && typeof window.__dshTabs.switchTo === 'function') { window.__dshTabs.switchTo('chat') } if (window.__dshChat && typeof window.__dshChat.selectSession === 'function') { await window.__dshChat.selectSession(id) } await window.dsh.sendPrompt(id, text) } catch (err) { console.error('quick-chat submit failed:', err) // C22 (drift cycle 18): non-blocking modal instead of native alert. // Native alert() would freeze the quickchat overlay mid-typing. const notify = window.__dshRenderer && window.__dshRenderer.notifyDialog if (notify) notify(`Send failed: ${err.message}`) else alert(`Send failed: ${err.message}`) } } function show() { ensureDom() if (open) return open = true refreshRecent() overlayEl.hidden = false // Delay focus until after the class flip so the CSS transition can grab // its start state cleanly. requestAnimationFrame(() => { overlayEl.classList.add('quickchat-open') if (inputEl) inputEl.focus() }) } function hide() { if (!open || !overlayEl) return open = false overlayEl.classList.remove('quickchat-open') overlayEl.hidden = true if (inputEl) inputEl.value = '' } function toggle() { open ? hide() : show() } window.__dshQuickChat = { show, hide, toggle, refreshRecent } // The header trigger button lives in the chat pane; it's optional (the // shortcut always works even if the button isn't present). // // Bind path forks on document.readyState so we work whether this script // loaded before or after DOMContentLoaded — the previous version relied // on the DOMContentLoaded event, but this script is at the bottom of the // HTML, so on some fast paths the event has already fired by the time // we register for it and the button silently never wires. Guarded by // readyState so we don't double-bind. function bindHeader() { const btn = document.getElementById('quickchat-open') if (btn && !btn.dataset.qcBound) { btn.dataset.qcBound = '1' btn.addEventListener('click', toggle) } if (window.dsh && typeof window.dsh.onQuickChatToggle === 'function') { window.dsh.onQuickChatToggle(() => toggle()) } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', bindHeader) } else { bindHeader() } })()