Your agent streams events from the backend. The frontend receives them. Now what?
Events sitting in a console log don't help users. They need to see "Creating src/app.ts...", watch code appear in an editor, see progress bars fill up, and get immediate error notifications when something fails.
This is about translating events into UI updates. File operations update the file tree. Code chunks stream into editors. Step events fill progress lists. Errors show notifications. The UI becomes a real-time reflection of what the agent is doing.
Why Frontend Progress Matters
Without progress reporting, users assume the worst. A 30-second operation feels like forever when there's no feedback. They refresh the page, cancel the operation, or assume something broke.
With progress reporting:
- Users stay informed - They see "Installing packages...", "Setting up database...", "Writing code..."
- Operations feel faster - Seeing progress makes time pass quickly
- Errors are immediate - No waiting for completion to see what failed
- Context is preserved - Users understand what succeeded before an error occurred
- Trust increases - Visibility builds confidence in the system
Good progress reporting turns a black box into a transparent, predictable experience.
Connecting to SSE Streams
Use the EventSource API to connect to Server-Sent Events:
const projectId = 'abc123';
const messageId = 'msg456';
const eventSource = new EventSource(
`/api/agents/${projectId}/chat?messageId=${messageId}`
);
eventSource.onopen = () => {
console.log('Connected to agent stream');
};
eventSource.onmessage = (e) => {
const event = JSON.parse(e.data);
handleEvent(event);
};
eventSource.onerror = (error) => {
console.error('SSE error:', error);
eventSource.close();
};EventSource automatically handles reconnection if the connection drops. It's built for long-lived streams.
When you're done:
eventSource.close();This closes the connection and stops receiving events.
Event Message Structure
Every event has the same structure:
interface AgentEvent {
type: string;
data?: any;
}The type follows the pattern: <agent>.<operation>.<lifecycle_stage>
Examples:
coding_agent.create_file.startedcoding_agent.edit_file.chunkdatabase_agent.step.completeddesign_system_agent.generate_design_system.done
The data field contains operation-specific information:
{
type: 'coding_agent.create_file.started',
data: {
file_path: 'src/app.ts'
}
}
{
type: 'coding_agent.create_file.chunk',
data: {
file_path: 'src/app.ts',
chunk: 'export function hello() {\n return "world";\n}'
}
}
{
type: 'coding_agent.create_file.completed',
data: {
file_path: 'src/app.ts'
}
}Basic Event Handler
Route events to different handlers based on type:
function handleEvent(event: AgentEvent) {
const { type, data } = event;
// Initialization
if (type === 'init') {
showMessage(data.message);
return;
}
// Completion
if (type === 'complete') {
hideLoadingIndicator();
showMessage('Agent finished');
return;
}
// File operations
if (type.includes('.create_file.')) {
handleFileCreate(type, data);
return;
}
if (type.includes('.edit_file.')) {
handleFileEdit(type, data);
return;
}
if (type.includes('.delete_file.')) {
handleFileDelete(type, data);
return;
}
// Step-based progress
if (type.includes('.step.')) {
handleStepProgress(type, data);
return;
}
// Errors
if (type.endsWith('.error')) {
handleError(type, data);
return;
}
}This routing pattern keeps event handling organized as you add more event types.
File Operation Events
File operations follow a three-phase lifecycle: started → chunk → completed.
Handling File Creation
const fileOperations = new Map<string, FileOperation>();
function handleFileCreate(type: string, data: any) {
const { file_path, chunk, error } = data;
if (type.endsWith('.started')) {
// Show loading state in file tree
fileOperations.set(file_path, {
type: 'create',
status: 'in_progress',
path: file_path
});
updateFileTree(file_path, { loading: true });
showNotification(`Creating ${file_path}...`, 'info');
}
if (type.endsWith('.chunk')) {
// Stream code into editor
const editor = getOrCreateEditor(file_path);
editor.setValue(chunk);
// Update syntax highlighting as code appears
editor.refresh();
}
if (type.endsWith('.completed')) {
if (error) {
// Show error state
updateFileTree(file_path, { loading: false, error: true });
showNotification(`Failed to create ${file_path}: ${error}`, 'error');
} else {
// Show success state
updateFileTree(file_path, { loading: false, created: true });
showNotification(`Created ${file_path}`, 'success');
}
fileOperations.delete(file_path);
}
if (type.endsWith('.error')) {
// Handle error
updateFileTree(file_path, { loading: false, error: true });
showNotification(`Error creating ${file_path}: ${error}`, 'error');
fileOperations.delete(file_path);
}
}UI updates:
- Started: File tree shows loading spinner next to new file
- Chunk: Editor shows code appearing in real-time
- Completed: Loading spinner becomes checkmark
- Error: Loading spinner becomes error icon, notification explains why
Handling File Edits
File edits include additional metadata like line changes:
function handleFileEdit(type: string, data: any) {
const { file_path, chunk, additions, deletions, old_code, new_code } = data;
if (type.endsWith('.started')) {
showNotification(`Editing ${file_path}...`, 'info');
highlightFileInTree(file_path, 'editing');
}
if (type.endsWith('.chunk')) {
// Show diff view while editing
const editor = getEditor(file_path);
if (editor) {
editor.setValue(chunk);
editor.refresh();
}
}
if (type.endsWith('.completed')) {
// Show statistics
if (additions || deletions) {
showNotification(
`Updated ${file_path} (+${additions}, -${deletions})`,
'success'
);
}
// Offer to show diff
if (old_code && new_code) {
showDiffButton(file_path, old_code, new_code);
}
highlightFileInTree(file_path, 'modified');
}
}The additions/deletions counts give users quick insight into the scope of changes.
Handling File Deletion
function handleFileDelete(type: string, data: any) {
const { file_path } = data;
if (type.endsWith('.started')) {
showNotification(`Deleting ${file_path}...`, 'info');
}
if (type.endsWith('.completed')) {
// Remove from file tree
removeFromFileTree(file_path);
// Close editor if open
const editor = getEditor(file_path);
if (editor) {
closeEditor(file_path);
}
showNotification(`Deleted ${file_path}`, 'success');
}
}Streaming Code to Editors
For code generation, stream chunks character by character:
const editorBuffers = new Map<string, string>();
function handleCodeChunk(filePath: string, chunk: string) {
// Accumulate chunks
const buffer = editorBuffers.get(filePath) || '';
const updated = buffer + chunk;
editorBuffers.set(filePath, updated);
// Update editor
const editor = getOrCreateEditor(filePath);
editor.setValue(updated);
// Scroll to cursor position (show latest code)
editor.scrollIntoView({ line: editor.lineCount(), ch: 0 });
// Update character count in status bar
updateStatusBar(filePath, {
characters: updated.length,
lines: updated.split('\n').length
});
}
function handleCodeComplete(filePath: string) {
// Clear buffer
editorBuffers.delete(filePath);
// Final editor state
const editor = getEditor(filePath);
if (editor) {
editor.refresh();
editor.clearSelection();
}
// Enable editing (was read-only during streaming)
editor.setOption('readOnly', false);
}This creates a typewriter effect where users see code appearing in real-time.
Step-Based Progress
Step events track multi-phase operations:
interface Step {
title: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
step_type: string;
error?: string;
details?: any;
}
const steps = new Map<string, Step>();
function handleStepProgress(type: string, data: any) {
const { title, status, step_type, error, details } = data;
if (type.endsWith('.started')) {
steps.set(title, {
title,
status: 'in_progress',
step_type
});
showNotification(`${title}...`, 'info');
}
if (type.endsWith('.completed')) {
steps.set(title, {
title,
status: 'completed',
step_type,
details
});
showNotification(`✓ ${title}`, 'success');
}
if (type.endsWith('.failed')) {
steps.set(title, {
title,
status: 'failed',
step_type,
error
});
showNotification(`✗ ${title}: ${error}`, 'error');
}
// Update step list UI
renderStepList(Array.from(steps.values()));
}Render steps as a progress list:
function StepList({ steps }: { steps: Step[] }) {
return (
<div className="space-y-2">
{steps.map((step) => (
<div key={step.title} className="flex items-center gap-2">
{step.status === 'in_progress' && (
<Spinner className="w-4 h-4 text-blue-500" />
)}
{step.status === 'completed' && (
<CheckIcon className="w-4 h-4 text-green-500" />
)}
{step.status === 'failed' && (
<XIcon className="w-4 h-4 text-red-500" />
)}
<span className={step.status === 'failed' ? 'text-red-600' : ''}>
{step.title}
</span>
{step.error && (
<Tooltip content={step.error}>
<InfoIcon className="w-4 h-4 text-red-400" />
</Tooltip>
)}
</div>
))}
</div>
);
}Users see a live-updating checklist of what the agent is doing.
Progress Indicators
For operations with known steps, show progress percentage:
function handleProgressEvent(type: string, data: any) {
const { progress, current, total, step } = data;
// Update progress bar
updateProgressBar(progress);
// Update step counter
updateStepCounter(current, total);
// Show current step
showMessage(`Step ${current} of ${total}: ${step}`);
}Render as a progress bar:
function ProgressBar({ progress, current, total, step }: ProgressBarProps) {
return (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{step}</span>
<span>{current} / {total}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<div className="text-xs text-gray-500 text-center">
{Math.round(progress)}% complete
</div>
</div>
);
}Error Notifications
Show errors immediately when they occur:
function handleError(type: string, data: any) {
const { error, file_path, command, details } = data;
// Extract operation from type
const operation = type.split('.').slice(1, -1).join(' ');
// Build error message
let message = `Error during ${operation}`;
if (file_path) message += ` on ${file_path}`;
if (command) message += ` running "${command}"`;
message += `: ${error}`;
// Show notification
showNotification(message, 'error', {
duration: 10000, // Errors stay longer
dismissible: true,
actions: [
{
label: 'Copy Error',
onClick: () => navigator.clipboard.writeText(error)
},
details && {
label: 'View Details',
onClick: () => showErrorModal(error, details)
}
].filter(Boolean)
});
// Log to error tracking
logError({
type,
error,
context: { file_path, command, details }
});
}Error notifications include:
- What operation failed
- What resource it was operating on
- The actual error message
- Actions (copy error, view details)
Event Filtering Patterns
Filter events by patterns to route them efficiently:
function handleEvent(event: AgentEvent) {
const { type, data } = event;
// File operations (any lifecycle stage)
if (type.includes('.create_file.') ||
type.includes('.edit_file.') ||
type.includes('.delete_file.') ||
type.includes('.read_file.')) {
handleFileOperation(type, data);
return;
}
// Terminal/shell operations
if (type.includes('.run_shell_command.') ||
type.includes('.run_terminal_command.')) {
handleTerminalOperation(type, data);
return;
}
// Search operations
if (type.includes('.glob_search.') ||
type.includes('.grep_search.') ||
type.includes('.list_dir.')) {
handleSearchOperation(type, data);
return;
}
// Step-based progress
if (type.includes('.step.')) {
handleStepProgress(type, data);
return;
}
// Sub-agent orchestration
if (type.includes('.use_database_agent.') ||
type.includes('.use_auth_agent.') ||
type.includes('.use_payments_agent.')) {
handleSubAgentOperation(type, data);
return;
}
// Web research
if (type.includes('.web_research.') ||
type.includes('.web_search.')) {
handleWebResearch(type, data);
return;
}
// Catch-all for errors
if (type.endsWith('.error')) {
handleError(type, data);
return;
}
}This routing keeps event handling organized as you support more operations.
File Tree Updates
Update the file tree in real-time as operations complete:
interface FileTreeNode {
path: string;
type: 'file' | 'directory';
children?: FileTreeNode[];
status?: 'loading' | 'created' | 'modified' | 'deleted' | 'error';
}
function updateFileTree(filePath: string, update: Partial<FileTreeNode>) {
const tree = getFileTree();
const node = findNodeByPath(tree, filePath);
if (node) {
// Update existing node
Object.assign(node, update);
} else {
// Create new node
const newNode: FileTreeNode = {
path: filePath,
type: 'file',
...update
};
// Insert into tree at correct location
insertNode(tree, newNode);
}
// Re-render tree
renderFileTree(tree);
}
function highlightFileInTree(filePath: string, status: string) {
updateFileTree(filePath, { status: status as any });
// Remove highlight after delay
setTimeout(() => {
updateFileTree(filePath, { status: undefined });
}, 3000);
}Visual states:
- Loading: Spinner icon
- Created: Checkmark (briefly), then normal file icon
- Modified: Dot indicator, highlight background
- Error: X icon, red background
- Deleted: Fade out animation, then remove from tree
Terminal Output Display
Show terminal command output in a console view:
interface TerminalOutput {
command: string;
output: string;
status: 'running' | 'completed' | 'error';
timestamp: Date;
}
const terminalHistory: TerminalOutput[] = [];
function handleTerminalOperation(type: string, data: any) {
const { command, output, error } = data;
if (type.endsWith('.started')) {
terminalHistory.push({
command,
output: '',
status: 'running',
timestamp: new Date()
});
renderTerminal();
}
if (type.endsWith('.completed')) {
const entry = terminalHistory.find(e => e.command === command && e.status === 'running');
if (entry) {
entry.output = output || '';
entry.status = error ? 'error' : 'completed';
}
renderTerminal();
}
}Render as a terminal-style display:
function Terminal({ history }: { history: TerminalOutput[] }) {
return (
<div className="bg-gray-900 text-green-400 font-mono text-sm p-4 rounded">
{history.map((entry, i) => (
<div key={i} className="mb-4">
<div className="flex items-center gap-2">
<span className="text-gray-500">$</span>
<span>{entry.command}</span>
{entry.status === 'running' && (
<Spinner className="w-3 h-3 ml-2" />
)}
</div>
{entry.output && (
<pre className={entry.status === 'error' ? 'text-red-400' : ''}>
{entry.output}
</pre>
)}
</div>
))}
</div>
);
}Web Research Display
Web research emits streaming chunks as the agent searches:
let researchBuffer = '';
function handleWebResearch(type: string, data: any) {
if (type.endsWith('.streaming')) {
const { chunk } = data;
// Accumulate chunks
researchBuffer += chunk;
// Update research panel with streaming text
updateResearchPanel(researchBuffer);
}
if (type.endsWith('.step.started')) {
const { title } = data;
showResearchStep(title, 'in_progress');
}
if (type.endsWith('.step.completed')) {
const { title } = data;
showResearchStep(title, 'completed');
}
if (type.endsWith('.completed')) {
// Finalize research display
finalizeResearchPanel(researchBuffer);
researchBuffer = '';
}
}The research panel shows live text streaming like a chat interface, with step indicators showing progress through research phases.
Cancellation Handling
Let users cancel long-running operations:
let eventSource: EventSource | null = null;
function startAgent(projectId: string, message: string) {
eventSource = new EventSource(
`/api/agents/${projectId}/chat?message=${encodeURIComponent(message)}`
);
eventSource.onmessage = (e) => {
const event = JSON.parse(e.data);
handleEvent(event);
};
// Show cancel button
showCancelButton();
}
function cancelAgent() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
// Send cancellation request to backend
fetch(`/api/agents/${projectId}/cancel`, {
method: 'POST'
});
// Update UI
hideCancelButton();
showNotification('Operation cancelled', 'info');
hideLoadingIndicators();
}The backend handles cancellation through AbortSignal, stopping tool execution and cleaning up resources.
Reconnection Handling
Handle connection drops gracefully:
let reconnectAttempts = 0;
const maxReconnectAttempts = 3;
eventSource.onerror = (error) => {
console.error('SSE error:', error);
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
showNotification(
`Connection lost. Reconnecting... (attempt ${reconnectAttempts})`,
'warning'
);
// EventSource automatically reconnects
// Just track attempts and show UI feedback
} else {
eventSource?.close();
showNotification(
'Connection lost. Please refresh the page.',
'error',
{ duration: null } // Don't auto-dismiss
);
}
};
eventSource.onopen = () => {
// Connection established/re-established
reconnectAttempts = 0;
if (reconnectAttempts > 0) {
showNotification('Reconnected successfully', 'success');
}
};Complete Example
Here's a full implementation tying it all together:
import { useEffect, useState } from 'react';
interface UseAgentStreamProps {
projectId: string;
message: string;
}
function useAgentStream({ projectId, message }: UseAgentStreamProps) {
const [events, setEvents] = useState<AgentEvent[]>([]);
const [status, setStatus] = useState<'idle' | 'connecting' | 'streaming' | 'complete' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!projectId || !message) return;
setStatus('connecting');
const eventSource = new EventSource(
`/api/agents/${projectId}/chat?message=${encodeURIComponent(message)}`
);
eventSource.onopen = () => {
setStatus('streaming');
};
eventSource.onmessage = (e) => {
const event = JSON.parse(e.data);
setEvents((prev) => [...prev, event]);
if (event.type === 'complete') {
setStatus('complete');
eventSource.close();
}
};
eventSource.onerror = (err) => {
console.error('SSE error:', err);
setError('Connection error');
setStatus('error');
eventSource.close();
};
return () => {
eventSource.close();
};
}, [projectId, message]);
return { events, status, error };
}
function AgentInterface({ projectId, message }: UseAgentStreamProps) {
const { events, status, error } = useAgentStream({ projectId, message });
return (
<div className="space-y-4">
{/* Status indicator */}
<div className="flex items-center gap-2">
{status === 'streaming' && <Spinner />}
<span>{getStatusText(status)}</span>
</div>
{/* Event list */}
<div className="space-y-2">
{events.map((event, i) => (
<EventDisplay key={i} event={event} />
))}
</div>
{/* Error display */}
{error && (
<div className="bg-red-50 border border-red-200 p-4 rounded">
<p className="text-red-800">{error}</p>
</div>
)}
</div>
);
}What We're Skipping
Virtual scrolling for massive event lists, complex state management with Redux/Zustand, WebSocket alternatives, offline support with service workers, event replay systems. These add complexity you don't need initially.
The patterns here—EventSource for connection, event routing, UI updates, progress indicators, error handling—cover most use cases. Start simple. Add sophistication when your UX demands it.
What's Next
Your frontend now consumes events and translates them into real-time UI updates. Users see what's happening, progress is visible, and errors are immediate.
Next, we'll look at managing conversation history—how to store messages, handle context limits, and implement features like conversation branching and resume functionality. That's what turns individual interactions into persistent, resumable sessions.