{"slug": "build-an-agent-ui-that-explains-its-state-with-angular-signals", "title": "Build an Agent UI That Explains Its State with Angular Signals", "summary": "A developer outlined a pattern for building agent user interfaces in Angular that model runtime states explicitly rather than collapsing them into a single loading spinner. The approach derives UI state from an observable event stream using Angular signals and computed(), distinguishing states such as planning, using a tool, awaiting approval, recovering, blocked, completed, and failed, while guarding against stale events from cancelled runs via run IDs.", "body_md": "Most chat interfaces have three states: empty, loading, and finished. Agent workflows have many more.\n\nAn 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.\n\nThe better model is not “messages plus loading.” It is an explicit state machine.\n\nStart with observable runtime states—not the model's hidden reasoning:\n\n```\ntype AgentUiState =\n  | { kind: \"idle\" }\n  | { kind: \"planning\" }\n  | { kind: \"using_tool\"; tool: string }\n  | { kind: \"waiting_for_approval\"; proposalId: string }\n  | { kind: \"recovering\"; attempt: number }\n  | { kind: \"blocked\"; reasonCode: string }\n  | { kind: \"completed\"; outcome: string }\n  | { kind: \"failed\"; message: string };\n```\n\nThis vocabulary should come from actual runtime events. Do not fabricate a “thinking” narrative that implies access to private chain-of-thought.\n\nAngular signals work well when the event stream is the source of truth and presentation is derived from it:\n\n``` js\nimport { computed, signal } from \"@angular/core\";\n\ntype AgentEvent = {\n  type: \"run_started\" | \"tool_started\" | \"approval_required\" |\n        \"retry_started\" | \"run_completed\" | \"run_failed\";\n  tool?: string;\n  proposalId?: string;\n  attempt?: number;\n  outcome?: string;\n  message?: string;\n};\n\nconst events = signal<AgentEvent[]>([]);\n\nconst state = computed<AgentUiState>(() => {\n  const event = events().at(-1);\n  if (!event) return { kind: \"idle\" };\n\n  switch (event.type) {\n    case \"run_started\": return { kind: \"planning\" };\n    case \"tool_started\":\n      return { kind: \"using_tool\", tool: event.tool! };\n    case \"approval_required\":\n      return { kind: \"waiting_for_approval\", proposalId: event.proposalId! };\n    case \"retry_started\":\n      return { kind: \"recovering\", attempt: event.attempt! };\n    case \"run_completed\":\n      return { kind: \"completed\", outcome: event.outcome! };\n    case \"run_failed\":\n      return { kind: \"failed\", message: event.message! };\n  }\n});\n```\n\nAngular's [signals guide](https://angular.dev/guide/signals) 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.\n\nUse `effect()` only for a real side effect such as analytics or persistence—and keep it independent from the state transition itself.\n\nApproval is not a modal layered over “loading.” It pauses one proposal and creates a new user decision.\n\n```\n@switch (state().kind) {\n  @case ('using_tool') {\n    <p aria-live=\"polite\">Using {{ state().tool }}</p>\n  }\n  @case ('waiting_for_approval') {\n    <app-action-review\n      [proposalId]=\"state().proposalId\"\n      (approved)=\"approve($event)\"\n      (rejected)=\"reject($event)\" />\n  }\n  @case ('recovering') {\n    <p aria-live=\"polite\">Recovering, attempt {{ state().attempt }}</p>\n  }\n  @case ('blocked') {\n    <p role=\"alert\">Action blocked: {{ state().reasonCode }}</p>\n  }\n}\n```\n\nThe review component should show the proposed tool, bounded arguments, evidence freshness, and scope of approval. “Continue?” is not enough for a consequential action.\n\nA 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:\n\n``` js\nconst activeRunId = signal<string | null>(null);\n\nfunction acceptEvent(runId: string, event: AgentEvent) {\n  if (runId !== activeRunId()) return;\n  events.update((current) => [...current, event]);\n}\n```\n\nCancellation should be a runtime operation as well as a visual one. Removing a spinner does not stop a network request or tool call.\n\nFor 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.\n\nUse 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.\n\nExpose `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.\n\nComponent tests can drive events deterministically:\n\n``` js\nit(\"shows approval after a tool proposal\", () => {\n  mount(AgentPanelComponent);\n\n  emit({ type: \"run_started\" });\n  emit({\n    type: \"approval_required\",\n    proposalId: \"synthetic-proposal\",\n  });\n\n  cy.findByRole(\"button\", { name: /approve/i }).should(\"be.visible\");\n  cy.findByText(/synthetic-proposal/i).should(\"exist\");\n});\n```\n\nCypress documents current Angular component-testing support in its [Angular guide](https://docs.cypress.io/app/component-testing/angular/overview). Keep model and tool calls stubbed for these state tests; use a smaller number of integration tests for the real event protocol.\n\nAn agent UI should answer three questions:\n\nAngular 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.", "url": "https://wpnews.pro/news/build-an-agent-ui-that-explains-its-state-with-angular-signals", "canonical_source": "https://dev.to/raju_dandigam/build-an-agent-ui-that-explains-its-state-with-angular-signals-28op", "published_at": "2026-09-18 00:09:49+00:00", "updated_at": "2026-09-18 00:22:54.316279+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-products"], "entities": ["Angular"], "alternates": {"html": "https://wpnews.pro/news/build-an-agent-ui-that-explains-its-state-with-angular-signals", "markdown": "https://wpnews.pro/news/build-an-agent-ui-that-explains-its-state-with-angular-signals.md", "text": "https://wpnews.pro/news/build-an-agent-ui-that-explains-its-state-with-angular-signals.txt", "jsonld": "https://wpnews.pro/news/build-an-agent-ui-that-explains-its-state-with-angular-signals.jsonld"}}