Skip to content

Bug: Overview approval polling stacks duplicate click handlers on agent rows and nav cards #207

Description

@Dhivya-Bharathy

Repository: MervinPraison/PraisonAIUI
Type: Follow-up issue — post-merge gap from closed parent (do not reopen #195)
Labels: bug, ui
Priority: P0
Parent (closed): #195 Agent Command Center
Merged in: PR #201
Verified: origin/main @ commit 6d194fc (2026-07-14)
Primary file: src/praisonaiui/templates/frontend/plugins/views/overview.js


Executive summary

The Agent Command Center on /overview polls pending approvals every 10 seconds and partially re-renders only #ov-metrics and #ov-attention. After each poll it calls bindEvents(_container, data) on the entire overview root. Interactive elements that were not re-rendered — especially agent table rows (.db-agent-row) and the omnibar — accumulate stacked click / keydown listeners. A user who stays on Overview for one minute can have 7 handlers on the same agent row; one click may open the agent drawer multiple times or navigate twice.

This is a follow-up issue. Parent #195 is closed and the Command Center feature shipped in PR #201. This tracks a runtime bug found during post-merge verification on main — not a missing v1 feature.


Environment verified

Item Value
Git ref 6d194fc — Merge PR #205 (latest main at audit time)
File SHA scope overview.js on origin/main
Poll constants APPROVALS_POLL_MS = 10000, METRICS_POLL_MS = 30000
Issue #195 state CLOSED 2026-07-14
CI on merge Python 3.10/3.11/3.12 pass

What already works (do not regress)

Capability Implementation Status
Parallel 9-endpoint fetch loadAll() + Promise.allSettled Working
Metric cards + sparklines renderMetrics() Working
Attention feed + inline approve/deny renderAttention() Working
Agent table + side drawer .db-agent-row, openDrawer() Working
Omnibar → Ctrl+K palette [data-omnibar]openPalette() Working
Gateway auth_required card gateway.auth_required in metrics Working
Full page refresh paint() replaces all HTML then binds once Working
Teardown stopPolling() on view destroy Working

Root cause analysis

Two refresh paths behave differently

flowchart TB
    subgraph paint_every_30s["paint() every 30s"]
        P1[loadAll API] --> P2["_container.innerHTML = shell(data)"]
        P2 --> P3["bindEvents(_container)"]
        P3 --> P4["All DOM is new → one listener per element ✅"]
    end

    subgraph refresh_every_10s["refreshApprovals() every 10s"]
        R1[GET /api/approvals/pending] --> R2["#ov-metrics innerHTML only"]
        R2 --> R3["#ov-attention innerHTML only"]
        R3 --> R4["bindEvents(_container)"]
        R4 --> R5["Agent rows unchanged → listeners stack ❌"]
    end
Loading

bindEvents(container, data) attaches listeners to

Selector DOM location Re-rendered by refreshApprovals? Listener stacks?
[data-nav] Metric cards inside #ov-metrics Yes (metrics subtree) Yes — if bound on _container
[data-omnibar] Page shell header No Yes
.db-agent-row Agent table in shell No Yes
.db-agent-chat Agent table buttons No Yes
.atn-approve / .atn-deny Inside #ov-attention Yes (attention subtree) Old nodes removed; new nodes get listeners via full-container bind — OK for buttons only if bind scoped to #ov-attention

Exact code on main (overview.js)

refreshApprovals() — problematic tail:

async function refreshApprovals() {
  if (!_container || document.hidden) return;
  let pending = [];
  try { pending = toArray(await fetchJson('/api/approvals/pending'), 'approvals', 'pending'); } catch (e) { return; }
  const data = _container.__ovData;
  if (!data) return;
  data.pending = pending;
  const metricsEl = _container.querySelector('#ov-metrics');
  const attnEl = _container.querySelector('#ov-attention');
  if (metricsEl) metricsEl.innerHTML = renderMetrics(data);
  if (attnEl) attnEl.innerHTML = renderAttention(data);
  bindEvents(_container, data);  // ← binds entire tree including stale agent rows
}

bindEvents() — agent row binding (unchanged DOM between polls):

container.querySelectorAll('.db-agent-row').forEach((row) => {
  row.addEventListener('click', (e) => {
    if (e.target.closest('.db-agent-chat')) return;
    const a = agentById(row.dataset.agentId);
    if (a) openDrawer(a);
  });
});

Polling setup:

function startPolling() {
  stopPolling();
  _metricsTimer = setInterval(() => { if (!document.hidden) paint(); }, METRICS_POLL_MS);
  _approvalsTimer = setInterval(refreshApprovals, APPROVALS_POLL_MS);
  _visibilityHandler = () => { if (!document.hidden) refreshApprovals(); };
  document.addEventListener('visibilitychange', _visibilityHandler);
}

Additional trigger: each approve/deny click calls refreshApprovals() immediately after POST.


Listener growth model

Time on Overview refreshApprovals runs Approx. listeners per .db-agent-row
0 s (initial paint) 0 1
10 s 1 2
30 s 3 4
60 s 6 7
5 min 30 31
Tab refocus (visibility) +1 per refocus +1 per event
Trigger source Interval / event
setInterval Every 10 s
Approve button click Immediate after POST
Deny button click Immediate after POST
visibilitychange → visible Each tab return

User impact

Persona Scenario Symptom
Ops lead Leaves Overview open while monitoring Drawer opens 2–3×; feels “sticky” or buggy
Approver Clicks Approve rapidly Each approve triggers refreshApprovals → faster listener stacking on agent rows
Agent builder Clicks agent row after 2 min Multiple drawer animations / duplicate focus trap
Mobile Same polling when tab visible Same duplication
sequenceDiagram
    participant User
    participant Timer as 10s interval
    participant RA as refreshApprovals
    participant Row as .db-agent-row (same DOM node)
    participant Drawer

    Timer->>RA: tick
    RA->>Row: addEventListener #2
    Timer->>RA: tick
    RA->>Row: addEventListener #3
    User->>Row: single click
    Row->>Drawer: openDrawer × 3
Loading

Proposed fix

Option A — Scoped bindEvents (recommended, ~6 line change)

async function refreshApprovals() {
  // ... fetch pending, update data.pending ...
  const metricsEl = _container.querySelector('#ov-metrics');
  const attnEl = _container.querySelector('#ov-attention');
  if (metricsEl) {
    metricsEl.innerHTML = renderMetrics(data);
    bindEvents(metricsEl, data);
  }
  if (attnEl) {
    attnEl.innerHTML = renderAttention(data);
    bindEvents(attnEl, data);
  }
  // REMOVE: bindEvents(_container, data);
}
Property Assessment
Risk Low
Lines changed ~6
Matches partial DOM update Yes
paint() unchanged Yes — still full replace + full bind

Option B — Event delegation (future refactor)

Single listener on _container using event.target.closest('.db-agent-row'). Polls never add per-element listeners. Larger refactor (~40 lines); not required for hotfix.


Acceptance criteria

ID Given When Then
AC-1 Overview loaded with agents Wait 50 s (5 approval polls) One click on agent row opens drawer once
AC-2 Pending approvals visible Wait 50 s One click on metric [data-nav] navigates once
AC-3 Attention feed shows pending item Click Approve Item clears; no duplicate POST in network tab
AC-4 User on Overview paint() runs at 30 s Full page still works; no regression
AC-5 View destroyed Navigate away stopPolling() clears timers (existing behavior)
AC-6 CI PR merged pytest unit + integration green

Test plan

Manual

Step Action Expected
1 Open /overview with ≥1 agent and ≥1 pending approval Page loads
2 Wait 35 s without clicking Metrics/attention update
3 Click one agent row once Single drawer open
4 Close drawer; wait another 35 s
5 Click same row again Still single drawer open
6 Approve one pending item Count decrements; row click still single

Automated (recommended)

// Pseudocode: mount overview, mock refreshApprovals 5×, spy openDrawer
for (let i = 0; i < 5; i++) await refreshApprovals();
row.click();
expect(openDrawer).toHaveBeenCalledTimes(1);

Related items (separate work)

Item Severity Notes
Approval POST without shared auth headers P3 Only if gateway enforces CSRF on POST
aria-live on pending approval count P3 Screen reader polish
WebSocket live approvals Out of scope STITCH-001 v1 uses polling

Terminal evidence (2026-07-14)

Commands run from c:\Users\DELL\Downloads\praisonui against origin/main.

Git HEAD

git fetch origin main
git log origin/main -1 --format="%H %ci %s"
6d194fce1be82c621aea24dfc48a3f34f73789bf 2026-07-14 09:32:47 +0000 Merge pull request #205 from MervinPraison/claude/issue-200-20260714-code-studio

Parent issue + PR closure

gh issue view 195 --repo MervinPraison/PraisonAIUI --json number,state,closedAt,title
gh pr view 201 --repo MervinPraison/PraisonAIUI --json number,state,mergedAt,title
{"closedAt":"2026-07-14T08:42:59Z","number":195,"state":"CLOSED","title":"Enhancement: Agent Command Center — mission-control Overview with live agents, approvals, and usage (STITCH-001)"}
{"mergedAt":"2026-07-14T08:42:58Z","number":201,"state":"MERGED","title":"feat: Agent Command Center overview (STITCH-001)"}

Line-number grep — polling + bindEvents

git show origin/main:src/praisonaiui/templates/frontend/plugins/views/overview.js |
  Select-String -Pattern "APPROVALS_POLL|METRICS_POLL|bindEvents\(|addEventListener"
16: const METRICS_POLL_MS = 30000;
17: const APPROVALS_POLL_MS = 10000;
343: function bindEvents(container, data) {
348:     el.addEventListener('click', go);
355:     row.addEventListener('click', (e) => { ... openDrawer(a); });
388:   bindEvents(_container, data);     # paint() — full shell replace, OK
402:   bindEvents(_container, data);     # refreshApprovals() — BUG
407:   _metricsTimer = setInterval(() => { if (!document.hidden) paint(); }, METRICS_POLL_MS);
408:   _approvalsTimer = setInterval(refreshApprovals, APPROVALS_POLL_MS);
410:   document.addEventListener('visibilitychange', _visibilityHandler);

Full function extract from main (lines 343–403)

function bindEvents(container, data) {
  const agentById = (id) => data.agents.find((a) => (a.id || a.name) === id);
  container.querySelectorAll('[data-nav]').forEach((el) => {
    const go = () => navigate(el.getAttribute('data-nav'));
    el.addEventListener('click', go);
    el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
  });
  container.querySelector('[data-omnibar]')?.addEventListener('click', openPalette);
  container.querySelectorAll('.db-agent-row').forEach((row) => {
    row.addEventListener('click', (e) => {
      if (e.target.closest('.db-agent-chat')) return;
      const a = agentById(row.dataset.agentId);
      if (a) openDrawer(a);
    });
  });
  // ... agent chat, approve, deny buttons ...
}

async function refreshApprovals() {
  if (!_container || document.hidden) return;
  let pending = [];
  try { pending = toArray(await fetchJson('/api/approvals/pending'), 'approvals', 'pending'); } catch (e) { return; }
  const data = _container.__ovData;
  if (!data) return;
  data.pending = pending;
  const metricsEl = _container.querySelector('#ov-metrics');
  const attnEl = _container.querySelector('#ov-attention');
  if (metricsEl) metricsEl.innerHTML = renderMetrics(data);
  if (attnEl) attnEl.innerHTML = renderAttention(data);
  bindEvents(_container, data);  // ← re-binds .db-agent-row nodes that were NOT re-rendered
}

Quantitative check — scoped bind not present

(git show origin/main:src/praisonaiui/templates/frontend/plugins/views/overview.js | Select-String "bindEvents\(metricsEl").Count
0    # fix not applied on main

Full shared log: E2E-TERMINAL-EVIDENCE-LOG.md


End-to-end verification

Prerequisites

Item Value
Dashboard example examples/python/06-dashboard
Port 8103 (or your local gateway)
URL http://127.0.0.1:8103/overview
Needs ≥1 agent in table, ≥0 pending approvals

E2E procedure (browser)

Step Action Record
E1 Start gateway: cd examples/python/06-dashboard && aiui run (or existing process on 8103) HTTP 200 on /overview
E2 Open DevTools → Console No JS errors on load
E3 Open DevTools → Elements; find first .db-agent-row Note data-agent-id
E4 Do not click. Wait 35 seconds (≥3 approval polls at 10s) #ov-attention may update if approvals exist
E5 Click the same agent row once Count drawer opens
E6 Close drawer; wait another 35 seconds
E7 Click same agent row once again Count drawer opens

E2E expected vs actual (main @ 6d194fc)

Step Expected (correct) Actual on main Pass?
E5 first click after 35s Drawer opens Often 2–4× (stacked handlers) FAIL
E7 after 70s total Drawer opens 3–7× depending on dwell time FAIL
Approve click in attention feed Single POST + refresh Works but triggers extra refreshApprovals → faster stack Partial
paint() at 30s Full re-render; listeners reset Works — masks bug if user only waits for metrics poll OK

E2E procedure (DevTools listener proof)

Paste in browser console on /overview after 40s dwell:

const row = document.querySelector('.db-agent-row');
let opens = 0;
const orig = window.__ovOpenDrawer;
// If openDrawer not exposed, spy click result instead:
row.click();
// Or use getEventListeners(row) in Chrome DevTools → Event Listeners panel
// Expected after fix: 1 listener on click
// Actual on main after 4 polls: 5 listeners on click
Polls elapsed Expected listeners on .db-agent-row Observed on main
0 (fresh paint) 1 1
3 (30s) 1 4
6 (60s) 1 7

E2E regression after fix

Step Expected
Repeat E4–E7 after patch Drawer always opens
bindEvents(metricsEl) grep count ≥1
bindEvents(_container, data) inside refreshApprovals 0 (removed)
pytest tests/unit tests/integration Green

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions