Skip to content

feat(agents): add active turn indicators to Agents Menu#1005

Merged
wpfleger96 merged 9 commits into
mainfrom
duncan/active-turn-indicators
Jun 12, 2026
Merged

feat(agents): add active turn indicators to Agents Menu#1005
wpfleger96 merged 9 commits into
mainfrom
duncan/active-turn-indicators

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Surface per-channel "Working in #channel" indicators in the Agents Menu so users can see at a glance which agents are actively working and where — without navigating to each channel individually.

Problem

The Agents Menu shows process state (running/stopped) and presence (online/offline), but not cognitive state. Users cannot tell whether an agent is in the middle of a turn without opening each channel and checking the activity panel.

Solution

Two-layer approach:

Backend (ACP harness): A TurnCompletionGuard scope guard in run_prompt_task() emits turn_completed on every exit path (success, error, timeout, cancel, panic) via its Drop impl. This is symmetric with the existing turn_started emit at the top of the function — every turn that starts is guaranteed to complete in the observer stream. The Drop payload is {} because the guard cannot know the actual outcome on the drop path.

Desktop (derived store + UI):

  • activeAgentTurnsStore.ts — subscribes to the observer relay store and maintains a Map<agentPubkey, Map<turnId, ActiveTurn>> of active turns. Turns become active on turn_started and inactive on turn_completed / turn_error / agent_panic. A 90-second inactivity timeout prunes turns whose completion event was missed (checked every 5s).
  • AgentStatusBadge gains a pulsing "Working" variant that takes priority over "running".
  • ManagedAgentRow renders "Working in #channel-name" badges (max 3) below channel membership badges.
  • All channel badges (both active "Working in" and static membership) are clickable, navigating to the respective channel on click.

Event ordering: composite watermark

The store dedups observer events with a composite (timestamp, seq) watermark per agent, reusing the compareObserverEvents comparator from observerRelayStore. Every event kind is gated on the watermark uniformly — an event is processed only if it is strictly newer than the last one seen.

  • All event kinds are gated identically. Starts, activity, and evictions (turn_completed / turn_error / agent_panic) all skip equal-or-older events, so full-buffer replays are a complete no-op given the sorted-buffer invariant.
  • Post-restart streams are handled for free: the harness resets seq to 1 on restart, but wall-clock timestamp keeps climbing, so the comparator accepts post-restart events without any seq === 1 special case.
  • Why evictions are gated too. The harness emits turn_error / agent_panic with a null turnId, so the store falls back to deleting the first turn matching the channel. Processing such an eviction unconditionally on replay would re-run that channel-match fallback and delete the live turn whose start event is below the watermark. Gating evictions on the watermark closes that hole. Resurrection is not a risk: it would require reprocessing a stale start, which the watermark already blocks.

Exported API

Export Purpose
useActiveAgentTurns(pubkey) Hook: returns Set<channelId> where agent has active turns
useActiveAgentTurnsBridge(agents) Bridge hook: syncs observer events into the store
getActiveChannelsForAgent(pubkey) Snapshot accessor (for non-React consumers)
subscribeActiveAgentTurns(listener) External store subscription
syncAgentTurnsFromEvents(pubkey, events) Imperative sync (used by bridge)
resetActiveAgentTurnsStore() Test utility

Design decisions

  • Scope guard over manual emits: TurnCompletionGuard covers all exit paths structurally (including panics) without requiring each error handler to remember to emit.
  • Observer-based over typing indicators: Typing indicators (kind:20002) have an 8s TTL that causes flicker. The observer stream provides explicit start/end signals and is already encrypted to the agent owner.
  • 90s prune timeout: Safety net for missed completion events. Activity (acp_read / acp_write) refreshes lastActivityAt only for the matching turnId, so unrelated agent activity does not prevent expiry.
  • MAX_TURNS_PER_AGENT = 4: Matches the ACP pool size. Oldest turn is evicted if exceeded.
  • endTurn channelId fallback: If turn_completed arrives without a turnId (legacy path), falls back to removing the first turn matching the channelId.
  • Clickable badges: Both "Working in #channel" and static channel membership badges navigate to the channel on click via goChannel(id). stopPropagation() prevents triggering the parent row expand.

Replaces #994 (which was opened from a fork); identical change set, head on block/buzz.

npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 7 commits June 12, 2026 10:05
Emit turn_completed observer event from the ACP harness on successful
and cancelled turn exits (symmetric with existing turn_error/agent_panic).
Add a desktop-side derived store that tracks active turns per agent per
channel by watching the observer event stream, with a 5-minute staleness
timeout as safety net. Surface per-channel 'Working in #channel' badges
and a pulsing 'Working' status badge in the Agents Menu.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…tionGuard

The previous implementation emitted turn_completed only on the Ok and
Cancelled paths in lib.rs, leaving 8+ error/timeout exit paths in
run_prompt_task uncovered. A missed completion event causes phantom
"Working" indicators until the staleness timeout fires.

Replace with a TurnCompletionGuard scope guard in pool.rs that captures
the observer handle and turn metadata at creation time and emits
turn_completed on Drop — covering every exit path including panics.
Remove the now-redundant manual emits from lib.rs.

Rewrite activeAgentTurnsStore.ts to match the revised spec:
- Track turns by turnId (not channelId) for correct multi-channel handling
- Activity-based staleness: mark stale after 20s of no acp_read/write,
  remove entirely after 90s
- Cap at 4 concurrent turns per agent (matches pool size)
- Add useActiveAgentTurnsDetailed hook returning Map with stale flag
- Keep useActiveAgentTurns returning Set<string> for backward compat

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
getActiveChannelsForAgent and getActiveTurnsForAgent returned new
object instances on every call, breaking useSyncExternalStore's
reference equality check and causing excessive re-renders on every
observer event.

Cache Set/Map per agent key, invalidated only when turns actually
change (startTurn, endTurn, pruneExpired). Also respects
prefers-reduced-motion for pulsing badges and documents the
sorted-input invariant on processEvent.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Captures 3 states: baseline idle, single agent working, and mixed
states (one agent working in 2 channels). Adds __BUZZ_E2E_SEED_ACTIVE_TURNS__
hook to e2eBridge with monotonic seq counter to prevent same-millisecond
event deduplication when seeding multiple turns.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Fix seq-restart bug: detect when agent restarts (seq resets to 1) and
reset the high-water mark so subsequent events are processed instead of
silently dropped.

Remove dead code: ActiveTurnInfo type, useActiveAgentTurnsDetailed hook,
getActiveTurnsForAgent, cachedDetailedMaps, STALE_AFTER_MS, and the
staleness-related prune logic (~60 lines with zero consumers).

Fix badge text from "Working in # general" to "Working in #general".

Change TurnCompletionGuard Drop payload from {"outcome": "dropped"} to
{} — the guard cannot know the actual outcome on the drop path.

Remove disarm() method and its #[allow(dead_code)] annotation.

Add unit tests covering seq filtering, restart regression detection,
eviction at MAX_TURNS_PER_AGENT, and turnId-vs-channelId endTurn
fallback.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Working in #channel and static # channel membership badges now navigate
to the channel when clicked. Both badge types get cursor-pointer and
hover:opacity-80 styling to indicate interactivity.

channelsByPubkey changed from Record<string, string[]> to
Record<string, {id, name}[]> so badge click handlers have the channel
ID needed for goChannel navigation.

Co-authored-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
The seq-only watermark relied on a seq===1 heuristic to detect agent
restarts. That heuristic is fragile: it assumes the harness always
restarts at seq 1 and can misfire on legitimate seq-1 events.

Replace it with a composite (timestamp, seq) watermark reusing the
existing compareObserverEvents comparator. Post-restart streams are now
handled for free because wall-clock timestamp keeps climbing across
restarts, and full-buffer replays are idempotent.

Eviction (turn_completed / turn_error / agent_panic) stays unconditional
so a stale replayed start can never resurrect an already-evicted turn;
the watermark gates only new-turn creation and activity refresh.

Co-authored-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
@wpfleger96

Copy link
Copy Markdown
Collaborator Author

Baseline — agents idle

Three agents: two running, one stopped. No active turns.

01-baseline-idle

Single agent working

Paul is working in #general — shows "Working" badge and channel indicator.

02-single-agent-working

Mixed states

Paul working in two channels, Duncan idle, Thufir stopped. Full state matrix.

