Refactor ChatInterface and MicButton components for improved scroll behavior and microphone support. Adjusted auto-scroll thresholds, added error handling for microphone access, and hid unused UI elements.

This commit is contained in:
simos
2025-07-08 13:48:33 +00:00
parent fca741ab3f
commit 27f34db777
4 changed files with 99 additions and 38 deletions

View File

@@ -1112,15 +1112,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const isNearBottom = useCallback(() => { const isNearBottom = useCallback(() => {
if (!scrollContainerRef.current) return false; if (!scrollContainerRef.current) return false;
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
// Consider "near bottom" if within 100px of the bottom // Consider "near bottom" if within 50px of the bottom
return scrollHeight - scrollTop - clientHeight < 100; return scrollHeight - scrollTop - clientHeight < 50;
}, []); }, []);
// Handle scroll events to detect when user manually scrolls up // Handle scroll events to detect when user manually scrolls up
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
if (scrollContainerRef.current) { if (scrollContainerRef.current) {
const wasNearBottom = isNearBottom(); const nearBottom = isNearBottom();
setIsUserScrolledUp(!wasNearBottom); setIsUserScrolledUp(!nearBottom);
} }
}, [isNearBottom]); }, [isNearBottom]);
@@ -1540,13 +1540,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}); });
useEffect(() => { useEffect(() => {
// Only auto-scroll to bottom when new messages arrive if: // Auto-scroll to bottom when new messages arrive
// 1. Auto-scroll is enabled in settings
// 2. User hasn't manually scrolled up
if (scrollContainerRef.current && chatMessages.length > 0) { if (scrollContainerRef.current && chatMessages.length > 0) {
if (autoScrollToBottom) { if (autoScrollToBottom) {
// If auto-scroll is enabled, always scroll to bottom unless user has manually scrolled up
if (!isUserScrolledUp) { if (!isUserScrolledUp) {
setTimeout(() => scrollToBottom(), 0); setTimeout(() => scrollToBottom(), 50); // Small delay to ensure DOM is updated
} }
} else { } else {
// When auto-scroll is disabled, preserve the visual position // When auto-scroll is disabled, preserve the visual position
@@ -1564,12 +1563,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
} }
}, [chatMessages.length, isUserScrolledUp, scrollToBottom, autoScrollToBottom]); }, [chatMessages.length, isUserScrolledUp, scrollToBottom, autoScrollToBottom]);
// Scroll to bottom when component mounts with existing messages // Scroll to bottom when component mounts with existing messages or when messages first load
useEffect(() => { useEffect(() => {
if (scrollContainerRef.current && chatMessages.length > 0 && autoScrollToBottom) { if (scrollContainerRef.current && chatMessages.length > 0) {
setTimeout(() => scrollToBottom(), 100); // Small delay to ensure rendering // Always scroll to bottom when messages first load (user expects to see latest)
// Also reset scroll state
setIsUserScrolledUp(false);
setTimeout(() => scrollToBottom(), 200); // Longer delay to ensure full rendering
} }
}, [scrollToBottom, autoScrollToBottom]); }, [chatMessages.length > 0, scrollToBottom]); // Trigger when messages first appear
// Add scroll event listener to detect user scrolling // Add scroll event listener to detect user scrolling
useEffect(() => { useEffect(() => {
@@ -1636,8 +1638,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
can_interrupt: true can_interrupt: true
}); });
// Always scroll to bottom when user sends a message (they're actively participating) // Always scroll to bottom when user sends a message and reset scroll state
setTimeout(() => scrollToBottom(), 0); setIsUserScrolledUp(false); // Reset scroll state so auto-scroll works for Claude's response
setTimeout(() => scrollToBottom(), 100); // Longer delay to ensure message is rendered
// Session Protection: Mark session as active to prevent automatic project updates during conversation // Session Protection: Mark session as active to prevent automatic project updates during conversation
// This is crucial for maintaining chat state integrity. We handle two cases: // This is crucial for maintaining chat state integrity. We handle two cases:
@@ -1882,21 +1885,21 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
{/* Floating scroll to bottom button */}
{isUserScrolledUp && chatMessages.length > 0 && (
<button
onClick={scrollToBottom}
className="absolute bottom-4 right-4 w-10 h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800 z-10"
title="Scroll to bottom"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
)}
</div> </div>
{/* Floating scroll to bottom button - positioned outside scrollable container */}
{isUserScrolledUp && chatMessages.length > 0 && (
<button
onClick={scrollToBottom}
className="fixed bottom-20 sm:bottom-24 right-4 sm:right-6 w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800 z-50"
title="Scroll to bottom"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
)}
{/* Input Area - Fixed Bottom */} {/* Input Area - Fixed Bottom */}
<div className={`p-2 sm:p-4 md:p-6 flex-shrink-0 ${ <div className={`p-2 sm:p-4 md:p-6 flex-shrink-0 ${
isInputFocused ? 'pb-2 sm:pb-4 md:pb-6' : 'pb-16 sm:pb-4 md:pb-6' isInputFocused ? 'pb-2 sm:pb-4 md:pb-6' : 'pb-16 sm:pb-4 md:pb-6'
@@ -1977,8 +1980,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</svg> </svg>
</button> </button>
)} )}
{/* Mic button */} {/* Mic button - HIDDEN */}
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2"> <div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
<MicButton <MicButton
onTranscript={handleTranscript} onTranscript={handleTranscript}
className="w-10 h-10 sm:w-10 sm:h-10" className="w-10 h-10 sm:w-10 sm:h-10"

View File

@@ -568,11 +568,13 @@ function GitPanel({ selectedProject, isMobile }) {
<Sparkles className="w-4 h-4" /> <Sparkles className="w-4 h-4" />
)} )}
</button> </button>
<MicButton <div style={{ display: 'none' }}>
onTranscript={(transcript) => setCommitMessage(transcript)} <MicButton
mode="default" onTranscript={(transcript) => setCommitMessage(transcript)}
className="p-1.5" mode="default"
/> className="p-1.5"
/>
</div>
</div> </div>
</div> </div>
<div className="flex items-center justify-between mt-2"> <div className="flex items-center justify-between mt-2">

View File

@@ -5,13 +5,35 @@ import { transcribeWithWhisper } from '../utils/whisper';
export function MicButton({ onTranscript, className = '' }) { export function MicButton({ onTranscript, className = '' }) {
const [state, setState] = useState('idle'); // idle, recording, transcribing, processing const [state, setState] = useState('idle'); // idle, recording, transcribing, processing
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [isSupported, setIsSupported] = useState(true);
const mediaRecorderRef = useRef(null); const mediaRecorderRef = useRef(null);
const streamRef = useRef(null); const streamRef = useRef(null);
const chunksRef = useRef([]); const chunksRef = useRef([]);
const lastTapRef = useRef(0); const lastTapRef = useRef(0);
// Version indicator to verify updates // Check microphone support on mount
useEffect(() => {
const checkSupport = () => {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
setIsSupported(false);
setError('Microphone not supported. Please use HTTPS or a modern browser.');
return;
}
// Additional check for secure context
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
setIsSupported(false);
setError('Microphone requires HTTPS. Please use a secure connection.');
return;
}
setIsSupported(true);
setError(null);
};
checkSupport();
}, []);
// Start recording // Start recording
const startRecording = async () => { const startRecording = async () => {
@@ -20,6 +42,11 @@ export function MicButton({ onTranscript, className = '' }) {
setError(null); setError(null);
chunksRef.current = []; chunksRef.current = [];
// Check if getUserMedia is available
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('Microphone access not available. Please use HTTPS or a supported browser.');
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream; streamRef.current = stream;
@@ -79,7 +106,23 @@ export function MicButton({ onTranscript, className = '' }) {
console.log('Recording started successfully'); console.log('Recording started successfully');
} catch (err) { } catch (err) {
console.error('Failed to start recording:', err); console.error('Failed to start recording:', err);
setError('Microphone access denied');
// Provide specific error messages based on error type
let errorMessage = 'Microphone access failed';
if (err.name === 'NotAllowedError') {
errorMessage = 'Microphone access denied. Please allow microphone permissions.';
} else if (err.name === 'NotFoundError') {
errorMessage = 'No microphone found. Please check your audio devices.';
} else if (err.name === 'NotSupportedError') {
errorMessage = 'Microphone not supported by this browser.';
} else if (err.name === 'NotReadableError') {
errorMessage = 'Microphone is being used by another application.';
} else if (err.message.includes('HTTPS')) {
errorMessage = err.message;
}
setError(errorMessage);
setState('idle'); setState('idle');
} }
}; };
@@ -109,6 +152,11 @@ export function MicButton({ onTranscript, className = '' }) {
e.stopPropagation(); e.stopPropagation();
} }
// Don't proceed if microphone is not supported
if (!isSupported) {
return;
}
// Debounce for mobile double-tap issue // Debounce for mobile double-tap issue
const now = Date.now(); const now = Date.now();
if (now - lastTapRef.current < 300) { if (now - lastTapRef.current < 300) {
@@ -138,6 +186,14 @@ export function MicButton({ onTranscript, className = '' }) {
// Button appearance based on state // Button appearance based on state
const getButtonAppearance = () => { const getButtonAppearance = () => {
if (!isSupported) {
return {
icon: <Mic className="w-5 h-5" />,
className: 'bg-gray-400 cursor-not-allowed',
disabled: true
};
}
switch (state) { switch (state) {
case 'recording': case 'recording':
return { return {

View File

@@ -142,8 +142,8 @@ const QuickSettingsPanel = ({
</label> </label>
</div> </div>
{/* Whisper Dictation Settings */} {/* Whisper Dictation Settings - HIDDEN */}
<div className="space-y-2"> <div className="space-y-2" style={{ display: 'none' }}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Whisper Dictation</h4> <h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Whisper Dictation</h4>
<div className="space-y-2"> <div className="space-y-2">