feat(flow): filter the timeline by agent

Adds an agent filter to the FL0W header (#1473): a select listing the
live agents narrows the timeline to messages involving the chosen agent
(matched on `from` OR `to`). Each message row now carries
`data-from`/`data-to`; non-matching rows get `.flow-hidden`. New rows
pick up the active filter at render time; changing the filter re-scans
existing rows. The selection persists in localStorage so a reload or
tab-switch keeps the view. The dropdown is populated from the live
container list (and stays current on container add/remove); a saved
selection survives even if that agent isn't currently listed.

Pure frontend; composes with the sent+delivered collapse (the surviving
collapsed row keeps its `data-from`/`data-to`). Closes #1473.
This commit is contained in:
iris 2026-06-06 11:55:28 +02:00
commit 4c77eefc51
3 changed files with 77 additions and 1 deletions

View file

@ -163,3 +163,19 @@ body.flow-shell .flow-main-slim {
wants `#inbox-section` in the DOM (legacy contract), but we
surface the messages via the pill/flyout instead. */
.flow-inbox-headless { display: none !important; }
/* Agent filter (#1473): a select in the FL0W header narrows the
timeline to messages involving one agent. Non-matching rows get
`.flow-hidden`; the select reuses the journal-unit control look. */
.flow-filter {
font-family: inherit;
font-size: 0.85em;
background: var(--bg-elev);
color: var(--amber);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.25em 0.5em;
margin-left: auto;
}
.flow-filter:focus { outline: 1px solid var(--purple); }
.msgrow.flow-hidden { display: none; }

View file

@ -18,6 +18,9 @@
<header class="logs-header">
<a class="logs-back" href="/">← dashboard</a>
<span class="logs-title">FL0W</span>
<select id="flow-agent-filter" class="flow-filter" title="filter timeline by agent" aria-label="filter timeline by agent">
<option value="">all agents</option>
</select>
</header>
<!-- Operator inbox flyout trigger — count + click → side panel

View file

@ -91,8 +91,56 @@ import {
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
if (!s || !Array.isArray(s.containers)) return;
for (const c of s.containers) flowContainers.set(c.name, c);
populateAgentFilter();
}).catch(() => { /* graceful: compose just shows `*` and nothing else */ });
// ─── agent filter (#1473) ───────────────────────────────────────────────
// A select in the FL0W header narrows the timeline to messages involving
// one agent (matched on `from` OR `to`). Each rendered row carries
// `data-from` / `data-to`; non-matching rows get `.flow-hidden`. New rows
// pick up the active filter at render time (see renderMsg); changing the
// filter re-scans existing rows. Selection persists in localStorage so a
// reload / tab-switch keeps the view. Uses `$('msgflow')` for row access
// so it works regardless of the message-flow IIFE's local scope.
let agentFilter = localStorage.getItem('flow-agent-filter') || '';
function rowMatchesFilter(from, to) {
return !agentFilter || from === agentFilter || to === agentFilter;
}
function applyAgentFilter() {
const flow = $('msgflow');
if (!flow) return;
for (const row of flow.children) {
const { from, to } = row.dataset;
if (from === undefined && to === undefined) continue; // non-message row
row.classList.toggle('flow-hidden', !rowMatchesFilter(from, to));
}
}
function populateAgentFilter() {
const sel = $('flow-agent-filter');
if (!sel) return;
const names = [...flowContainers.keys()].sort();
sel.replaceChildren();
sel.append(el('option', { value: '' }, 'all agents'));
for (const n of names) sel.append(el('option', { value: n }, n));
// Preserve a saved selection even if that agent isn't in the live
// container list (yet / anymore) so the filter doesn't silently reset.
if (agentFilter && !names.includes(agentFilter)) {
sel.append(el('option', { value: agentFilter }, agentFilter));
}
sel.value = agentFilter;
}
{
const sel = $('flow-agent-filter');
if (sel) {
sel.addEventListener('change', () => {
agentFilter = sel.value;
if (agentFilter) localStorage.setItem('flow-agent-filter', agentFilter);
else localStorage.removeItem('flow-agent-filter');
applyAgentFilter();
});
}
}
// ─── message flow: shared terminal pane ────────────────────────────────
// Scroll, pill, backfill + SSE plumbing live in @hive/shared/terminal.
// What stays here is the broker-message renderer + the page-local
@ -168,6 +216,11 @@ import {
} else {
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
}
// Tag with the participants so the agent filter (#1473) can match
// on `from`/`to`, and hide the row up-front if a filter is active.
row.dataset.from = ev.from;
row.dataset.to = ev.to;
if (!rowMatchesFilter(ev.from, ev.to)) row.classList.add('flow-hidden');
// Register this row so future replies can reference it.
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
return row;
@ -244,9 +297,13 @@ import {
container_state_changed: (ev) => {
if (ev.container && ev.container.name) {
flowContainers.set(ev.container.name, ev.container);
populateAgentFilter(); // keep the filter dropdown current
}
},
container_removed: (ev) => { flowContainers.delete(ev.name); },
container_removed: (ev) => {
flowContainers.delete(ev.name);
populateAgentFilter();
},
// Drop every other mutation kind silently — without this
// they'd fall through to the terminal module's default
// renderer and clutter the log with JSON dumps. The dashboard