Skip to content

canvas: poll-storm, SSE self-heal & keyboardless-wall UX (#141 #137 #142)#144

Merged
davidj4tech merged 3 commits into
mainfrom
canvas-review-followups
Jul 11, 2026
Merged

canvas: poll-storm, SSE self-heal & keyboardless-wall UX (#141 #137 #142)#144
davidj4tech merged 3 commits into
mainfrom
canvas-review-followups

Conversation

@davidj4tech

Copy link
Copy Markdown
Owner

Three follow-up fixes from the canvas review, each a separate commit, all confined to packages/visual/src/agent_media_visual/canvas.py. Branched from and targeting agent-tree-vim-nav.

#141 — /agents poll storm (perf)

  • Server: memoized the /agents payload for ~2s (_agents_payload(), canvas.py:383) behind a lock, so a burst of client polls collapses to a single tmux list-panes + per-pane capture-pane sweep instead of N screens × M panes forking every few seconds. /agents handler now just returns the cached payload (canvas.py:2487).
  • Client: the /agents poll is now adaptive (scheduleAgents, canvas.py:2421) — fast (4s) only while the tree pill is expanded, a slow 12s heartbeat when collapsed (still keeps the "who needs me" pill's dot/count live and discovers new agents), 30s while backgrounded. Gated the controller poll (~2s), the beat pump (~1s) and the video-restore timer (~5s) on !document.hidden; the beat pump (pumpSeq, canvas.py:1597) now runs only while the voice is speaking instead of ticking forever.

Note: I did not make collapsed-polling stop entirely as the issue's phrasing suggests — with zero polling when collapsed, a freshly-appeared agent that needs you would never light the pill (you can't expand a pill that isn't there), defeating the whole "who needs me" glance. A slow heartbeat when collapsed keeps that working while still killing the fast storm. Server memoization already collapses the N-client multiplication regardless. Flag if you'd prefer truly-zero collapsed polling.

#137 — SSE self-heal + client cap (fix)

  • Server: heartbeat is now a real data: {"kind":"ping"} frame (canvas.py:2564) instead of an SSE : comment (which EventSource ignores, so it could never drive a client watchdog). Added a MAX_SSE_CLIENTS=64 cap in Hub.attach() (canvas.py:66) — returns None past the cap and the SSE handler answers 503 before sending headers (canvas.py:2543), so a flood of half-open /events streams can't exhaust the threaded server's threads/fds.
  • Client: onmessage ignores kind==='ping' for rendering but stamps lastEventTs on every frame; a 15s watchdog closes and reopens the EventSource after ~45s of silence (connectEvents, canvas.py:1739) so a stalled wall reconnects itself instead of only dimming the 8px dot. lastEventTs is reset on foreground so unhiding doesn't trip a false reconnect.

#142 — keyboardless-wall UX (fix)

  • Replaced the native window.prompt() fallbacks (askToken / typedSeek / typedOpen) with an in-page input sheet (askSheet, canvas.py:1908; #sheet, canvas.py:1305) reusing the #help/#toast/#peek overlay pattern — respects the e-ink theme, not a dead modal. No native prompt() calls remain.
  • On a /input 401, toast() a pointer to the pairing QR (…/pair) instead of a native modal — typing a 40-char token on a wall is misery; the in-page sheet is still offered for keyboard canvases.
  • Room-legible disconnect: after ~10s down, grey the canvas (scrim backdrop-filter, no conflict with the .layer filters) and float a big "reconnecting…" banner (#offbar, canvas.py:1301). Driven by a shared setDisconnected (canvas.py:1728) that the canvas: SSE stream can silently stall (no heartbeat/​watchdog) and has no client cap #137 watchdog and onopen/onerror all call — one coherent mechanism, not two competing ones. The 10s escalation timer is not reset by repeated onerror/retry, so a real outage actually surfaces.
  • /input failures now go through toast(r.detail) instead of console.warn.

⚠️ Needs a browser check

The embedded client JS can't be exercised headless. It passes node --check (syntax) and I've self-reviewed the logic, but the client-side behaviour needs your visual confirmation in a real browser / on the wall:

  • expand/collapse the agent tree and confirm the pill stays live when collapsed;
  • the in-page sheet for s/o (typed seek / open URL) and the token/pairing path on a 401;
  • the "reconnecting…" banner + canvas dimming on a killed stream, and that it self-heals (kill the canvas service's connection, not the service itself);
  • e-ink theming of #sheet and #offbar on the Pine Note.

Verification

  • python3 -m py_compile packages/visual/src/agent_media_visual/canvas.py — passes.
  • Import smoke (import agent_media_visual.canvas) — passes; MAX_SSE_CLIENTS=64, _agents_payload present.
  • Extracted app JS → node --check — passes (syntax only).

Cleanup

Built in a leftover worktree at ~/projects/agent-media-canvas-followups (branch canvas-review-followups) — remove it after merge: git worktree remove ~/projects/agent-media-canvas-followups.

Closes #137
Closes #141
Closes #142

Server: memoize the /agents payload for ~2s so N canvases x M panes stop
each forking `tmux list-panes` + a `capture-pane` per claude pane on a
short timer — a burst of client polls now collapses to one host sweep.

Client: poll /agents fast only while the agent-tree pill is expanded and
someone is watching states change; drop to a slow heartbeat when
collapsed (still keeps the "who needs me" pill's dot/count live and
discovers new agents). Gate the periodic controller poll (~2s), the beat
pump (~1s) and the video-restore timer (~5s) on !document.hidden the way
pollAgents already is, and run the beat pump only while the voice is
speaking instead of forever.
Server: emit the heartbeat as a real `data: {"kind":"ping"}` frame
instead of an SSE `: comment` — EventSource ignores comments, so a
comment heartbeat never reaches the client and a silent stall (mobile
backgrounding, half-open TCP on a days-long wall) goes undetected. Cap
held-open /events streams in Hub.attach() (MAX_SSE_CLIENTS=64) and return
503 past the cap so a flood of half-open clients can't exhaust
ThreadingHTTPServer's threads/fds.

Client: ignore kind==='ping' for rendering but stamp lastEventTs on every
frame; a 15s watchdog closes and reopens the EventSource after ~45s of
silence so a stalled wall reconnects itself instead of only dimming an
8px dot. Reset the clock on foreground so unhiding doesn't trip a false
reconnect.
#142)

Replace the native window.prompt() fallbacks (askToken / typedSeek /
typedOpen) with an in-page input sheet reusing the #help/#toast/#peek
overlay pattern, so they honour the e-ink theme and aren't dead modals on
a keyboardless wall. On a /input 401, toast a pointer to the pairing QR
(a 40-char token is misery to type on a wall) instead of popping a native
modal. Make disconnect room-legible: after ~10s down, grey the canvas and
float a big "reconnecting…" banner — coordinated with the #137 watchdog
(shared setDisconnected) rather than a competing mechanism, so a frozen
wall no longer looks live behind only an 8px dot. Route /input failures
through toast(r.detail) instead of console.warn.
@davidj4tech davidj4tech deleted the branch main July 11, 2026 10:44
@davidj4tech davidj4tech reopened this Jul 11, 2026
@davidj4tech davidj4tech changed the base branch from agent-tree-vim-nav to main July 11, 2026 11:13
@davidj4tech

Copy link
Copy Markdown
Owner Author

Reopened and retargeted to main. This PR was auto-closed at 10:44Z when its original base branch agent-tree-vim-nav was deleted right after it merged into main (PR #136) — not an intentional close. The three commits are unchanged (2646ac4); merge preview into main is clean (canvas.py only). Ready for review — fixes issues #141, #137, #142.

@davidj4tech davidj4tech merged commit 3697df5 into main Jul 11, 2026
4 checks passed
@davidj4tech davidj4tech deleted the canvas-review-followups branch July 11, 2026 11:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant