From c2569cad765a3f1b9d066185ffa6763c98088803 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 5 Jun 2026 08:25:05 +1000 Subject: [PATCH] feat(chat): add Send button to agent composers (mobile fix) Soft keyboards have no reliable Enter-to-send, so chat was unsendable on mobile browsers. Add an optional themed Send button wired through wireAgentChat (Enter-to-send kept for desktop), applied to the Companion rail, Yerin, and Little Blue composers. Blackflame-styled, flex-row layout. Co-Authored-By: Claude Opus 4.8 --- public/components/agent_chat.js | 16 +++++++++++++--- public/components/rightrail.js | 5 +++-- public/style.css | 11 +++++++++-- public/views/little_blue.js | 5 +++-- public/views/sentinel.js | 5 +++-- 5 files changed, 31 insertions(+), 11 deletions(-) diff --git a/public/components/agent_chat.js b/public/components/agent_chat.js index 4b4fe67..ec35d1b 100644 --- a/public/components/agent_chat.js +++ b/public/components/agent_chat.js @@ -38,9 +38,10 @@ function draftCardEl(d, onResolve) { * @param {boolean} [o.showDrafts] render propose_change draft cards (Dross only) * @param {object} [o.toolLabels] tool-name → display string * @param {(text:string)=>object} [o.turnBody] POST body builder - * @returns {{ load: () => Promise }} + * @param {HTMLElement} [o.sendBtnEl] optional Send button (needed on touch/mobile where there's no Enter key) + * @returns {{ load: () => Promise, send: () => Promise }} */ -export function wireAgentChat({ logEl, inputEl, historyUrl, turnUrl, agentName, showDrafts = false, toolLabels = {}, turnBody = (text) => ({ text }) }) { +export function wireAgentChat({ logEl, inputEl, historyUrl, turnUrl, agentName, showDrafts = false, toolLabels = {}, turnBody = (text) => ({ text }), sendBtnEl = null }) { async function resolveDraft(id, status, cardNode) { try { await api.post(`/api/pending-changes/${id}/${status === 'approved' ? 'approve' : 'reject'}`); @@ -93,10 +94,19 @@ export function wireAgentChat({ logEl, inputEl, historyUrl, turnUrl, agentName, } } + // Desktop: Enter sends (Shift+Enter = newline). Mobile soft keyboards have no + // reliable Enter-to-send, so callers also pass a tappable Send button. if (inputEl._sendHandler) inputEl.removeEventListener('keydown', inputEl._sendHandler); const handler = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }; inputEl._sendHandler = handler; inputEl.addEventListener('keydown', handler); - return { load }; + if (sendBtnEl) { + if (sendBtnEl._sendHandler) sendBtnEl.removeEventListener('click', sendBtnEl._sendHandler); + const click = () => { send(); inputEl.focus(); }; + sendBtnEl._sendHandler = click; + sendBtnEl.addEventListener('click', click); + } + + return { load, send }; } diff --git a/public/components/rightrail.js b/public/components/rightrail.js index 03d5669..ff964f9 100644 --- a/public/components/rightrail.js +++ b/public/components/rightrail.js @@ -22,12 +22,13 @@ export async function renderRightrail(root) { const log = el('div', { class: 'rail-log' }); const input = el('textarea', { class: 'rail-input', rows: 1, placeholder: 'Ask the companion…' }); + const sendBtn = el('button', { class: 'rail-send', type: 'button', title: 'Send', 'aria-label': 'Send' }, '➤'); const header = el('div', { class: 'rail-hd' }, el('span', { class: 'who' }, '◆ Companion'), el('span', { class: 'chev', onclick: toggle, title: 'Collapse' }, '⟩')); mount(root, el('div', { class: 'rail-toggle', onclick: toggle, title: 'Companion' }, 'CRADLE'), - el('div', { class: 'rail-chat' }, header, log, el('div', { class: 'rail-inputwrap' }, input))); + el('div', { class: 'rail-chat' }, header, log, el('div', { class: 'rail-inputwrap' }, input, sendBtn))); // (Re)wire the chat whenever the active Space changes. async function initChat(spaceId) { @@ -36,7 +37,7 @@ export async function renderRightrail(root) { return; } const chat = wireAgentChat({ - logEl: log, inputEl: input, + logEl: log, inputEl: input, sendBtnEl: sendBtn, historyUrl: `/api/spaces/${spaceId}/companion`, turnUrl: `/api/spaces/${spaceId}/companion/turn`, agentName: 'Companion', showDrafts: true, toolLabels: COMPANION_LABELS, diff --git a/public/style.css b/public/style.css index 467d7be..f1b6bd7 100644 --- a/public/style.css +++ b/public/style.css @@ -364,8 +364,15 @@ ul.plain li:last-child { border-bottom: none; } .draftx .ok { background:#2a6f4f; color:#d9ffe9; border:none; border-radius:6px; padding:4px 12px; } .draftx .no { background:#2a2f3d; color:#aeb6c7; border:none; border-radius:6px; padding:4px 12px; } .draftx.resolved { opacity:.55; } .resolved-tag { font-size: 11px; text-transform:uppercase; color:#7d869b; margin-top:6px; } -.rail-inputwrap { border-top:1px solid var(--border,#262b38); padding:9px 12px; } -.rail-input { width:100%; resize:none; background:#0c0e14; color:#c9d1e0; border:1px solid #262b38; border-radius:8px; padding:7px 9px; } +.rail-inputwrap { border-top:1px solid var(--border,#262b38); padding:9px 12px; display:flex; align-items:flex-end; gap:7px; } +.rail-input { flex:1 1 auto; min-width:0; width:100%; resize:none; background:#0c0e14; color:#c9d1e0; border:1px solid #262b38; border-radius:8px; padding:7px 9px; } +.rail-send { flex:0 0 auto; width:38px; height:36px; display:inline-flex; align-items:center; justify-content:center; + background:var(--accent,#ff4f2e); color:#fff; border:none; border-radius:8px; cursor:pointer; + font-size:15px; line-height:1; box-shadow:0 0 0 1px rgba(255,79,46,.25), 0 2px 8px rgba(255,79,46,.18); + transition:filter .12s ease, transform .06s ease; } +.rail-send:hover { filter:brightness(1.12); } +.rail-send:active { transform:translateY(1px); } +.rail-send:disabled { opacity:.45; cursor:default; box-shadow:none; } .err { color:#e08a8a; font-size: 13px; } #shell.rail-collapsed .rail-chat { display:none; } diff --git a/public/views/little_blue.js b/public/views/little_blue.js index 697c5c2..0b8f731 100644 --- a/public/views/little_blue.js +++ b/public/views/little_blue.js @@ -82,6 +82,7 @@ async function renderActions(panel, toastHost) { export async function render(main) { const log = el('div', { class: 'rail-log lb-log' }); const input = el('textarea', { class: 'rail-input', rows: 1, placeholder: 'Tell Little Blue what’s wrong…' }); + const sendBtn = el('button', { class: 'rail-send', type: 'button', title: 'Send', 'aria-label': 'Send' }, '➤'); const actionsPanel = el('div', { class: 'lb-actions' }); const toastHost = el('div', { class: 'lb-toasts' }); mount(main, @@ -89,10 +90,10 @@ export async function render(main) { el('p', { class: 'view-sub' }, 'She keeps the lab alive. Safe fixes run on her word; risky ones wait for yours.'), toastHost, el('div', { class: 'lb-grid' }, - el('div', { class: 'lb-chat' }, log, el('div', { class: 'rail-inputwrap' }, input)), + el('div', { class: 'lb-chat' }, log, el('div', { class: 'rail-inputwrap' }, input, sendBtn)), actionsPanel)); const chat = wireAgentChat({ - logEl: log, inputEl: input, + logEl: log, inputEl: input, sendBtnEl: sendBtn, historyUrl: '/api/little-blue', turnUrl: '/api/little-blue/turn', agentName: 'Little Blue', showDrafts: false, toolLabels: BLUE_LABELS }); diff --git a/public/views/sentinel.js b/public/views/sentinel.js index af039a7..35d7bfc 100644 --- a/public/views/sentinel.js +++ b/public/views/sentinel.js @@ -12,12 +12,13 @@ const YERIN_LABELS = { export async function render(main) { const log = el('div', { class: 'rail-log sentinel-log' }); const input = el('textarea', { class: 'rail-input', rows: 1, placeholder: 'Ask Yerin about the Void’s security…' }); + const sendBtn = el('button', { class: 'rail-send', type: 'button', title: 'Send', 'aria-label': 'Send' }, '➤'); mount(main, el('div', { class: 'yerin-view' }, el('h1', { class: 'view-h1' }, '◆ Yerin'), el('p', { class: 'view-sub' }, 'Sage of the Endless Sword — read-only security & observability. She watches, reports, and warns; she never acts.'), - el('div', { class: 'sentinel-chat' }, log, el('div', { class: 'rail-inputwrap' }, input)))); + el('div', { class: 'sentinel-chat' }, log, el('div', { class: 'rail-inputwrap' }, input, sendBtn)))); const chat = wireAgentChat({ - logEl: log, inputEl: input, + logEl: log, inputEl: input, sendBtnEl: sendBtn, historyUrl: '/api/security/yerin', turnUrl: '/api/security/yerin/turn', agentName: 'Yerin', showDrafts: false, toolLabels: YERIN_LABELS });