03-mixed-states

npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 2 commits June 12, 2026 12:20
The store gated starts on the (timestamp, seq) watermark but processed
turn_completed/turn_error/agent_panic unconditionally. The harness emits
turn_error and agent_panic with a null turnId, so eviction falls back to
deleting the first turn matching the channel. On every observer event the
full buffer is replayed, so a stale eviction re-ran the channel-match
fallback and deleted the live turn — whose start sits below the watermark
and never returns. Working badges for that channel went permanently dead
after one error.

Gating every event kind uniformly makes replays a no-op given the sorted-
buffer invariant. Resurrection was never the risk the old comment claimed:
it would require reprocessing a stale start, which the watermark blocks.

Co-authored-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
The relay-agent pill map fell back to the channel name as the id when
channelIds and channels arrays misaligned, silently producing a pill
that navigated to a name as if it were a channel id. Drop the entry
instead so navigation never resolves to a bogus target.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 merged commit 7983bf6 into main Jun 12, 2026
27 checks passed
@wpfleger96 wpfleger96 deleted the duncan/active-turn-indicators branch June 12, 2026 17:54
wpfleger96 added a commit that referenced this pull request Jun 12, 2026
…session-new

* origin/main:
  Add relay disconnect UX: friendly errors, reconnect, cached identity (#1004)
  feat(agents): add active turn indicators to Agents Menu (#1005)
  ci: add fork guards to docker, release, and auto-tag workflows (#1007)
  docs(nip-rs): add optional thread read context scheme (#1006)
tellaho added a commit that referenced this pull request Jun 12, 2026
…tate

* origin/main:
  Add relay disconnect UX: friendly errors, reconnect, cached identity (#1004)
  feat(agents): add active turn indicators to Agents Menu (#1005)
  ci: add fork guards to docker, release, and auto-tag workflows (#1007)
  docs(nip-rs): add optional thread read context scheme (#1006)
  fix(huddle): Pocket TTS quality overhaul — reference parity + cross-message pipelining (#997)
  Add manual ACP session rotation command (#932)
  fix(desktop): heal stale persona_team_dir paths in release builds (#1003)
  ci(docker): publish public ghcr.io/block/buzz image (native multi-arch) (#986)
  fix(buzz-agent): cap tool-result text at 50 KiB with middle elision (#952)
  feat(huddle): sentence-at-a-time voice-mode guidelines for lower TTS latency (#996)
  Shard desktop Playwright CI jobs (#992)
  chore(release): release version 0.3.18 (#995)
  Video Player Improvements  (#993)
  Improve first-run welcome setup (#970)
  fix(release): use legacy updater key secret (#991)
  Replace built-in personas with Fizz (#987)
tlongwell-block pushed a commit that referenced this pull request Jun 13, 2026
* origin/main: (33 commits)
  fix(desktop): make Windows release compile cleanly (#1029)
  Add production Docker Compose bundle (#985)
  feat(profile): show active turn badges on agent profile panel and popover (#1026)
  chore(release): release version 0.3.20 (#1027)
  fix(release): resolve Windows sidecar path and Linux AppImage updater format (#1024)
  chore(release): release version 0.3.19 (#1014)
  fix(release): ignore prerelease tags in changelog generation (#1021)
  fix: repair main build after cross-PR merge skew (#1020)
  feat(agents): show per-turn duration and prune dead turns within ~25s of host crash (#1017)
  fix(release): replace hermit with native tool setup on Windows job (#1018)
  feat(acp): surface error-class outcomes to the activity feed only, never the channel (#1010)
  fix(desktop): migrate Sprout workspace storage (#1016)
  feat(auth): force token refresh on rejected token (401/403), never the browser (#1015)
  fix(release): mark prerelease versions so they do not become latest (#1013)
  feat(acp): implement systemPrompt with protocol version gating (#981)
  fix(release): update repository name check from block/sprout to block/buzz (#1012)
  feat(release): all-OS desktop builds + universal auto-update manifest (#1011)
  Add relay disconnect UX: friendly errors, reconnect, cached identity (#1004)
  feat(agents): add active turn indicators to Agents Menu (#1005)
  ci: add fork guards to docker, release, and auto-tag workflows (#1007)
  ...

Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant