From b1f10b1d1b0f94c2abea0bc6a5a2e65434d23d6d Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 20 May 2026 15:27:31 +0200 Subject: [PATCH] render message reply threads in dashboard and per-agent inbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MessageEvent and DashboardEvent Sent/Delivered now carry id and in_reply_to - broker.send() includes last_insert_rowid in the emitted event - recent_all() and recv_batch() include id and in_reply_to from the DB - deliver_reminders_batch() tracks per-row rowids within the transaction - dashboard message flow: reply rows are indented with a border-left and a clickable '↳ reply' tag that scroll-jumps + briefly highlights the parent - per-agent inbox: reply messages get a '↳ reply ·' prefix and indent Closes #26 --- hive-ag3nt/assets/agent.css | 6 ++++ hive-ag3nt/assets/app.js | 5 ++- hive-c0re/assets/app.js | 40 ++++++++++++++++++++-- hive-c0re/assets/dashboard.css | 24 ++++++++++++++ hive-c0re/src/broker.rs | 55 ++++++++++++++++++++++--------- hive-c0re/src/dashboard.rs | 8 +++-- hive-c0re/src/dashboard_events.rs | 8 +++++ hive-c0re/src/main.rs | 8 +++-- 8 files changed, 132 insertions(+), 22 deletions(-) diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css index dc468db..e5c3555 100644 --- a/hive-ag3nt/assets/agent.css +++ b/hive-ag3nt/assets/agent.css @@ -150,6 +150,12 @@ pre.diff { .agent-inbox .inbox-from { color: var(--amber); } .agent-inbox .inbox-sep { color: var(--muted); } .agent-inbox .inbox-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; } +.agent-inbox li.inbox-reply { + padding-left: 1em; + border-left: 2px solid var(--border); + margin-left: 0.4em; +} +.agent-inbox .inbox-reply-tag { color: var(--muted); font-size: 0.85em; } .agent-inbox .answer-form { grid-column: 1 / -1; diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index 8fd8fd3..452c4d0 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -532,7 +532,10 @@ list.innerHTML = ''; const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19); for (const m of rows) { - const li = el('li'); + const li = el('li', m.in_reply_to != null ? { class: 'inbox-reply' } : {}); + if (m.in_reply_to != null) { + li.append(el('span', { class: 'inbox-reply-tag' }, '↳ reply · ')); + } li.append( el('span', { class: 'inbox-ts' }, fmt(m.at)), ' ', el('span', { class: 'inbox-from' }, m.from), ' ', diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 3906e84..98edabf 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -1631,8 +1631,16 @@ if (bannerOffTimer) clearTimeout(bannerOffTimer); bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000); } + // Map of broker row id → rendered row element. Lets reply rows add + // a visual "↳ in reply to" indicator that links back to the parent. + // Bounded by the history window (~200 msgs from /dashboard/history), + // well within normal memory. + const msgRowMap = new Map(); + function renderMsg(ev, api, glyph) { - const row = api.row('msgrow ' + ev.kind, ''); + const isReply = ev.in_reply_to != null; + const cls = 'msgrow ' + ev.kind + (isReply ? ' msg-reply' : ''); + const row = api.row(cls, ''); // Build via DOM so path anchors stay live + escape rules are // automatic (text nodes don't need esc()). const ts = document.createElement('span'); @@ -1648,7 +1656,35 @@ const body = document.createElement('span'); body.className = 'msg-body'; appendLinkified(body, ev.body, ev.file_refs); - row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body); + // Reply thread indicator: a small "↳ reply to " hint that + // shows which message this is responding to. If we have the parent + // in our row map, clicking scrolls it into view. + if (isReply) { + const replyTag = document.createElement('span'); + replyTag.className = 'msg-reply-tag'; + const parentRow = msgRowMap.get(ev.in_reply_to); + if (parentRow) { + const link = document.createElement('a'); + link.href = '#'; + link.textContent = '↳ reply'; + link.title = 'scroll to parent message'; + link.addEventListener('click', (e) => { + e.preventDefault(); + parentRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + parentRow.classList.add('msg-highlight'); + setTimeout(() => parentRow.classList.remove('msg-highlight'), 1500); + }); + replyTag.append(link); + } else { + replyTag.textContent = '↳ reply'; + } + row.prepend(replyTag); + row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body); + } else { + row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body); + } + // Register this row so future replies can reference it. + if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row); } HiveTerminal.create({ logEl: flow, diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 1e690d0..449d550 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -726,6 +726,30 @@ summary:hover { color: var(--purple); } } .live .msgrow.sent .msg-arrow { color: var(--cyan); } .live .msgrow.delivered .msg-arrow { color: var(--green); } +/* Reply-thread rendering: indented border-left + muted reply tag. */ +.live .msgrow.msg-reply { + padding-left: 1.2em; + border-left: 2px solid var(--border); + margin-left: 0.6em; +} +.msg-reply-tag { + color: var(--muted); + font-size: 0.8em; + white-space: nowrap; + order: -1; /* prepend before other flex items */ +} +.msg-reply-tag a { + color: var(--muted); + text-shadow: none; + font-weight: normal; +} +.msg-reply-tag a:hover { color: var(--fg); } +/* Flash highlight when scrolled to from a reply link. */ +@keyframes msg-highlight-fade { + from { background: rgba(203, 166, 247, 0.18); } + to { background: transparent; } +} +.msg-highlight { animation: msg-highlight-fade 1.5s ease-out forwards; } .msg-ts { color: var(--muted); font-size: 0.85em; } .msg-arrow { font-weight: bold; } .msg-from { color: var(--amber); } diff --git a/hive-c0re/src/broker.rs b/hive-c0re/src/broker.rs index d5a8442..34a20ef 100644 --- a/hive-c0re/src/broker.rs +++ b/hive-c0re/src/broker.rs @@ -100,16 +100,22 @@ pub const MAX_REMINDER_ATTEMPTS: u32 = 5; #[serde(rename_all = "snake_case", tag = "kind")] pub enum MessageEvent { Sent { + /// Broker row id — used by the dashboard to track thread parents. + id: i64, from: String, to: String, body: String, at: i64, + in_reply_to: Option, }, Delivered { + /// Broker row id — used by the dashboard to track thread parents. + id: i64, from: String, to: String, body: String, at: i64, + in_reply_to: Option, }, } @@ -167,16 +173,20 @@ impl Broker { pub fn send(&self, message: &Message) -> Result<()> { let conn = self.conn.lock().unwrap(); + let now = now_unix(); conn.execute( "INSERT INTO messages (sender, recipient, body, sent_at, in_reply_to) VALUES (?1, ?2, ?3, ?4, ?5)", - params![message.from, message.to, message.body, now_unix(), message.in_reply_to], + params![message.from, message.to, message.body, now, message.in_reply_to], )?; + let row_id = conn.last_insert_rowid(); drop(conn); let _ = self.events.send(MessageEvent::Sent { + id: row_id, from: message.from.clone(), to: message.to.clone(), body: message.body.clone(), - at: now_unix(), + at: now, + in_reply_to: message.in_reply_to, }); Ok(()) } @@ -220,21 +230,19 @@ impl Broker { let conn = self.conn.lock().unwrap(); let limit_i = i64::try_from(limit.min(i64::MAX as u64)).unwrap_or(i64::MAX); let mut stmt = conn.prepare( - "SELECT sender, recipient, body, sent_at + "SELECT id, sender, recipient, body, sent_at, in_reply_to FROM messages ORDER BY id DESC LIMIT ?1", )?; - // `recent_all` powers dashboard backfill; in_reply_to is not - // carried through MessageEvent::Sent (no field for it) — that - // is fine for now; the dashboard thread-rendering will use - // /api/state InboxRow data which does carry the field. let rows = stmt.query_map(params![limit_i], |row| { Ok(MessageEvent::Sent { - from: row.get(0)?, - to: row.get(1)?, - body: row.get(2)?, - at: row.get(3)?, + id: row.get(0)?, + from: row.get(1)?, + to: row.get(2)?, + body: row.get(3)?, + at: row.get(4)?, + in_reply_to: row.get(5)?, }) })?; rows.collect::>>() @@ -408,10 +416,12 @@ impl Broker { // which surface the harness used. for d in &deliveries { let _ = self.events.send(MessageEvent::Delivered { + id: d.id, from: d.message.from.clone(), to: d.message.to.clone(), body: d.message.body.clone(), at: now, + in_reply_to: d.message.in_reply_to, }); } Ok(deliveries) @@ -749,20 +759,33 @@ impl Broker { } }; let mut results: Vec> = Vec::with_capacity(items.len()); + // Per-item broker row ids — collected inside the transaction so + // we can emit Sent events with the correct id after commit. + let mut msg_ids: Vec = Vec::with_capacity(items.len()); for (id, agent, body) in items { - let r = (|| -> Result<()> { + let r = (|| -> Result { tx.execute( "INSERT INTO messages (sender, recipient, body, sent_at) \ VALUES (?1, ?2, ?3, ?4)", params!["reminder", agent, body, now], )?; + let msg_id = tx.last_insert_rowid(); tx.execute( "UPDATE reminders SET sent_at = ?1 WHERE id = ?2", params![now, id], )?; - Ok(()) + Ok(msg_id) })(); - results.push(r); + match r { + Ok(msg_id) => { + msg_ids.push(msg_id); + results.push(Ok(())); + } + Err(e) => { + msg_ids.push(-1); + results.push(Err(e)); + } + } } if let Err(e) = tx.commit() { let err_str = format!("{e:#}"); @@ -773,13 +796,15 @@ impl Broker { } drop(conn); // Emit per-row Sent events (only for rows that succeeded). - for ((id, agent, body), result) in items.iter().zip(results.iter()) { + for (((id, agent, body), result), msg_id) in items.iter().zip(results.iter()).zip(msg_ids.iter()) { if result.is_ok() { let _ = self.events.send(MessageEvent::Sent { + id: *msg_id, from: "reminder".to_owned(), to: agent.clone(), body: body.clone(), at: now, + in_reply_to: None, }); tracing::debug!(reminder_id = id, %agent, "reminder delivered"); } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 977be08..0f085da 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -696,25 +696,29 @@ async fn dashboard_history(State(state): State) -> Response { let events: Vec = messages .into_iter() .map(|m| match m { - crate::broker::MessageEvent::Sent { from, to, body, at } => { + crate::broker::MessageEvent::Sent { id, from, to, body, at, in_reply_to } => { let file_refs = scan_validated_paths(&body); crate::dashboard_events::DashboardEvent::Sent { seq: 0, + id, from, to, body, at, + in_reply_to, file_refs, } } - crate::broker::MessageEvent::Delivered { from, to, body, at } => { + crate::broker::MessageEvent::Delivered { id, from, to, body, at, in_reply_to } => { let file_refs = scan_validated_paths(&body); crate::dashboard_events::DashboardEvent::Delivered { seq: 0, + id, from, to, body, at, + in_reply_to, file_refs, } } diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 943032f..32bae04 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -40,10 +40,14 @@ pub enum DashboardEvent { /// appear in this list, everything else stays plain text. Sent { seq: u64, + /// Broker row id. Allows the dashboard to track reply threads. + id: i64, from: String, to: String, body: String, at: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + in_reply_to: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] file_refs: Vec, }, @@ -51,10 +55,14 @@ pub enum DashboardEvent { /// `file_refs` is the same shape as `Sent`. Delivered { seq: u64, + /// Broker row id. Allows the dashboard to track reply threads. + id: i64, from: String, to: String, body: String, at: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + in_reply_to: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] file_refs: Vec, }, diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 00ad47e..1206567 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -257,25 +257,29 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc) { tokio::spawn(async move { loop { match rx.recv().await { - Ok(MessageEvent::Sent { from, to, body, at }) => { + Ok(MessageEvent::Sent { id, from, to, body, at, in_reply_to }) => { let file_refs = dashboard::scan_validated_paths(&body); coord.emit_dashboard_event(DashboardEvent::Sent { seq: coord.next_seq(), + id, from, to, body, at, + in_reply_to, file_refs, }); } - Ok(MessageEvent::Delivered { from, to, body, at }) => { + Ok(MessageEvent::Delivered { id, from, to, body, at, in_reply_to }) => { let file_refs = dashboard::scan_validated_paths(&body); coord.emit_dashboard_event(DashboardEvent::Delivered { seq: coord.next_seq(), + id, from, to, body, at, + in_reply_to, file_refs, }); }