Agent-Active Banner
The agent-active banner appears in the contact chat view whenever a non-terminal plan is running on the contact. It tells every user — even those without plan permissions — that an AI agent is handling the conversation.
Why it exists
Section titled “Why it exists”When an autonomous plan sends messages on a contact thread, users without plan visibility see outbound messages appearing “from nowhere.” The banner eliminates that confusion by surfacing agent activity to anyone who can view the contact.
API endpoint
Section titled “API endpoint”GET /api/contacts/{contact_id}/active-plansPermission gate: ContactInstanceAction::View — not plan-level permissions. Any user who can see the contact gets the banner.
Response type: ContactActivePlansSummary
pub struct ContactActivePlansSummary { pub plans: Vec<ContactActivePlan>, pub can_view_plans: bool,}
pub struct ContactActivePlan { pub id: Uuid, pub state: PlanState, pub template_id: Option<Uuid>, pub template_name: Option<String>,}can_view_plans reflects whether the caller holds PlanCollectionPermission::List. The UI uses this flag to decide whether to render “View plan →” links and template names.
For callers without plan permissions, template_id and template_name are None, and PlanState is redacted via redact_for_limited_view() — stripping payloads like Failed { error_message } to prevent leaking execution internals.
Real-time updates
Section titled “Real-time updates”The use_contact_active_plans hook seeds from the API on mount, then subscribes to two WebSocket events:
| Event | Effect |
|---|---|
plan.state.changed | Updates the plan’s state in the local list, or removes it if the new state is terminal |
plan.created | Adds the plan to the list if it targets this contact and is non-terminal |
On WebSocket reconnect, the hook refetches from the API to reconcile any missed transitions.
The hook mirrors the server-side permission gate locally — template_id and payload-carrying states are never written into the client signal for permission-less callers, even though WebSocket events broadcast org-wide.
Visual tones
Section titled “Visual tones”The banner uses three visual variants based on aggregate plan state. The “loudest” state wins when multiple plans are active.
| Tone | Trigger states | Dot | Background |
|---|---|---|---|
| Executing | Any plan is Executing | Pulsing primary | Primary tint + primary left rail |
| Active | Any plan is StandBy (none executing) | Static primary | Primary tint + primary left rail |
| Waiting | All plans are Paused, AwaitingInput, or PendingReview | Static amber | Amber tint + amber left rail |
Banner messages
Section titled “Banner messages”Single-plan messages adapt to the current state:
| State | Message |
|---|---|
Executing | An AI agent is working on this contact right now |
StandBy | An AI agent is scheduled to continue on this contact |
PendingReview | An AI agent is waiting for your approval to start |
AwaitingInput | An AI agent needs your input to continue |
Paused | An AI agent is paused on this contact |
Multi-plan: “Multiple AI agents are handling this contact (N active)”
Permission-less fallback: “An AI agent is handling this contact automatically” — no state-specific wording, no template names.
Permission-aware rendering
Section titled “Permission-aware rendering”| Element | With plan permission | Without plan permission |
|---|---|---|
| Banner visibility | ✓ | ✓ |
| State-specific wording | ✓ | Generic fallback |
| Template name (clickable link) | ✓ | Hidden |
| ”View plan →” link | ✓ | Hidden |
Key files
Section titled “Key files”| File | Purpose |
|---|---|
plan/api/get_contact_active_plans_api.rs | API endpoint, permission gating, state redaction |
plan/hooks/use_contact_active_plans.rs | Real-time hook with WebSocket subscription |
plan/components/contact_plan_active_banner_component.rs | Banner UI with tone logic and message rendering |
plan/types/contact_active_plans_summary_type.rs | ContactActivePlansSummary and ContactActivePlan types |
plan/types/plan_state_type.rs | PlanState::is_active(), redact_for_limited_view(), from_db_value() |
PlanState helpers
Section titled “PlanState helpers”Three methods were added to PlanState for this feature:
// Returns true for non-terminal states (not Completed, Failed, or Stopped)pub fn is_active(&self) -> bool
// Strips sensitive payloads for permission-less callers// Uses exhaustive match — adding a new variant forces a redaction decisionpub fn redact_for_limited_view(self) -> Self
// Server-only: parses JSONB from DB, logs warnings on failurepub fn from_db_value(plan_id: Uuid, value: Value) -> Option<Self>