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:
parent
d9c7b7fcc1
commit
4c77eefc51
3 changed files with 77 additions and 1 deletions
|
|
@ -163,3 +163,19 @@ body.flow-shell .flow-main-slim {
|
||||||
wants `#inbox-section` in the DOM (legacy contract), but we
|
wants `#inbox-section` in the DOM (legacy contract), but we
|
||||||
surface the messages via the pill/flyout instead. */
|
surface the messages via the pill/flyout instead. */
|
||||||
.flow-inbox-headless { display: none !important; }
|
.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; }
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@
|
||||||
<header class="logs-header">
|
<header class="logs-header">
|
||||||
<a class="logs-back" href="/">← dashboard</a>
|
<a class="logs-back" href="/">← dashboard</a>
|
||||||
<span class="logs-title">FL0W</span>
|
<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>
|
</header>
|
||||||
|
|
||||||
<!-- Operator inbox flyout trigger — count + click → side panel
|
<!-- Operator inbox flyout trigger — count + click → side panel
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,56 @@ import {
|
||||||
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
|
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
|
||||||
if (!s || !Array.isArray(s.containers)) return;
|
if (!s || !Array.isArray(s.containers)) return;
|
||||||
for (const c of s.containers) flowContainers.set(c.name, c);
|
for (const c of s.containers) flowContainers.set(c.name, c);
|
||||||
|
populateAgentFilter();
|
||||||
}).catch(() => { /* graceful: compose just shows `*` and nothing else */ });
|
}).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 ────────────────────────────────
|
// ─── message flow: shared terminal pane ────────────────────────────────
|
||||||
// Scroll, pill, backfill + SSE plumbing live in @hive/shared/terminal.
|
// Scroll, pill, backfill + SSE plumbing live in @hive/shared/terminal.
|
||||||
// What stays here is the broker-message renderer + the page-local
|
// What stays here is the broker-message renderer + the page-local
|
||||||
|
|
@ -168,6 +216,11 @@ import {
|
||||||
} else {
|
} else {
|
||||||
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
|
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.
|
// Register this row so future replies can reference it.
|
||||||
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
|
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
|
||||||
return row;
|
return row;
|
||||||
|
|
@ -244,9 +297,13 @@ import {
|
||||||
container_state_changed: (ev) => {
|
container_state_changed: (ev) => {
|
||||||
if (ev.container && ev.container.name) {
|
if (ev.container && ev.container.name) {
|
||||||
flowContainers.set(ev.container.name, ev.container);
|
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
|
// Drop every other mutation kind silently — without this
|
||||||
// they'd fall through to the terminal module's default
|
// they'd fall through to the terminal module's default
|
||||||
// renderer and clutter the log with JSON dumps. The dashboard
|
// renderer and clutter the log with JSON dumps. The dashboard
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue