Improve dictation UX: transcribe-only mode with UI fixes

- Dictation now appends transcribed text to input instead of auto-sending
- Voice button stays visible even with attachments or existing text
- Dictation works on create agent screen (not just agent chat)
- Input auto-grows when dictation appends text
- New agent screen input has solid background (no transparency)
- Timer preserves duration after confirming (no scary reset to zero)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-12-29 11:24:15 +07:00
co-authored by Claude Opus 4.5
parent 09d4acce4c
commit 0cb8bbcf62
3 changed files with 79 additions and 70 deletions
+13 -8
View File
@@ -972,14 +972,16 @@ export default function DraftAgentScreen() {
</View> </View>
) : null} ) : null}
</ScrollView> </ScrollView>
<AgentInputArea <View style={styles.inputAreaWrapper}>
agentId={DRAFT_AGENT_ID} <AgentInputArea
serverId={selectedServerId ?? ""} agentId={DRAFT_AGENT_ID}
onSubmitMessage={handleCreateFromInput} serverId={selectedServerId ?? ""}
isSubmitLoading={isLoading} onSubmitMessage={handleCreateFromInput}
value={promptText} isSubmitLoading={isLoading}
onChangeText={setPromptText} value={promptText}
/> onChangeText={setPromptText}
/>
</View>
</View> </View>
</View> </View>
</View> </View>
@@ -1024,6 +1026,9 @@ const styles = StyleSheet.create((theme) => ({
contentContainer: { contentContainer: {
flex: 1, flex: 1,
}, },
inputAreaWrapper: {
backgroundColor: theme.colors.background,
},
configScrollContent: { configScrollContent: {
flexGrow: 1, flexGrow: 1,
}, },
@@ -171,10 +171,6 @@ export function AgentInputArea({
const agentIdRef = useRef(agentId); const agentIdRef = useRef(agentId);
const sendAgentMessageRef = useRef(sendAgentMessage); const sendAgentMessageRef = useRef(sendAgentMessage);
const onSubmitMessageRef = useRef(onSubmitMessage); const onSubmitMessageRef = useRef(onSubmitMessage);
const agentStatusRef = useRef<string | undefined>(undefined);
const updateQueueRef = useRef<
((updater: (current: QueuedMessage[]) => QueuedMessage[]) => void) | null
>(null);
const submitMessage = useCallback( const submitMessage = useCallback(
async (text: string, images?: Array<{ uri: string; mimeType: string }>) => { async (text: string, images?: Array<{ uri: string; mimeType: string }>) => {
if (onSubmitMessageRef.current) { if (onSubmitMessageRef.current) {
@@ -190,33 +186,12 @@ export function AgentInputArea({
if (!text) { if (!text) {
return; return;
} }
const shouldQueue = agentStatusRef.current === "running"; const current = userInput;
if (shouldQueue) { const shouldPad = current.length > 0 && !/\s$/.test(current);
updateQueueRef.current?.((current) => [ const nextValue = `${current}${shouldPad ? " " : ""}${text}`;
...current, setUserInput(nextValue);
{
id: generateMessageId(),
text,
},
]);
return;
}
void (async () => {
try {
await submitMessage(text);
} catch (error) {
console.error("[AgentInput] Failed to send transcribed message:", error);
updateQueueRef.current?.((current) => [
...current,
{
id: generateMessageId(),
text,
},
]);
}
})();
}, },
[submitMessage] [setUserInput, userInput]
); );
const handleDictationError = useCallback((error: Error) => { const handleDictationError = useCallback((error: Error) => {
@@ -225,24 +200,24 @@ export function AgentInputArea({
const canStartDictation = useCallback(() => { const canStartDictation = useCallback(() => {
const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected; const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected;
const allowed = !isRealtimeMode && socketConnected && Boolean(agent); const allowed = !isRealtimeMode && socketConnected && (Boolean(agent) || Boolean(onSubmitMessage));
console.log("[AgentInput] canStartDictation", { console.log("[AgentInput] canStartDictation", {
allowed, allowed,
isRealtimeMode, isRealtimeMode,
wsConnected: socketConnected, wsConnected: socketConnected,
}); });
return allowed; return allowed;
}, [agent, isRealtimeMode, wsOrInert]); }, [agent, isRealtimeMode, onSubmitMessage, wsOrInert]);
const canConfirmDictation = useCallback(() => { const canConfirmDictation = useCallback(() => {
const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected; const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected;
const allowed = socketConnected && Boolean(agent); const allowed = socketConnected && (Boolean(agent) || Boolean(onSubmitMessage));
console.log("[AgentInput] canConfirmDictation", { console.log("[AgentInput] canConfirmDictation", {
allowed, allowed,
wsConnected: socketConnected, wsConnected: socketConnected,
}); });
return allowed; return allowed;
}, [agent, wsOrInert]); }, [agent, onSubmitMessage, wsOrInert]);
const { const {
isRecording: isDictating, isRecording: isDictating,
@@ -657,13 +632,22 @@ export function AgentInputArea({
}); });
} }
const handleNativeSizerLayout = useCallback(
(event: { nativeEvent: { layout: { height: number } } }) => {
if (IS_WEB) {
return;
}
applyMeasuredHeight(event.nativeEvent.layout.height, "native-sizer");
},
[applyMeasuredHeight]
);
const isAgentRunning = agent?.status === "running"; const isAgentRunning = agent?.status === "running";
agentStatusRef.current = agent?.status;
const hasText = userInput.trim().length > 0; const hasText = userInput.trim().length > 0;
const hasImages = selectedImages.length > 0; const hasImages = selectedImages.length > 0;
const hasSendableContent = hasText || hasImages; const hasSendableContent = hasText || hasImages;
const shouldShowSendButton = !isAgentRunning && hasSendableContent; const shouldShowSendButton = !isAgentRunning && hasSendableContent;
const shouldShowVoiceControls = !hasSendableContent; const shouldShowVoiceControls = true;
const shouldHandleDesktopSubmit = IS_WEB; const shouldHandleDesktopSubmit = IS_WEB;
const updateQueue = useCallback( const updateQueue = useCallback(
@@ -676,11 +660,6 @@ export function AgentInputArea({
}, },
[agentId, serverId, setQueuedMessages], [agentId, serverId, setQueuedMessages],
); );
useEffect(() => {
updateQueueRef.current = updateQueue;
}, [updateQueue]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!IS_WEB) { if (!IS_WEB) {
return; return;
@@ -1003,6 +982,13 @@ export function AgentInputArea({
editable={!isDictating && wsOrInert.isConnected} editable={!isDictating && wsOrInert.isConnected}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined} onKeyPress={shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined}
/> />
{!IS_WEB && (
<View pointerEvents="none" style={styles.textInputSizerContainer}>
<Text style={styles.textInputSizer} onLayout={handleNativeSizerLayout}>
{userInput.length > 0 ? userInput : " "}
</Text>
</View>
)}
{/* Button row below input */} {/* Button row below input */}
<View style={styles.buttonRow}> <View style={styles.buttonRow}>
@@ -1058,27 +1044,30 @@ export function AgentInputArea({
)} )}
</Pressable> </Pressable>
</> </>
) : shouldShowSendButton ? ( ) : (
<Pressable <>
onPress={handleSendMessage} {shouldShowSendButton && (
disabled={!wsOrInert.isConnected || isProcessing || isSubmitLoading} <Pressable
style={[ onPress={handleSendMessage}
styles.sendButton as any, disabled={!wsOrInert.isConnected || isProcessing || isSubmitLoading}
(!wsOrInert.isConnected || isProcessing || isSubmitLoading ? styles.buttonDisabled : undefined) as any, style={[
]} styles.sendButton as any,
> (!wsOrInert.isConnected || isProcessing || isSubmitLoading
{isSubmitLoading ? ( ? styles.buttonDisabled
<ActivityIndicator size="small" color="white" /> : undefined) as any,
) : ( ]}
<ArrowUp size={20} color="white" /> >
{isSubmitLoading ? (
<ActivityIndicator size="small" color="white" />
) : (
<ArrowUp size={20} color="white" />
)}
</Pressable>
)} )}
</Pressable> {shouldShowVoiceControls && voiceButton}
) : shouldShowVoiceControls ? ( {realtimeButton}
<> </>
{voiceButton} )}
{realtimeButton}
</>
) : null}
</View> </View>
</View> </View>
</Animated.View> </Animated.View>
@@ -1197,6 +1186,20 @@ const styles = StyleSheet.create(((theme: any) => ({
} }
: {}), : {}),
}, },
textInputSizerContainer: {
position: "absolute",
left: 0,
right: 0,
opacity: 0,
zIndex: -1,
},
textInputSizer: {
width: "100%",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
fontSize: theme.fontSize.lg,
lineHeight: theme.fontSize.lg * 1.4,
},
buttonRow: { buttonRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
+2 -1
View File
@@ -320,6 +320,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
pendingRequestIdRef.current = null; pendingRequestIdRef.current = null;
setPendingRequestId(null); setPendingRequestId(null);
setIsProcessing(false); setIsProcessing(false);
setDuration(0);
setStatus("idle"); setStatus("idle");
setRetryAttempt(0); setRetryAttempt(0);
setRetryInfo(null); setRetryInfo(null);
@@ -493,7 +494,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
setError(null); setError(null);
stopDurationTracking(); stopDurationTracking();
setDuration(0);
setIsProcessing(true); setIsProcessing(true);
setRetryInfo(null); setRetryInfo(null);
setRetryAttempt(0); setRetryAttempt(0);
@@ -566,6 +566,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
pendingRequestIdRef.current = null; pendingRequestIdRef.current = null;
setPendingRequestId(null); setPendingRequestId(null);
setIsProcessing(false); setIsProcessing(false);
setDuration(0);
setFailedRecording(null); setFailedRecording(null);
setStatus("idle"); setStatus("idle");
setRetryAttempt(0); setRetryAttempt(0);