feat: improve UI/UX with volume meter and refine input area styling

- Add VolumeMeter component for realtime audio visualization
- Refactor input area to show volume meter in realtime mode
- Update button styling and layout for cleaner interface
- Improve text input integration with better visual hierarchy
- Add volume state tracking to Speechmatics audio hook
- Add agent working directory validation on creation
- Update agent prompt with better orchestration guidelines

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
Mohamed Boudra
2025-10-22 18:00:59 +02:00
co-authored by Claude Happy
parent de5aba4186
commit e1e14b1473
6 changed files with 658 additions and 141 deletions
+216 -71
View File
@@ -11,11 +11,18 @@ import {
Keyboard,
} from "react-native";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated";
import ReanimatedAnimated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withRepeat,
withSequence,
withTiming,
} from "react-native-reanimated";
import { router } from "expo-router";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { theme as defaultTheme } from "../styles/theme";
// Simple unique ID generator
@@ -39,6 +46,7 @@ import { ArtifactDrawer, type Artifact } from "@/components/artifact-drawer";
import { ActiveProcesses } from "@/components/active-processes";
import { AgentStreamView } from "@/components/agent-stream-view";
import { ConversationSelector } from "@/components/conversation-selector";
import { VolumeMeter } from "@/components/volume-meter";
import {
Settings,
Mic,
@@ -125,6 +133,97 @@ interface AgentUpdate {
notification: SessionNotification;
}
interface RealtimeCircleProps {
volume: number;
onClose: () => void;
}
function RealtimeCircle({ volume, onClose }: RealtimeCircleProps) {
const { theme } = useUnistyles();
// Base size for the circles
const BASE_SIZE = 80;
const MIN_SCALE = 1;
const MAX_SCALE = 1.8;
// Create animated value for volume-based scaling
const volumeScale = useSharedValue(MIN_SCALE);
const pulseScale = useSharedValue(1);
// Update volume scale when volume changes
useEffect(() => {
// Map volume (0-1) to scale range (MIN_SCALE - MAX_SCALE)
const targetScale = MIN_SCALE + (volume * (MAX_SCALE - MIN_SCALE));
volumeScale.value = withSpring(targetScale, {
damping: 15,
stiffness: 150,
});
}, [volume]);
// Continuous pulsating animation for outer circle
useEffect(() => {
pulseScale.value = withRepeat(
withSequence(
withTiming(1.3, { duration: 1000 }),
withTiming(1, { duration: 1000 })
),
-1,
false
);
}, []);
// Animated style for inner circle (volume reactive)
const innerCircleStyle = useAnimatedStyle(() => ({
width: BASE_SIZE,
height: BASE_SIZE,
borderRadius: BASE_SIZE / 2,
transform: [{ scale: volumeScale.value }],
}));
// Animated style for outer circle (continuous pulse)
const outerCircleStyle = useAnimatedStyle(() => ({
width: BASE_SIZE * 1.5,
height: BASE_SIZE * 1.5,
borderRadius: (BASE_SIZE * 1.5) / 2,
transform: [{ scale: pulseScale.value }],
opacity: 0.3,
}));
return (
<Pressable
onPress={onClose}
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<View style={{ position: "relative", alignItems: "center", justifyContent: "center" }}>
{/* Outer pulsating circle */}
<ReanimatedAnimated.View
style={[
outerCircleStyle,
{
position: "absolute",
backgroundColor: theme.colors.palette.blue[600],
},
]}
/>
{/* Inner volume-reactive circle */}
<ReanimatedAnimated.View
style={[
innerCircleStyle,
{
backgroundColor: theme.colors.palette.blue[600],
},
]}
/>
</View>
</Pressable>
);
}
export default function VoiceAssistantScreen() {
const { settings, isLoading: settingsLoading } = useSettings();
const [conversationId, setConversationId] = useState<string | null>(null);
@@ -142,7 +241,7 @@ export default function VoiceAssistantScreen() {
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInset = insets.bottom;
const animatedKeyboardStyle = useAnimatedStyle(() => {
'worklet';
"worklet";
const absoluteHeight = Math.abs(keyboardHeight.value);
const padding = Math.max(0, absoluteHeight - bottomInset);
return {
@@ -1100,64 +1199,95 @@ export default function VoiceAssistantScreen() {
{/* Fixed Footer */}
<View
style={[
styles.inputArea,
styles.inputAreaWrapper,
{ paddingBottom: Math.max(insets.bottom, 8) },
]}
>
{/* Text input */}
<TextInput
value={userInput}
onChangeText={setUserInput}
placeholder="Say something..."
placeholderTextColor={defaultTheme.colors.mutedForeground}
style={styles.textInput}
multiline
editable={!isRecording && ws.isConnected}
/>
{/* Buttons */}
<View style={styles.buttonRow}>
{/* Realtime mode button */}
{isRealtimeMode ? (
// Realtime mode - show volume meter
<Pressable
onPress={handleRealtimeToggle}
disabled={!ws.isConnected}
style={[
styles.realtimeButton,
!ws.isConnected && styles.buttonDisabled,
isRealtimeMode && styles.realtimeButtonActive,
]}
style={[styles.inputArea, { minHeight: 200, justifyContent: "center" }]}
>
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
<AudioLines size={20} color="white" />
</Animated.View>
<VolumeMeter volume={realtimeAudio.volume} />
</Pressable>
) : (
// Normal mode - show text input and buttons
<View style={styles.inputArea}>
{/* Text input */}
<TextInput
value={userInput}
onChangeText={setUserInput}
placeholder="Say something..."
placeholderTextColor={defaultTheme.colors.mutedForeground}
style={styles.textInput}
multiline
editable={!isRecording && ws.isConnected}
/>
{/* Main action button */}
<Pressable
onPress={handleButtonClick}
disabled={!ws.isConnected}
style={[
styles.mainButton,
!ws.isConnected && styles.buttonDisabled,
isRecording && styles.mainButtonRecording,
isInProgress && styles.mainButtonInProgress,
userInput.trim() &&
!isRecording &&
!isInProgress &&
styles.mainButtonWithText,
]}
>
{isInProgress ? (
<Square size={18} color="white" fill="white" />
) : isRecording ? (
<Square size={14} color="white" fill="white" />
) : userInput.trim() ? (
<ArrowUp size={20} color="white" />
) : (
<Mic size={20} color="white" />
)}
</Pressable>
</View>
{/* Buttons */}
<View style={styles.buttonRow}>
{userInput.trim().length > 0 ? (
// Send button when text is entered
<Pressable
onPress={handleSendMessage}
disabled={!ws.isConnected || isInProgress}
style={[
styles.sendButton,
!ws.isConnected && styles.buttonDisabled,
]}
>
<ArrowUp size={20} color="white" />
</Pressable>
) : (
// Record and Realtime buttons when no text
<>
{/* Main action button */}
<Pressable
onPress={handleButtonClick}
disabled={!ws.isConnected}
style={[
styles.mainButton,
!ws.isConnected && styles.buttonDisabled,
isRecording && styles.mainButtonRecording,
isInProgress && styles.mainButtonInProgress,
]}
>
{isInProgress ? (
<Square size={18} color="white" fill="white" />
) : isRecording ? (
<Square size={14} color="white" fill="white" />
) : (
<Mic size={20} color={defaultTheme.colors.foreground} />
)}
</Pressable>
{/* Realtime mode button */}
<Pressable
onPress={handleRealtimeToggle}
disabled={!ws.isConnected}
style={[
styles.realtimeButton,
!ws.isConnected && styles.buttonDisabled,
isRealtimeMode && styles.realtimeButtonActive,
]}
>
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
<AudioLines
size={20}
color={
isRealtimeMode
? defaultTheme.colors.accentForeground
: defaultTheme.colors.background
}
/>
</Animated.View>
</Pressable>
</>
)}
</View>
</View>
)}
</View>
</ReanimatedAnimated.View>
@@ -1221,51 +1351,66 @@ const styles = StyleSheet.create((theme) => ({
paddingBottom: theme.spacing[4],
flexGrow: 1,
},
inputArea: {
paddingTop: theme.spacing[4],
paddingHorizontal: theme.spacing[6],
inputAreaWrapper: {
borderTopRightRadius: theme.borderRadius["2xl"],
borderTopLeftRadius: theme.borderRadius["2xl"],
paddingTop: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
backgroundColor: theme.colors.background,
backgroundColor: theme.colors.muted,
},
inputArea: {
borderRadius: theme.borderRadius["2xl"],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[3],
},
textInput: {
backgroundColor: theme.colors.muted,
backgroundColor: "transparent",
color: theme.colors.foreground,
borderRadius: theme.borderRadius["2xl"],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
marginBottom: theme.spacing[4],
paddingHorizontal: 0,
paddingVertical: 0,
fontSize: theme.fontSize.lg,
marginBottom: theme.spacing[3],
maxHeight: 128,
},
buttonRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
gap: theme.spacing[3],
marginBottom: theme.spacing[2],
gap: theme.spacing[2],
marginBottom: theme.spacing[1],
},
realtimeButton: {
width: 48,
height: 48,
width: 40,
height: 40,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.muted,
backgroundColor: theme.colors.accentForeground,
},
realtimeButtonActive: {
backgroundColor: theme.colors.palette.blue[600],
backgroundColor: theme.colors.foreground,
},
mainButton: {
width: 48,
height: 48,
width: 40,
height: 40,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.secondary,
backgroundColor: "transparent",
},
mainButtonRecording: {
backgroundColor: theme.colors.palette.red[500],
},
sendButton: {
width: 40,
height: 40,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.palette.blue[600],
},
mainButtonInProgress: {
backgroundColor: theme.colors.palette.red[600],
},
@@ -0,0 +1,176 @@
import { useEffect } from "react";
import { View } from "react-native";
import ReanimatedAnimated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
withTiming,
Easing,
} from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
interface VolumeMeterProps {
volume: number;
}
export function VolumeMeter({ volume }: VolumeMeterProps) {
const { theme } = useUnistyles();
// Base dimensions
const LINE_SPACING = 8;
const MAX_HEIGHT = 80;
const MIN_HEIGHT = 4;
// Shared values for each line's height
const line1Height = useSharedValue(MIN_HEIGHT);
const line2Height = useSharedValue(MIN_HEIGHT);
const line3Height = useSharedValue(MIN_HEIGHT);
// Idle pulse animations (when no volume)
const line1Pulse = useSharedValue(1);
const line2Pulse = useSharedValue(1);
const line3Pulse = useSharedValue(1);
// Start idle animations with different phases
useEffect(() => {
// Line 1 - fastest pulse
line1Pulse.value = withRepeat(
withSequence(
withTiming(1.2, { duration: 800, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) })
),
-1,
false
);
// Line 2 - medium pulse with offset
line2Pulse.value = withRepeat(
withSequence(
withTiming(1, { duration: 400 }),
withTiming(1.15, { duration: 1000, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 1000, easing: Easing.inOut(Easing.ease) })
),
-1,
false
);
// Line 3 - slowest pulse with different offset
line3Pulse.value = withRepeat(
withSequence(
withTiming(1, { duration: 600 }),
withTiming(1.25, { duration: 1200, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 1200, easing: Easing.inOut(Easing.ease) })
),
-1,
false
);
}, []);
// Update heights based on volume with different responsiveness per line
useEffect(() => {
if (volume > 0.01) {
// Active volume - animate heights based on volume
// Line 1 - most responsive, follows volume closely
const target1 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.2);
line1Height.value = withSpring(target1, {
damping: 10,
stiffness: 200,
});
// Line 2 - medium responsiveness
const target2 = MIN_HEIGHT + (MAX_HEIGHT * volume * 0.9);
line2Height.value = withSpring(target2, {
damping: 12,
stiffness: 150,
});
// Line 3 - smoothest, lags behind
const target3 = MIN_HEIGHT + (MAX_HEIGHT * volume * 0.7);
line3Height.value = withSpring(target3, {
damping: 15,
stiffness: 100,
});
} else {
// No volume - return to minimum
line1Height.value = withSpring(MIN_HEIGHT, {
damping: 20,
stiffness: 150,
});
line2Height.value = withSpring(MIN_HEIGHT, {
damping: 20,
stiffness: 150,
});
line3Height.value = withSpring(MIN_HEIGHT, {
damping: 20,
stiffness: 150,
});
}
}, [volume]);
// Animated styles for each line
const line1Style = useAnimatedStyle(() => ({
height: line1Height.value * (volume > 0.01 ? 1 : line1Pulse.value),
opacity: 0.8 + (volume * 0.2),
}));
const line2Style = useAnimatedStyle(() => ({
height: line2Height.value * (volume > 0.01 ? 1 : line2Pulse.value),
opacity: 0.7 + (volume * 0.3),
}));
const line3Style = useAnimatedStyle(() => ({
height: line3Height.value * (volume > 0.01 ? 1 : line3Pulse.value),
opacity: 0.6 + (volume * 0.4),
}));
return (
<View style={styles.container}>
<ReanimatedAnimated.View
style={[
styles.line,
{
width: 4,
backgroundColor: theme.colors.palette.blue[400],
},
line1Style,
]}
/>
<View style={{ width: LINE_SPACING }} />
<ReanimatedAnimated.View
style={[
styles.line,
{
width: 6,
backgroundColor: theme.colors.palette.blue[500],
},
line2Style,
]}
/>
<View style={{ width: LINE_SPACING }} />
<ReanimatedAnimated.View
style={[
styles.line,
{
width: 4,
backgroundColor: theme.colors.palette.blue[400],
},
line3Style,
]}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
height: 100,
},
line: {
borderRadius: theme.borderRadius.full,
},
}));
@@ -25,6 +25,7 @@ export interface SpeechmaticsAudio {
isActive: boolean;
isSpeaking: boolean;
isDetecting: boolean;
volume: number;
}
/**
@@ -129,6 +130,7 @@ export function useSpeechmaticsAudio(
const [isSpeaking, setIsSpeaking] = useState(false);
const [isDetecting, setIsDetecting] = useState(false);
const [audioInitialized, setAudioInitialized] = useState(false);
const [volume, setVolume] = useState(0);
const audioBufferRef = useRef<Uint8Array[]>([]);
const silenceStartRef = useRef<number | null>(null);
@@ -188,17 +190,18 @@ export function useSpeechmaticsAudio(
(event) => {
if (!isActive) return;
const volume: number = event.data;
const speechDetected = volume > VOLUME_THRESHOLD;
const volumeLevel: number = event.data;
setVolume(volumeLevel);
const speechDetected = volumeLevel > VOLUME_THRESHOLD;
// console.log('[SpeechmaticsAudio] Volume:', volume.toFixed(6));
// console.log('[SpeechmaticsAudio] Volume:', volumeLevel.toFixed(6), 'Threshold:', VOLUME_THRESHOLD);
if (speechDetected && !isSpeakingRef.current && !speechConfirmedRef.current) {
// Initial speech detection - start tracking
if (speechDetectionStartRef.current === null) {
console.log(
"[SpeechmaticsAudio] Speech started (volume:",
volume.toFixed(4),
volumeLevel.toFixed(4),
")"
);
speechDetectionStartRef.current = Date.now();
@@ -329,6 +332,7 @@ export function useSpeechmaticsAudio(
setIsActive(false);
setIsSpeaking(false);
setIsDetecting(false);
setVolume(0);
console.log("[SpeechmaticsAudio] Audio capture stopped");
}
@@ -348,5 +352,6 @@ export function useSpeechmaticsAudio(
isActive,
isSpeaking,
isDetecting,
volume,
};
}