diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 58ed989..07e5823 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -744,6 +744,10 @@ const resp = await fetch('/api/state'); if (!resp.ok) throw new Error('http ' + resp.status); const s = await resp.json(); + // Stash the latest snapshot for any sub-widget that wants a + // synchronous read (e.g. the compose autocomplete pulls agent + // names from here instead of refetching on every keystroke). + window.__hyperhive_state = s; const openDetails = snapshotOpenDetails(); renderContainers(s); renderTombstones(s); @@ -824,4 +828,180 @@ }), flow.firstChild); }; })(); + + // ─── compose: @-mention with sticky recipient ─────────────────────────── + (() => { + const input = $('op-compose-input'); + const prompt = $('op-compose-prompt'); + const suggest = $('op-compose-suggest'); + if (!input || !prompt || !suggest) return; + const STORAGE_KEY = 'hyperhive:op-compose:to'; + let stickyTo = localStorage.getItem(STORAGE_KEY) || ''; + let suggestActive = -1; + function renderPrompt() { + prompt.textContent = stickyTo ? `@${stickyTo}>` : '@—>'; + } + function knownAgents() { + const s = window.__hyperhive_state; + if (!s || !Array.isArray(s.containers)) return []; + // The broker uses the literal recipient `manager` for the + // manager's inbox, not the container name `hm1nd`. Swap on + // suggestion so `@manager` Just Works. + return s.containers.map((c) => (c.is_manager ? 'manager' : c.name)); + } + function autosize() { + input.style.height = 'auto'; + input.style.height = `${input.scrollHeight}px`; + } + /// Parse "@name body…" — return {to, body} when the input opens + /// with a known @-mention, otherwise null. + function parseAddressed(raw) { + const m = raw.match(/^@([\w-]+)\s+([\s\S]+)$/); + if (!m) return null; + return { to: m[1], body: m[2] }; + } + function hideSuggest() { + suggest.hidden = true; + suggest.innerHTML = ''; + suggestActive = -1; + } + function renderSuggest(matches) { + suggest.innerHTML = ''; + if (!matches.length) { hideSuggest(); return; } + for (let i = 0; i < matches.length; i += 1) { + const item = document.createElement('div'); + item.className = 'item' + (i === suggestActive ? ' active' : ''); + item.textContent = '@' + matches[i]; + item.addEventListener('mousedown', (e) => { + e.preventDefault(); + applySuggestion(matches[i]); + }); + suggest.append(item); + } + suggest.hidden = false; + } + function applySuggestion(name) { + // Replace the partial @-token at the start with the full name. + const v = input.value; + const m = v.match(/^@(\S*)/); + if (m) { + input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, ''); + } else { + input.value = `@${name} ` + v; + } + hideSuggest(); + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + autosize(); + } + function updateSuggest() { + const v = input.value; + // Only suggest when an @-token sits at the very start of the + // input — switching recipient is always "redirect this whole + // line." Mid-message @-mentions stay literal. + const m = v.match(/^@(\S*)/); + if (!m) { hideSuggest(); return; } + const partial = m[1].toLowerCase(); + const matches = knownAgents().filter((n) => n.toLowerCase().startsWith(partial)); + if (!matches.length) { hideSuggest(); return; } + if (suggestActive < 0 || suggestActive >= matches.length) suggestActive = 0; + renderSuggest(matches); + } + async function submit() { + const raw = input.value.trim(); + if (!raw) return; + let to; + let body; + const addressed = parseAddressed(raw); + if (addressed) { + to = addressed.to; + body = addressed.body.trim(); + } else if (stickyTo) { + to = stickyTo; + body = raw; + } else { + flashError('no recipient — start with @name to address a message'); + return; + } + if (!body) return; + const fd = new FormData(); + fd.append('to', to); + fd.append('body', body); + input.disabled = true; + try { + const resp = await fetch('/op-send', { + method: 'POST', + body: new URLSearchParams(fd), + redirect: 'manual', + }); + const ok = resp.ok || resp.type === 'opaqueredirect' + || (resp.status >= 200 && resp.status < 400); + if (!ok) { + flashError(`send failed: http ${resp.status}`); + return; + } + } catch (err) { + flashError(`send failed: ${err}`); + return; + } finally { + input.disabled = false; + } + stickyTo = to; + localStorage.setItem(STORAGE_KEY, to); + input.value = ''; + autosize(); + renderPrompt(); + input.focus(); + } + function flashError(msg) { + const flow = $('msgflow'); + if (!flow) return; + const row = document.createElement('div'); + row.className = 'msgrow meta'; + row.textContent = msg; + flow.insertBefore(row, flow.firstChild); + } + input.addEventListener('input', () => { autosize(); updateSuggest(); }); + input.addEventListener('keydown', (e) => { + if (!suggest.hidden) { + if (e.key === 'ArrowDown') { + const items = suggest.querySelectorAll('.item'); + suggestActive = (suggestActive + 1) % items.length; + renderSuggest(Array.from(items).map((i) => i.textContent.slice(1))); + e.preventDefault(); + return; + } + if (e.key === 'ArrowUp') { + const items = suggest.querySelectorAll('.item'); + suggestActive = (suggestActive - 1 + items.length) % items.length; + renderSuggest(Array.from(items).map((i) => i.textContent.slice(1))); + e.preventDefault(); + return; + } + if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) { + const active = suggest.querySelector('.item.active'); + if (active) { + applySuggestion(active.textContent.slice(1)); + e.preventDefault(); + return; + } + } + if (e.key === 'Escape') { + hideSuggest(); + e.preventDefault(); + return; + } + } + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + submit(); + } + }); + input.addEventListener('blur', () => { + // Defer so a click on a suggestion item (mousedown) lands first. + setTimeout(hideSuggest, 100); + }); + renderPrompt(); + autosize(); + })(); })(); diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index b487d50..6658891 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -475,6 +475,63 @@ summary:hover { color: var(--purple); } .msg-sep { color: var(--muted); } .msg-to { color: var(--pink); } .msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; } +.op-compose { + position: relative; + display: flex; + align-items: flex-start; + gap: 0.6em; + margin-top: 0.4em; + padding: 0.55em 0.8em; + background: rgba(24, 24, 37, 0.85); + border: 1px solid var(--border); + border-top: none; +} +.op-compose-prompt { + color: var(--purple); + text-shadow: 0 0 4px currentColor; + font-weight: bold; + white-space: nowrap; + user-select: none; + padding-top: 0.15em; +} +.op-compose-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--fg); + font: inherit; + font-size: 0.85em; + line-height: 1.5; + resize: none; + overflow: hidden; + min-height: 1.5em; + caret-color: var(--purple); +} +.op-compose-input::placeholder { color: var(--muted); } +.op-compose-suggest { + position: absolute; + bottom: 100%; + left: 0.8em; + margin-bottom: 0.2em; + background: rgba(24, 24, 37, 0.95); + border: 1px solid var(--border); + font-size: 0.85em; + min-width: 12em; + max-height: 12em; + overflow-y: auto; + z-index: 10; +} +.op-compose-suggest .item { + padding: 0.2em 0.8em; + cursor: pointer; + color: var(--fg); +} +.op-compose-suggest .item.active, +.op-compose-suggest .item:hover { + background: rgba(203, 166, 247, 0.18); + color: var(--purple); +} footer { margin-top: 4em; text-align: center; diff --git a/hive-c0re/assets/index.html b/hive-c0re/assets/index.html index 4abf1dd..df29468 100644 --- a/hive-c0re/assets/index.html +++ b/hive-c0re/assets/index.html @@ -49,8 +49,15 @@