Build an Agent UI That Explains Its State with Angular Signals
Most chat interfaces have three states: empty, loading, and finished. Agent workflows have many more. An agent may be planning, waiting for a tool, requesting approval, retrying a recoverable error, or suppressing an unsafe action. If the UI represents all of those as one spinner, users cannot tell whether the system is working, blocked, or about to change something. The better model is not βmessages plus loading.β It is an explicit state machine.
Define states the product can explain
Start with observable runtime states-not the model's hidden reasoning:
type AgentUiState =
| { kind: "idle" }
| { kind: "planning" }
| { kind: "using_tool"; tool: string }
| { kind: "waiting_for_approval"; proposalId: string }
| { kind: "recovering"; attempt: number }
| { kind: "blocked"; reasonCode: string }
| { kind: "completed"; outcome: string }
| { kind: "failed"; message: string };
This vocabulary should come from actual runtime events. Do not fabricate a βthinkingβ narrative that implies access to private chain-of-thought. Store facts; compute presentation.
Store facts; compute presentation
Angular signals work well when the event stream is the source of truth and presentation is derived from it:
import { computed, signal } from "@angular/core";
type AgentEvent = {
type: "run_started" | "tool_started" | "approval_required" | "retry_started" | "run_completed" | "run_failed";
tool?: string;
proposalId?: string;
attempt?: number;
outcome?: string;
message?: string;
};
const events = signal([]);
const state = computed(() => {
const event = events().at(-1);
if (!event) return { kind: "idle" };
switch (event.type) {
case "run_started": return { kind: "planning" };
case "tool_started": return { kind: "using_tool", tool: event.tool! };
case "approval_required": return { kind: "waiting_for_approval", proposalId: event.proposalId! };
case "retry_started": return { kind: "recovering", attempt: event.attempt! };
case "run_completed": return { kind: "completed", outcome: event.outcome! };
case "run_failed": return { kind: "failed", message: event.message! };
}
});
Angular's signals guide recommends computed() for derived state and warns against using effects to propagate state changes. That distinction matters here. The event list is state; the current label, available actions, and accessibility message are derivations. Use effect() only for a real side effect such as analytics or persistence-and keep it independent from the state transition itself.
Treat approval as a real state
Approval is not a modal layered over βloading.β It pauses one proposal and creates a new user decision.
@switch (state().kind) {
@case ('using_tool') {
<p aria-live="polite">Using {{ state().tool }}</p>
}
@case ('waiting_for_approval') {
<app-action-review [proposalId]="state().proposalId" (approved)="approve($event)" (rejected)="reject($event)" />
}
@case ('recovering') {
<p aria-live="polite">Recovering, attempt {{ state().attempt }}</p>
}
@case ('blocked') {
<p role="alert">Action blocked: {{ state().reasonCode }}</p>
}
}
The review component should show the proposed tool, bounded arguments, evidence freshness, and scope of approval. βContinue?β is not enough for a consequential action.
Prevent stale streams from winning
A common UI bug occurs when request A is cancelled, request B starts, and a late event from A overwrites B's state. Give each event a run ID and ignore events for inactive runs:
const activeRunId = signal<string | null>(null);
function acceptEvent(runId: string, event: AgentEvent) {
if (runId !== activeRunId()) return;
events.update((current) => [...current, event]);
}
Cancellation should be a runtime operation as well as a visual one. Removing a spinner does not stop a network request or tool call. For reconnectable streams, add an event ID or monotonically increasing sequence number. Ignore duplicates, detect gaps, and request a snapshot when the UI cannot safely reconstruct state. A signal will faithfully render bad ordering if the transport contract never defined ordering. Use immutable updates such as events.update(...). Angular's readonly signal surface does not prevent deep mutation of an object or array, and mutating a retained value in place can make state changes harder to reason about.
Make the state accessible and actionable
Expose aria-busy="true" only while work is genuinely progressing. An approval state is not busy: focus should move to the review controls, the proposed effect should be described, and Approve and Reject should remain keyboard accessible. Announce concise state changes through an aria-live region, but do not stream every token into it.
Test transitions, not animation timing
Component tests can drive events deterministically:
it("shows approval after a tool proposal", () => {
mount(AgentPanelComponent);
emit({ type: "run_started" });
emit({ type: "approval_required", proposalId: "synthetic-proposal" });
cy.findByRole("button", { name: /approve/i }).should("be.visible");
cy.findByText(/synthetic-proposal/i).should("exist");
});
Cypress documents current Angular component-testing support in its Angular guide. Keep model and tool calls stubbed for these state tests; use a smaller number of integration tests for the real event protocol.
Explain what the system knows
An agent UI should answer three questions:
- What observable stage is active?
- Does the user need to act?
- What outcome was verified?
Angular signals make those answers easy to derive from a structured event stream. The hard part is defining honest states. Do that first, and the UI stops being a chat box with a spinner-it becomes a trustworthy view of the workflow.
References
- Angular Signals
- Cypress
- Angular component testing
Comments
No comments yet. Start the discussion.