Yuval Avidani
Author
Designing for Uncertainty
When you design a standard form, you know exactly what can go wrong. A field is empty. An email is invalid. A number is out of range. The error states are finite, predictable, and testable.
When you design for an AI Agent, you have no idea what it might output:
- It might hallucinate facts with complete confidence
- It might refuse to answer for opaque reasons
- It might write a poem instead of JSON
- It might loop endlessly attempting and failing
- It might produce something 90% correct with a critical error in the remaining 10%
This matters to all of us building AI-powered products because the interface is the trust surface. Users can't see the model, can't understand the inference, can't verify the training data. They experience the UI. If your interface fails to communicate uncertainty, handle failures gracefully, or enable correction, even a capable AI becomes unusable.
The Trust Battery Concept
Think of user trust as a battery. Every successful interaction charges it. Every unexpected failure, hallucination, or confusing response drains it. Your UI design directly controls the charging and draining rates.
Trust Chargers:
- Accurate responses that meet expectations
- Clear communication of limitations
- Easy correction when AI is wrong
- Consistent behavior over time
- Transparency about what the AI can and cannot do
Trust Drainers:
- Confident-sounding hallucinations
- Unexplained failures or refusals
- Inconsistent behavior for similar inputs
- Hidden limitations that surprise users
- Inability to correct or guide the AI
Your interface design determines how gracefully you handle both scenarios.
Core Pattern 1: Streaming UI for Transparency
Streaming responses serve multiple purposes beyond perceived speed:
Show Thinking, Not Just Results
function AIResponse({ stream }: { stream: AsyncIterable }) {
const [content, setContent] = useState('');
const [status, setStatus] = useState<'thinking' | 'generating' | 'done'>('thinking');
useEffect(() => {
async function consume() {
setStatus('thinking');
// Show thinking indicator
await delay(500);
setStatus('generating');
for await (const chunk of stream) {
setContent(prev => prev + chunk);
}
setStatus('done');
}
consume();
}, [stream]);
return (
{status === 'thinking' && (
Analyzing your request...
)}
{status === 'generating' && }
{status === 'done' && (
)}
);
}
Why this matters:
- "Thinking" phase sets expectations (not instant)
- Character-by-character generation shows active work
- Users can read while generation happens
- Clear transition to "done" state with action options
Progressive Enhancement
Stream additional context after the main response:
function EnhancedResponse({ response, citations }) {
return (
{/ Main content streams first /}
{/ Citations load after main content /}
}>
{/ Related suggestions load last /}
}>
);
}
Users get the answer immediately, with supporting information loading progressively.
Core Pattern 2: User Correction as First-Class Feature
AI will be wrong. Design for easy correction, not as an edge case but as a core workflow.
Inline Editing
function EditableAIOutput({ content, onUpdate }) {
const [isEditing, setIsEditing] = useState(false);
const [editedContent, setEditedContent] = useState(content);
const handleSave = async () => {
await onUpdate(editedContent);
setIsEditing(false);
// Track correction for model improvement
trackCorrection({
original: content,
corrected: editedContent,
type: 'user_edit'
});
};
return (
{isEditing ? (
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
/>
) : (
{content}
className="edit-trigger"
onClick={() => setIsEditing(true)}
>
Edit
)}
);
}
Partial Acceptance
Let users accept parts of a response and regenerate others:
function SegmentedResponse({ segments }) {
const [acceptedSegments, setAcceptedSegments] = useState({});
return (
{segments.map((segment, i) => (
key={i}
className={segment ${acceptedSegments[i] ? 'accepted' : ''}}
>
{segment.content}
))}
);
}
Feedback Loops
Every AI interaction should offer feedback options:
function FeedbackButtons({ responseId }) {
const [feedback, setFeedback] = useState(null);
const submitFeedback = async (type: 'good' | 'bad', details?: string) => {
await api.submitFeedback({ responseId, type, details });
setFeedback(type);
};
if (feedback) {
return Thanks for your feedback!;
}
return (
onClick={() => submitFeedback('good')}
aria-label="This was helpful"
>
onClick={() => openDetailedFeedback('bad')}
aria-label="This wasn't helpful"
>
);
}
Negative feedback should prompt for more detail - what was wrong helps improve the system.
Core Pattern 3: Citation and Source Display
Grounding AI responses in verifiable sources builds trust and enables verification.
Inline Citations
function CitedContent({ content, citations }) {
const citedContent = parseContentWithCitations(content);
return (
{citedContent.map((segment, i) => (
segment.type === 'text' ? (
{segment.content}
) : (
key={i}
citation={citations[segment.citationId]}
/>
)
))}
Sources
{citations.map((citation, i) => (
))}
);
}
function CitationReference({ citation }) {
const [showPreview, setShowPreview] = useState(false);
return (
className="citation-ref"
onMouseEnter={() => setShowPreview(true)}
onMouseLeave={() => setShowPreview(false)}
>
[{citation.id}]
{showPreview && (
)}
);
}
Confidence Indicators
When appropriate, show how confident the AI is:
function ConfidenceIndicator({ confidence }: { confidence: number }) {
const level =
confidence > 0.9 ? 'high' :
confidence > 0.7 ? 'medium' :
confidence > 0.5 ? 'low' : 'very-low';
const labels = {
'high': 'High confidence',
'medium': 'Moderate confidence - verify important details',
'low': 'Low confidence - please verify',
'very-low': 'Very uncertain - treat as starting point only'
};
return (
confidence-indicator ${level}}>
{labels[level]}
);
}
Core Pattern 4: Graceful Failure States
AI fails in unique ways. Design for each failure mode:
Refusal Handling
function RefusalState({ reason, alternatives }) {
return (
I can't help with this request
{reason}
{alternatives.length > 0 && (
Here are some things I can help with instead:
{alternatives.map((alt, i) => (
))}
)}
);
}
Hallucination Warnings
When the AI generates information that can't be verified:
function UnverifiedContent({ content, canVerify }) {
return (
{!canVerify && (
This response includes information I couldn't verify.
Please double-check important facts.
)}
{content}
Was this information accurate?
);
}
Timeout and Error States
function AIError({ error, onRetry }) {
const errorMessages = {
'timeout': {
title: 'This is taking longer than expected',
message: 'The request is still processing. You can wait or try again.',
actions: ['wait', 'retry', 'simplify']
},
'overloaded': {
title: 'High demand right now',
message: 'Please try again in a moment.',
actions: ['retry']
},
'context_too_long': {
title: 'Too much information at once',
message: 'Try breaking your request into smaller parts.',
actions: ['simplify']
},
'unknown': {
title: 'Something went wrong',
message: 'We encountered an unexpected issue.',
actions: ['retry', 'contact_support']
}
};
const { title, message, actions } = errorMessages[error.type] || errorMessages.unknown;
return (
{title}
{message}
{actions.includes('retry') && (
)}
{actions.includes('simplify') && (
)}
{actions.includes('wait') && (
)}
);
}
Core Pattern 5: Human-in-the-Loop Workflows
For high-stakes AI actions, build approval workflows:
Preview and Confirm
function AIActionPreview({ action, onConfirm, onCancel }) {
return (
Review before executing
The AI wants to:
{action.description}
This will affect:
{action.affectedItems.map((item, i) => (
- {item}
))}
{action.reversible ? (
This can be undone
) : (
This cannot be undone
)}
className="confirm-button"
onClick={onConfirm}
>
Confirm and execute
className="cancel-button"
onClick={onCancel}
>
Cancel
);
}
Staged Execution
For complex operations, show progress and allow intervention:
function StagedExecution({ stages }) {
return (
{stages.map((stage, i) => (
stage ${stage.status}}>
{stage.name}
{stage.status === 'in_progress' && (
)}
{stage.status === 'awaiting_approval' && (
{stage.approvalMessage}
)}
{stage.status === 'completed' && stage.result && (
{stage.result.summary}
)}
))}
);
}
Design Principles for AI UX
1. Set Expectations Early
Before users interact with AI, tell them what to expect:
- What the AI can and cannot do
- What kinds of inputs work best
- When to trust output versus verify
- How to get help when AI fails
2. Make the AI's State Visible
Users should always know:
- Is the AI working or idle?
- How far along is a long operation?
- Did something fail or succeed?
- What can be done next?
3. Preserve User Agency
The user is in control, not the AI:
- Always provide escape hatches
- Never force users to accept AI output
- Make correction as easy as generation
- Allow users to override AI decisions
4. Design for Error Recovery
Every failure mode needs a recovery path:
- Clear error messages (not technical jargon)
- Suggested next steps
- Easy retry mechanisms
- Fallback to human assistance
5. Build Trust Gradually
Start with low-stakes, high-visibility operations:
- Show AI work before it executes
- Require confirmation for impactful actions
- Track and display accuracy over time
- Let users adjust AI autonomy based on trust
My Take: UX Is the Bottleneck
In my opinion, AI capability is outpacing AI UX. We have models that can do remarkable things, but interfaces that fail to communicate those capabilities, handle failures gracefully, or build user trust.
The teams that win in AI products won't just have the best models - they'll have interfaces that make those models usable, trustworthy, and delightful. Design for uncertainty. Build for correction. Communicate confidence. Show your work.
The AI can be brilliant, but if users don't trust it, don't understand it, and can't correct it - it doesn't matter. UX is the bottleneck.
Design accordingly.
