Building AI-Native UI with Angular
A weekend experiment on how Angular can be used to build AI-native product interfaces
The interface is part of the intelligence
AI-native products need more than a text box attached to a model. The interface has to make uncertainty visible, support progressive results, and let users correct the system without losing context.
Angular is a useful environment for this work because signals, dependency injection, and explicit component boundaries make state transitions easier to reason about. The goal is not to hide the model behind a conventional form. It is to design a clear collaboration between the user, the interface, and the model.
This article describes the architecture I used for a small recommendation interface. A user describes what they need, the server streams a response, and the UI progressively presents suggestions.
Start with explicit states
An AI request is not simply loading or finished. It can be idle, waiting for the first token, streaming a partial answer, complete, or failed.
I model those states as a discriminated union:
export type AssistantState =
| { status: 'idle' }
| { status: 'pending'; prompt: string }
| { status: 'streaming'; prompt: string; content: string }
| { status: 'complete'; prompt: string; content: string }
| { status: 'error'; prompt: string; message: string };
This is more verbose than three unrelated booleans, but it prevents impossible
combinations such as isLoading and hasError both being true.
It also makes the template easier to review. Every visual state corresponds to one domain state.
Put model activity behind a transport boundary
The Angular application should not call a model provider directly. API keys, rate limits, prompt construction, and provider-specific response formats belong on the server.
The frontend only needs a small contract:
export interface AssistantChunk {
type: 'content' | 'done';
value?: string;
}
export abstract class AssistantTransport {
abstract stream(prompt: string): Observable<AssistantChunk>;
}
An HTTP implementation can translate the server protocol into that contract. The rest of the application does not need to know whether the server uses server-sent events, newline-delimited JSON, or another streaming format.
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class HttpAssistantTransport implements AssistantTransport {
private readonly http = inject(HttpClient);
stream(prompt: string): Observable<AssistantChunk> {
return this.http.post<AssistantChunk>('/api/assistant', { prompt });
}
}
The example uses a regular HTTP response to keep the boundary clear. A real streaming implementation can replace this class without changing the store or component.
Keep orchestration in a signal store
The component should collect input and render state. Request orchestration, partial output, cancellation, and error mapping are easier to test in a small store.
import { Injectable, computed, inject, signal } from '@angular/core';
import { Subscription } from 'rxjs';
@Injectable()
export class AssistantStore {
private readonly transport = inject(AssistantTransport);
private request?: Subscription;
readonly state = signal<AssistantState>({ status: 'idle' });
readonly canSubmit = computed(() => {
const status = this.state().status;
return status !== 'pending' && status !== 'streaming';
});
submit(rawPrompt: string): void {
const prompt = rawPrompt.trim();
if (!prompt || !this.canSubmit()) {
return;
}
this.request?.unsubscribe();
this.state.set({ status: 'pending', prompt });
this.request = this.transport.stream(prompt).subscribe({
next: (chunk) => this.applyChunk(prompt, chunk),
error: () => {
this.state.set({
status: 'error',
prompt,
message: 'The assistant could not complete this request.',
});
},
});
}
cancel(): void {
this.request?.unsubscribe();
this.state.set({ status: 'idle' });
}
private applyChunk(prompt: string, chunk: AssistantChunk): void {
const current = this.state();
const content =
current.status === 'streaming' || current.status === 'complete'
? current.content
: '';
if (chunk.type === 'done') {
this.state.set({ status: 'complete', prompt, content });
return;
}
this.state.set({
status: 'streaming',
prompt,
content: content + (chunk.value ?? ''),
});
}
}
This store has one owner for the active subscription. Submitting a replacement request or pressing cancel stops the previous work before resetting state.
For a larger product, I would also preserve conversation history and attach a request ID to each event. The ID prevents a late response from an old request from modifying the current view.
Let the component describe the interaction
With the state machine outside the component, the UI remains small:
import { Component, inject, signal } from '@angular/core';
@Component({
selector: 'app-ai-assistant',
standalone: true,
providers: [
AssistantStore,
{
provide: AssistantTransport,
useExisting: HttpAssistantTransport,
},
],
template: `
<section>
<label for="prompt">What are you looking for?</label>
<textarea
id="prompt"
[value]="prompt()"
(input)="updatePrompt($event)"
></textarea>
<button
type="button"
[disabled]="!store.canSubmit()"
(click)="store.submit(prompt())"
>
Ask assistant
</button>
@switch (store.state().status) {
@case ('pending') {
<p aria-live="polite">Thinking...</p>
}
@case ('streaming') {
<p aria-live="polite">{{ output() }}</p>
<button type="button" (click)="store.cancel()">Stop</button>
}
@case ('complete') {
<p>{{ output() }}</p>
}
@case ('error') {
<p role="alert">{{ errorMessage() }}</p>
}
}
</section>
`,
})
export class AiAssistantComponent {
readonly store = inject(AssistantStore);
readonly prompt = signal('');
readonly output = () => {
const state = this.store.state();
return state.status === 'streaming' || state.status === 'complete'
? state.content
: '';
};
readonly errorMessage = () => {
const state = this.store.state();
return state.status === 'error' ? state.message : '';
};
updatePrompt(event: Event): void {
this.prompt.set((event.target as HTMLTextAreaElement).value);
}
}
The important detail is not the textarea. It is that pending, streaming, complete, and error states are intentionally visible. The interface does not pretend that model output appears instantly or is always correct.
Render structured output when the product needs structure
Plain text is useful for exploration, but product interfaces often need reviewable data. A recommendation feature might return:
export interface ProductRecommendation {
productId: string;
reason: string;
confidence: 'low' | 'medium' | 'high';
}
The server should validate model output before sending it to the browser. The UI can then render normal Angular components instead of parsing prose and hoping the format remains stable.
Structured output also creates room for product decisions:
- Show the reason behind each recommendation.
- Mark low-confidence suggestions.
- Let the user remove a bad result.
- Preserve manual edits when the model runs again.
These controls turn generated output into something the user can inspect and correct.
Design failure as part of the normal path
AI systems introduce ordinary distributed-system failures plus uncertain results. The UI should account for both.
At minimum, I want the interface to support:
- Cancellation for requests that take too long.
- Retry without requiring the user to rewrite the prompt.
- Clear separation between partial and final output.
- A stable error message that does not expose provider details.
- A path for correcting or rejecting a generated result.
None of these require a complex framework. They require explicit state and clear ownership.
What I would test
The most valuable tests operate on the store rather than the model provider:
it('accumulates chunks and completes the response', () => {
const chunks = new Subject<AssistantChunk>();
transport.stream.mockReturnValue(chunks);
store.submit('Recommend a keyboard');
expect(store.state().status).toBe('pending');
chunks.next({ type: 'content', value: 'Choose ' });
chunks.next({ type: 'content', value: 'a low-profile board.' });
chunks.next({ type: 'done' });
expect(store.state()).toEqual({
status: 'complete',
prompt: 'Recommend a keyboard',
content: 'Choose a low-profile board.',
});
});
I would add tests for cancellation, transport errors, empty prompts, duplicate submissions, and late events from replaced requests. Those cases contain more architectural risk than the visual markup.
The useful constraint
Angular does not make an interface AI-native by itself. It provides useful constraints: explicit dependencies, typed state, predictable templates, and testable boundaries.
The architecture becomes valuable when it keeps uncertainty understandable. The user should know when the system is working, what it produced, and how to change the result. That clarity is the real feature.