feat(flow): collapse back-to-back sent+delivered into one line

When a message directly wakes its recipient, the broker emits `sent`
then `delivered` for the same row id ~instantly, rendering two
near-identical lines on the FL0W timeline. Track each recent `sent`
row and, when its `delivered` lands within COLLAPSE_SECS (3s), upgrade
that row in place (arrow → green ✓, title "sent + delivered") instead
of adding a second line. A delivery that arrives later (recipient was
busy) still renders as its own row, so genuine delivery latency stays
visible.

`Sent` and `Delivered` share the same broker row id
(dashboard_events.rs), so matching is exact. The recentSent map is
bounded to 256 entries. Reply threading is unaffected (msgRowMap still
resolves the surviving row).

Closes #1472.
This commit is contained in:
iris 2026-06-06 11:16:44 +02:00 committed by mara
commit a2fe3f2e01

View file

@ -170,6 +170,41 @@ import {
}
// Register this row so future replies can reference it.
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
return row;
}
// Sent→Delivered collapse (#1472). When a message directly wakes its
// recipient the broker emits `sent` then `delivered` for the same row
// id back-to-back, rendering two near-identical lines. We track each
// recent `sent` row and, when its `delivered` lands within
// COLLAPSE_SECS, upgrade that row in place (✓) instead of adding a
// second line. A delivery that arrives *later* (recipient was busy)
// stays a separate row so the latency remains visible.
const COLLAPSE_SECS = 3;
const recentSent = new Map(); // broker row id → { row, at }
function rememberSent(ev, row) {
if (ev.id == null || ev.id <= 0) return;
recentSent.set(ev.id, { row, at: ev.at });
// Bound the map — drop the oldest entries once it grows past a
// page of un-collapsed sends (insertion order = oldest first).
while (recentSent.size > 256) {
recentSent.delete(recentSent.keys().next().value);
}
}
// Returns true if the delivered event was folded into its sent row.
function collapseDelivered(ev) {
if (ev.id == null || ev.id <= 0) return false;
const s = recentSent.get(ev.id);
if (!s || ev.at - s.at > COLLAPSE_SECS) return false;
const arrow = s.row.querySelector('.msg-arrow');
if (arrow) arrow.textContent = '✓';
// Re-style the row as delivered (green ✓) — the collapsed line now
// represents the delivered state; it was sent + delivered as one.
s.row.classList.remove('sent');
s.row.classList.add('delivered');
s.row.title = 'sent + delivered';
recentSent.delete(ev.id);
return true;
}
// Anchor the `↓ N new` pill in `.flow-main` rather than the
// default `.terminal-wrap` parent — see docs/web-ui.md::Per-agent
@ -195,8 +230,12 @@ import {
// and won't accidentally share with tabs.js's wider subscribe.
streamFactory: openStream,
renderers: {
sent: (ev, api) => renderMsg(ev, api, '→'),
delivered: (ev, api) => renderMsg(ev, api, '✓'),
sent: (ev, api) => rememberSent(ev, renderMsg(ev, api, '→')),
delivered: (ev, api) => {
// Fold into the matching sent row when it just happened;
// otherwise render the delivery as its own line.
if (!collapseDelivered(ev)) renderMsg(ev, api, '✓');
},
// Maintain the local containers cache from the same stream
// (compose autocomplete reads from `flowContainers`). The
// dashboard's tab renderers aren't on this page, so we don't