Back to writing

I Built My Own ChatGPT UI and Learned Why UI/UX Engineers Still Have Jobs

TLDR:

  • Spent 3 months building a ChatGPT-style interface from scratch
  • Discovered 7 non-obvious UX patterns that affect user trust
  • Built error handling system that increased user retention by 40%
  • Open-sourced streaming message component library
  • Custom hooks and components available on GitHub

How hard could it be? That's what I thought when I started building my own ChatGPT interface. Three months and 15,000 lines of code later, I've learned that AI interfaces aren't just about pretty chat bubbles — they're about managing human expectations, trust, and anxiety in real-time.

The Trust Patterns

Here's the most surprising discovery: tiny UI details directly impact how much users trust the AI. Let's look at some code:

1. The Thinking State



// Dynamic thinking indicator that adapts to response time
function ThinkingIndicator({ responseTime, confidence }: ThinkingIndicatorProps) {
  const [dots, setDots] = useState('...');
  const [showSubtext, setShowSubtext] = useState(false);
  
  useEffect(() => {
    // Show "thinking deeply" for longer responses
    if (responseTime > 5000) {
      setShowSubtext(true);
    }
    
    // Dynamic dot animation speed based on confidence
    const speed = confidence > 0.8 ? 300 : 500;
    
    const interval = setInterval(() => {
      setDots(d => d.length >= 3 ? '.' : d + '.');
    }, speed);
    
    return () => clearInterval(interval);
  }, [responseTime, confidence]);

  return (
    <div className="flex flex-col items-start gap-2">
      <div className="flex items-center gap-2">
        <span className="text-sm font-medium">Thinking{dots}</span>
        {confidence > 0.9 && <SparklesIcon />}
      </div>
      {showSubtext && (
        <span className="text-xs text-zinc-500">
          Analyzing your request carefully...
        </span>
      )}
    </div>
  );
}

2. The Error Recovery System

// Error boundary that maintains conversation context
class AIErrorBoundary extends React.Component {
  state = { error: null, errorInfo: null };

  componentDidCatch(error, errorInfo) {
    // Analyze error type and suggest recovery
    const recovery = this.analyzeError(error);
    
    this.setState({
      error,
      errorInfo,
      recoveryOptions: recovery.options,
      autoRecoveryPossible: recovery.canAutoRecover
    });
    
    if (recovery.canAutoRecover) {
      this.attemptAutoRecovery(recovery.strategy);
    }
  }
  
  analyzeError(error) {
    if (error.code === 'TOKEN_EXPIRED') {
      return {
        canAutoRecover: true,
        strategy: 'refresh',
        options: ['Continue conversation', 'Start new']
      };
    }
    
    if (error.code === 'CONTEXT_OVERFLOW') {
      return {
        canAutoRecover: true,
        strategy: 'summarize',
        options: ['Summarize history', 'Start new']
      };
    }
    
    return {
      canAutoRecover: false,
      options: ['Try again', 'Start new']
    };
  }

  render() {
    if (this.state.error) {
      return (
        <ErrorRecoveryUI 
          error={this.state.error}
          options={this.state.recoveryOptions}
          onSelect={this.handleRecovery}
        />
      );
    }
    
    return this.props.children;
  }
}

The Streaming Message Component

import { useState, useEffect, useRef } from 'react';



export function StreamingMessage({
  content,
  streamingSpeed = 30,
  onComplete
}: StreamingMessageProps) {
  const [displayedContent, setDisplayedContent] = useState('');
  const [isComplete, setIsComplete] = useState(false);
  const contentRef = useRef(content);
  
  useEffect(() => {
    let currentIndex = 0;
    
    // Dynamic speed based on content type
    const getDelay = (char: string, nextChar: string) => {
      if (char === '.' && nextChar === ' ') return 350;
      if (char === ',' && nextChar === ' ') return 200;
      if (char === '
') return 100;
      return streamingSpeed;
    };
    
    const stream = setInterval(() => {
      if (currentIndex < contentRef.current.length) {
        const char = contentRef.current[currentIndex];
        const nextChar = contentRef.current[currentIndex + 1];
        
        setDisplayedContent(prev => prev + char);
        currentIndex++;
        
        // Adjust interval for next character
        const nextDelay = getDelay(char, nextChar);
        if (nextDelay !== streamingSpeed) {
          clearInterval(stream);
          setTimeout(() => stream, nextDelay);
        }
      } else {
        clearInterval(stream);
        setIsComplete(true);
        onComplete?.();
      }
    }, streamingSpeed);
    
    return () => clearInterval(stream);
  }, [streamingSpeed, onComplete]);
  
  return (
    <div className="relative">
      <div className="prose dark:prose-invert">
        {displayedContent}
        {!isComplete && (
          <span className="animate-pulse">▊</span>
        )}
      </div>
      {isComplete && (
        <div className="absolute -right-6 top-0">
          <CopyButton content={content} />
        </div>
      )}
    </div>
  );
}

The Results

// Metrics from production
const userMetrics = {
  retention: {
    beforeErrorSystem: '45%',
    afterErrorSystem: '85%',
    improvement: '40%'
  },
  satisfaction: {
    beforeStreamingOptimization: 7.2,
    afterStreamingOptimization: 8.9,
    improvement: '23.6%'
  },
  trustScore: {
    withBasicUI: 6.5,
    withEnhancedPatterns: 8.8,
    improvement: '35.4%'
  }
};

Future Work

The code is open source and I'd love to see what others build with it. The most important lesson? UX engineers aren't just making things pretty — they're building trust interfaces between humans and increasingly complex AI systems. That's a job that won't be replaced by AI anytime soon.