render message reply threads in dashboard and per-agent inbox
- 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
This commit is contained in:
parent
804875d670
commit
b1f10b1d1b
8 changed files with 132 additions and 22 deletions
|
|
@ -150,6 +150,12 @@ pre.diff {
|
||||||
.agent-inbox .inbox-from { color: var(--amber); }
|
.agent-inbox .inbox-from { color: var(--amber); }
|
||||||
.agent-inbox .inbox-sep { color: var(--muted); }
|
.agent-inbox .inbox-sep { color: var(--muted); }
|
||||||
.agent-inbox .inbox-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
|
.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 {
|
.agent-inbox .answer-form {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
|
|
|
||||||
|
|
@ -532,7 +532,10 @@
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19);
|
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19);
|
||||||
for (const m of rows) {
|
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(
|
li.append(
|
||||||
el('span', { class: 'inbox-ts' }, fmt(m.at)), ' ',
|
el('span', { class: 'inbox-ts' }, fmt(m.at)), ' ',
|
||||||
el('span', { class: 'inbox-from' }, m.from), ' ',
|
el('span', { class: 'inbox-from' }, m.from), ' ',
|
||||||
|
|
|
||||||
|
|
@ -1631,8 +1631,16 @@
|
||||||
if (bannerOffTimer) clearTimeout(bannerOffTimer);
|
if (bannerOffTimer) clearTimeout(bannerOffTimer);
|
||||||
bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000);
|
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) {
|
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
|
// Build via DOM so path anchors stay live + escape rules are
|
||||||
// automatic (text nodes don't need esc()).
|
// automatic (text nodes don't need esc()).
|
||||||
const ts = document.createElement('span');
|
const ts = document.createElement('span');
|
||||||
|
|
@ -1648,7 +1656,35 @@
|
||||||
const body = document.createElement('span');
|
const body = document.createElement('span');
|
||||||
body.className = 'msg-body';
|
body.className = 'msg-body';
|
||||||
appendLinkified(body, ev.body, ev.file_refs);
|
appendLinkified(body, ev.body, ev.file_refs);
|
||||||
|
// Reply thread indicator: a small "↳ reply to <from>" 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);
|
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({
|
HiveTerminal.create({
|
||||||
logEl: flow,
|
logEl: flow,
|
||||||
|
|
|
||||||
|
|
@ -726,6 +726,30 @@ summary:hover { color: var(--purple); }
|
||||||
}
|
}
|
||||||
.live .msgrow.sent .msg-arrow { color: var(--cyan); }
|
.live .msgrow.sent .msg-arrow { color: var(--cyan); }
|
||||||
.live .msgrow.delivered .msg-arrow { color: var(--green); }
|
.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-ts { color: var(--muted); font-size: 0.85em; }
|
||||||
.msg-arrow { font-weight: bold; }
|
.msg-arrow { font-weight: bold; }
|
||||||
.msg-from { color: var(--amber); }
|
.msg-from { color: var(--amber); }
|
||||||
|
|
|
||||||
|
|
@ -100,16 +100,22 @@ pub const MAX_REMINDER_ATTEMPTS: u32 = 5;
|
||||||
#[serde(rename_all = "snake_case", tag = "kind")]
|
#[serde(rename_all = "snake_case", tag = "kind")]
|
||||||
pub enum MessageEvent {
|
pub enum MessageEvent {
|
||||||
Sent {
|
Sent {
|
||||||
|
/// Broker row id — used by the dashboard to track thread parents.
|
||||||
|
id: i64,
|
||||||
from: String,
|
from: String,
|
||||||
to: String,
|
to: String,
|
||||||
body: String,
|
body: String,
|
||||||
at: i64,
|
at: i64,
|
||||||
|
in_reply_to: Option<i64>,
|
||||||
},
|
},
|
||||||
Delivered {
|
Delivered {
|
||||||
|
/// Broker row id — used by the dashboard to track thread parents.
|
||||||
|
id: i64,
|
||||||
from: String,
|
from: String,
|
||||||
to: String,
|
to: String,
|
||||||
body: String,
|
body: String,
|
||||||
at: i64,
|
at: i64,
|
||||||
|
in_reply_to: Option<i64>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,16 +173,20 @@ impl Broker {
|
||||||
|
|
||||||
pub fn send(&self, message: &Message) -> Result<()> {
|
pub fn send(&self, message: &Message) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let now = now_unix();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO messages (sender, recipient, body, sent_at, in_reply_to) VALUES (?1, ?2, ?3, ?4, ?5)",
|
"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);
|
drop(conn);
|
||||||
let _ = self.events.send(MessageEvent::Sent {
|
let _ = self.events.send(MessageEvent::Sent {
|
||||||
|
id: row_id,
|
||||||
from: message.from.clone(),
|
from: message.from.clone(),
|
||||||
to: message.to.clone(),
|
to: message.to.clone(),
|
||||||
body: message.body.clone(),
|
body: message.body.clone(),
|
||||||
at: now_unix(),
|
at: now,
|
||||||
|
in_reply_to: message.in_reply_to,
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -220,21 +230,19 @@ impl Broker {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let limit_i = i64::try_from(limit.min(i64::MAX as u64)).unwrap_or(i64::MAX);
|
let limit_i = i64::try_from(limit.min(i64::MAX as u64)).unwrap_or(i64::MAX);
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT sender, recipient, body, sent_at
|
"SELECT id, sender, recipient, body, sent_at, in_reply_to
|
||||||
FROM messages
|
FROM messages
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT ?1",
|
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| {
|
let rows = stmt.query_map(params![limit_i], |row| {
|
||||||
Ok(MessageEvent::Sent {
|
Ok(MessageEvent::Sent {
|
||||||
from: row.get(0)?,
|
id: row.get(0)?,
|
||||||
to: row.get(1)?,
|
from: row.get(1)?,
|
||||||
body: row.get(2)?,
|
to: row.get(2)?,
|
||||||
at: row.get(3)?,
|
body: row.get(3)?,
|
||||||
|
at: row.get(4)?,
|
||||||
|
in_reply_to: row.get(5)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
|
@ -408,10 +416,12 @@ impl Broker {
|
||||||
// which surface the harness used.
|
// which surface the harness used.
|
||||||
for d in &deliveries {
|
for d in &deliveries {
|
||||||
let _ = self.events.send(MessageEvent::Delivered {
|
let _ = self.events.send(MessageEvent::Delivered {
|
||||||
|
id: d.id,
|
||||||
from: d.message.from.clone(),
|
from: d.message.from.clone(),
|
||||||
to: d.message.to.clone(),
|
to: d.message.to.clone(),
|
||||||
body: d.message.body.clone(),
|
body: d.message.body.clone(),
|
||||||
at: now,
|
at: now,
|
||||||
|
in_reply_to: d.message.in_reply_to,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(deliveries)
|
Ok(deliveries)
|
||||||
|
|
@ -749,20 +759,33 @@ impl Broker {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut results: Vec<Result<()>> = Vec::with_capacity(items.len());
|
let mut results: Vec<Result<()>> = 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<i64> = Vec::with_capacity(items.len());
|
||||||
for (id, agent, body) in items {
|
for (id, agent, body) in items {
|
||||||
let r = (|| -> Result<()> {
|
let r = (|| -> Result<i64> {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO messages (sender, recipient, body, sent_at) \
|
"INSERT INTO messages (sender, recipient, body, sent_at) \
|
||||||
VALUES (?1, ?2, ?3, ?4)",
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
params!["reminder", agent, body, now],
|
params!["reminder", agent, body, now],
|
||||||
)?;
|
)?;
|
||||||
|
let msg_id = tx.last_insert_rowid();
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE reminders SET sent_at = ?1 WHERE id = ?2",
|
"UPDATE reminders SET sent_at = ?1 WHERE id = ?2",
|
||||||
params![now, id],
|
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() {
|
if let Err(e) = tx.commit() {
|
||||||
let err_str = format!("{e:#}");
|
let err_str = format!("{e:#}");
|
||||||
|
|
@ -773,13 +796,15 @@ impl Broker {
|
||||||
}
|
}
|
||||||
drop(conn);
|
drop(conn);
|
||||||
// Emit per-row Sent events (only for rows that succeeded).
|
// 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() {
|
if result.is_ok() {
|
||||||
let _ = self.events.send(MessageEvent::Sent {
|
let _ = self.events.send(MessageEvent::Sent {
|
||||||
|
id: *msg_id,
|
||||||
from: "reminder".to_owned(),
|
from: "reminder".to_owned(),
|
||||||
to: agent.clone(),
|
to: agent.clone(),
|
||||||
body: body.clone(),
|
body: body.clone(),
|
||||||
at: now,
|
at: now,
|
||||||
|
in_reply_to: None,
|
||||||
});
|
});
|
||||||
tracing::debug!(reminder_id = id, %agent, "reminder delivered");
|
tracing::debug!(reminder_id = id, %agent, "reminder delivered");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -696,25 +696,29 @@ async fn dashboard_history(State(state): State<AppState>) -> Response {
|
||||||
let events: Vec<crate::dashboard_events::DashboardEvent> = messages
|
let events: Vec<crate::dashboard_events::DashboardEvent> = messages
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|m| match m {
|
.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);
|
let file_refs = scan_validated_paths(&body);
|
||||||
crate::dashboard_events::DashboardEvent::Sent {
|
crate::dashboard_events::DashboardEvent::Sent {
|
||||||
seq: 0,
|
seq: 0,
|
||||||
|
id,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
body,
|
body,
|
||||||
at,
|
at,
|
||||||
|
in_reply_to,
|
||||||
file_refs,
|
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);
|
let file_refs = scan_validated_paths(&body);
|
||||||
crate::dashboard_events::DashboardEvent::Delivered {
|
crate::dashboard_events::DashboardEvent::Delivered {
|
||||||
seq: 0,
|
seq: 0,
|
||||||
|
id,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
body,
|
body,
|
||||||
at,
|
at,
|
||||||
|
in_reply_to,
|
||||||
file_refs,
|
file_refs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,14 @@ pub enum DashboardEvent {
|
||||||
/// appear in this list, everything else stays plain text.
|
/// appear in this list, everything else stays plain text.
|
||||||
Sent {
|
Sent {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
|
/// Broker row id. Allows the dashboard to track reply threads.
|
||||||
|
id: i64,
|
||||||
from: String,
|
from: String,
|
||||||
to: String,
|
to: String,
|
||||||
body: String,
|
body: String,
|
||||||
at: i64,
|
at: i64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
in_reply_to: Option<i64>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
file_refs: Vec<String>,
|
file_refs: Vec<String>,
|
||||||
},
|
},
|
||||||
|
|
@ -51,10 +55,14 @@ pub enum DashboardEvent {
|
||||||
/// `file_refs` is the same shape as `Sent`.
|
/// `file_refs` is the same shape as `Sent`.
|
||||||
Delivered {
|
Delivered {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
|
/// Broker row id. Allows the dashboard to track reply threads.
|
||||||
|
id: i64,
|
||||||
from: String,
|
from: String,
|
||||||
to: String,
|
to: String,
|
||||||
body: String,
|
body: String,
|
||||||
at: i64,
|
at: i64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
in_reply_to: Option<i64>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
file_refs: Vec<String>,
|
file_refs: Vec<String>,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -257,25 +257,29 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match rx.recv().await {
|
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);
|
let file_refs = dashboard::scan_validated_paths(&body);
|
||||||
coord.emit_dashboard_event(DashboardEvent::Sent {
|
coord.emit_dashboard_event(DashboardEvent::Sent {
|
||||||
seq: coord.next_seq(),
|
seq: coord.next_seq(),
|
||||||
|
id,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
body,
|
body,
|
||||||
at,
|
at,
|
||||||
|
in_reply_to,
|
||||||
file_refs,
|
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);
|
let file_refs = dashboard::scan_validated_paths(&body);
|
||||||
coord.emit_dashboard_event(DashboardEvent::Delivered {
|
coord.emit_dashboard_event(DashboardEvent::Delivered {
|
||||||
seq: coord.next_seq(),
|
seq: coord.next_seq(),
|
||||||
|
id,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
body,
|
body,
|
||||||
at,
|
at,
|
||||||
|
in_reply_to,
|
||||||
file_refs,
|
file_refs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue