Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ vi.mock('react-native', () => ({
TextInput: 'TextInput',
View: 'View',
}));
vi.mock('react-native-reanimated', () => ({
default: { View: 'Animated.View' },
FadeIn: { duration: vi.fn(() => ({})) },
FadeOut: { duration: vi.fn(() => ({})) },
useReducedMotion: () => false,
}));
vi.mock('@/components/ui/icons', () => ({
ArrowUp: 'ArrowUp',
Paperclip: 'Paperclip',
Expand All @@ -31,6 +37,7 @@ type RenderProps = {
hasSendableContent?: boolean;
inputEditable: boolean;
isStreaming?: boolean;
voiceInputAvailable?: boolean;
};

function makeProps(overrides: Partial<RenderProps> = {}) {
Expand Down Expand Up @@ -69,6 +76,13 @@ function findTextInput(root: TestRenderer.ReactTestInstance): TestRenderer.React
return root.find(node => typeof node.type === 'string' && (node.type as string) === 'TextInput');
}

function findAllByType(
root: TestRenderer.ReactTestInstance,
type: string
): TestRenderer.ReactTestInstance[] {
return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type);
}

function findByAccessibilityLabel(
root: TestRenderer.ReactTestInstance,
label: string
Expand Down Expand Up @@ -144,4 +158,31 @@ describe('ChatComposerInputRow mounted — iOS writing-tools lock', () => {

renderer.unmount();
});

it('keeps the microphone mounted beside Stop while streaming', async () => {
const renderer = await renderRow({
inputEditable: true,
isStreaming: true,
canSend: false,
hasSendableContent: false,
voiceInputAvailable: true,
});

expect(findAllByType(renderer.root, 'VoiceInputButton')).toHaveLength(1);
expect(findByAccessibilityLabel(renderer.root, 'Stop generating')).not.toBeNull();

renderer.unmount();
});

it('renders the mic at the lg size so it reaches the 48dp Android target', async () => {
const renderer = await renderRow({
inputEditable: true,
voiceInputAvailable: true,
});

const [mic] = findAllByType(renderer.root, 'VoiceInputButton');
expect(mic?.props.size).toBe('lg');

renderer.unmount();
});
});
84 changes: 49 additions & 35 deletions apps/mobile/src/components/agents/chat-composer-input-row.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ArrowUp, Paperclip, Square } from '@/components/ui/icons';
import { CLOUD_AGENT_PROMPT_MAX_LENGTH } from '@kilocode/cloud-agent-sdk/limits';
import { type RefObject } from 'react';
import Animated, { FadeIn, FadeOut, useReducedMotion } from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Expand Down Expand Up @@ -87,6 +88,7 @@ export function ChatComposerInputRow({
}: Readonly<ChatComposerInputRowProps>) {
const colors = useThemeColors();
const { t } = useTranslation();
const reducedMotion = useReducedMotion();
const inputScrollable = shouldEnableComposerInputScroll(measureHeight, maxInputHeight);

return (
Expand Down Expand Up @@ -138,54 +140,66 @@ export function ChatComposerInputRow({
/>
</View>

{!isStreaming && voiceInputAvailable ? (
{voiceInputAvailable ? (
<View className="ml-1">
<VoiceInputButton
disabled={voiceDisabled}
size="sm"
size="lg"
status={voiceInputStatus}
onPress={onToggleVoice}
/>
</View>
) : null}

{isStreaming && !hasSendableContent && !isSending ? (
<Pressable
onPress={onStop}
disabled={disabled}
hitSlop={CONTROL_HIT_SLOP}
accessibilityRole="button"
accessibilityLabel={t('agentChat.composer.stopGenerating')}
accessibilityState={{ disabled }}
className={cn(
'h-8 w-8 items-center justify-center rounded-full bg-neutral-400 active:opacity-70 dark:bg-neutral-500',
disabled && 'opacity-50'
)}
<Animated.View
key="stop"
entering={reducedMotion ? undefined : FadeIn.duration(150)}
exiting={reducedMotion ? undefined : FadeOut.duration(100)}
>
<Square size={14} color="white" fill="white" />
</Pressable>
<Pressable
onPress={onStop}
disabled={disabled}
hitSlop={CONTROL_HIT_SLOP}
accessibilityRole="button"
accessibilityLabel={t('agentChat.composer.stopGenerating')}
accessibilityState={{ disabled }}
className={cn(
'h-8 w-8 items-center justify-center rounded-full bg-neutral-400 active:opacity-70 dark:bg-neutral-500',
disabled && 'opacity-50'
)}
>
<Square size={14} color="white" fill="white" />
</Pressable>
</Animated.View>
) : (
<Pressable
onPress={onSubmit}
disabled={!canSend}
hitSlop={CONTROL_HIT_SLOP}
accessibilityRole="button"
accessibilityLabel={t('agentChat.composer.sendMessage')}
accessibilityState={{ disabled: !canSend, busy: isSending }}
className={`h-8 w-8 items-center justify-center rounded-full active:opacity-70 ${
canSend ? 'bg-accent-soft' : 'bg-muted'
}`}
<Animated.View
key="send"
entering={reducedMotion ? undefined : FadeIn.duration(150)}
exiting={reducedMotion ? undefined : FadeOut.duration(100)}
>
{isSending ? (
<ActivityIndicator size="small" color={colors.mutedForeground} />
) : (
<ArrowUp
size={18}
color={canSend ? colors.accentSoftForeground : colors.mutedForeground}
strokeWidth={2.5}
/>
)}
</Pressable>
<Pressable
onPress={onSubmit}
disabled={!canSend}
hitSlop={CONTROL_HIT_SLOP}
accessibilityRole="button"
accessibilityLabel={t('agentChat.composer.sendMessage')}
accessibilityState={{ disabled: !canSend, busy: isSending }}
className={`h-8 w-8 items-center justify-center rounded-full active:opacity-70 ${
canSend ? 'bg-accent-soft' : 'bg-muted'
}`}
>
{isSending ? (
<ActivityIndicator size="small" color={colors.mutedForeground} />
) : (
<ArrowUp
size={18}
color={canSend ? colors.accentSoftForeground : colors.mutedForeground}
strokeWidth={2.5}
/>
)}
</Pressable>
</Animated.View>
)}
</View>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ describe('resolveChatComposerControlState', () => {
isFocused: false,
isSending: false,
isUploading: false,
hasFailedAttachments: false,
voiceInputActive: false,
});

Expand Down Expand Up @@ -42,6 +43,7 @@ describe('resolveChatComposerControlState', () => {
isFocused: false,
isSending: override.isSending,
isUploading: false,
hasFailedAttachments: false,
voiceInputActive: false,
});

Expand All @@ -64,6 +66,7 @@ describe('resolveChatComposerControlState', () => {
isFocused: false,
isSending: false,
isUploading: false,
hasFailedAttachments: false,
voiceInputActive: false,
});

Expand All @@ -85,6 +88,7 @@ describe('resolveChatComposerControlState', () => {
isFocused: false,
isSending: false,
isUploading: false,
hasFailedAttachments: false,
voiceInputActive: false,
});

Expand All @@ -105,6 +109,7 @@ describe('resolveChatComposerControlState', () => {
isFocused: false,
isSending: false,
isUploading: false,
hasFailedAttachments: false,
voiceInputActive: false,
});

Expand All @@ -124,6 +129,7 @@ describe('resolveChatComposerControlState', () => {
isFocused: false,
isSending: false,
isUploading: false,
hasFailedAttachments: false,
voiceInputActive: false,
});

Expand All @@ -143,6 +149,7 @@ describe('resolveChatComposerControlState', () => {
isFocused: false,
isSending: false,
isUploading: false,
hasFailedAttachments: false,
voiceInputActive: false,
});

Expand All @@ -162,6 +169,7 @@ describe('resolveChatComposerControlState', () => {
isFocused: false,
isSending: false,
isUploading: false,
hasFailedAttachments: false,
voiceInputActive: false,
});

Expand All @@ -179,6 +187,7 @@ describe('resolveChatComposerControlState', () => {
isFocused: false,
isSending: false,
isUploading: true,
hasFailedAttachments: false,
voiceInputActive: false,
});

Expand All @@ -188,6 +197,24 @@ describe('resolveChatComposerControlState', () => {
expect(state.inputEditable).toBe(true);
});

it('gates send on a failed attachment chip while sendable content remains', () => {
const state = resolveChatComposerControlState({
attachmentsCount: 1,
sendableAttachmentsCount: 1,
attachmentMax: 5,
disabled: false,
hasText: true,
isFocused: false,
isSending: false,
isUploading: false,
hasFailedAttachments: true,
voiceInputActive: false,
});

expect(state.canSend).toBe(false);
expect(state.hasSendableContent).toBe(true);
});

it('keeps the toolbar visible when focused, has text, has attachments, or voice is active', () => {
const base = {
attachmentsCount: 0,
Expand All @@ -198,6 +225,7 @@ describe('resolveChatComposerControlState', () => {
isFocused: false,
isSending: false,
isUploading: false,
hasFailedAttachments: false,
voiceInputActive: false,
};

Expand All @@ -222,6 +250,7 @@ describe('resolveChatComposerControlState', () => {
isFocused: false,
isSending: false,
isUploading: false,
hasFailedAttachments: false,
voiceInputActive: false,
});

Expand All @@ -238,13 +267,14 @@ describe('resolveChatComposerControlState', () => {
isFocused: false,
isSending: true,
isUploading: false,
hasFailedAttachments: false,
voiceInputActive: false,
});

expect(state.paperclipDisabled).toBe(true);
});

it('disables the paperclip and input while this owner is voice active', () => {
it('disables the paperclip but keeps the input editable while this owner is voice active', () => {
const state = resolveChatComposerControlState({
attachmentsCount: 0,
sendableAttachmentsCount: 0,
Expand All @@ -254,12 +284,13 @@ describe('resolveChatComposerControlState', () => {
isFocused: false,
isSending: false,
isUploading: false,
hasFailedAttachments: false,
voiceInputActive: true,
});

expect(state.paperclipDisabled).toBe(true);
expect(state.inputEditable).toBe(false);
expect(state.inputAccessibilityDisabled).toBe(true);
expect(state.inputEditable).toBe(true);
expect(state.inputAccessibilityDisabled).toBe(false);
});

it('leaves voice enabled (only toolbar gates it) when the composer is otherwise ready', () => {
Expand All @@ -272,6 +303,7 @@ describe('resolveChatComposerControlState', () => {
isFocused: false,
isSending: false,
isUploading: false,
hasFailedAttachments: false,
voiceInputActive: false,
});

Expand Down
16 changes: 12 additions & 4 deletions apps/mobile/src/components/agents/chat-composer-input-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ type ChatComposerControlInput = {
isSending: boolean;
/** True while an attachment upload is in flight; blocks send until it settles. */
isUploading: boolean;
/** True when at least one attachment chip is terminally failed; gates send. */
hasFailedAttachments: boolean;
voiceInputActive: boolean;
};

Expand Down Expand Up @@ -36,8 +38,11 @@ type ChatComposerControlState = {
* the rules in one place lets the component stay a thin presenter and makes
* every state — happy, blocked, and listening — testable without rendering
* the composer. Voice input integrates here too: an active voice session
* makes the input read-only and locks the attachment picker while speech is
* being recognized.
* locks the attachment picker while speech is being recognized, but it keeps
* the input editable so dictation can insert at the caret (a user edit during
* dictation aborts the session in the selection-aware draft path). A
* terminally failed attachment chip gates send (`hasFailedAttachments`), so a
* failed upload renders Send disabled instead of toasting on press.
*/
export function resolveChatComposerControlState(
input: ChatComposerControlInput
Expand All @@ -51,6 +56,7 @@ export function resolveChatComposerControlState(
isFocused,
isSending,
isUploading,
hasFailedAttachments,
voiceInputActive,
} = input;
// Streaming is intentionally NOT a composer gate. The user must be able to
Expand All @@ -63,11 +69,13 @@ export function resolveChatComposerControlState(
const voiceDisabled = toolbarDisabled;
const paperclipDisabled =
toolbarDisabled || voiceInputActive || attachmentsCount >= attachmentMax;
const inputEditable = !toolbarDisabled && !voiceInputActive;
// Voice activity no longer makes the input read-only: dictation inserts at
// the caret, so the user can keep editing (an edit aborts the session).
const inputEditable = !toolbarDisabled;
const showToolbar = isFocused || hasText || attachmentsCount > 0 || voiceInputActive;
const hasSendableContent = hasText || sendableAttachmentsCount > 0;
return {
canSend: hasSendableContent && !disabled && !isSending && !isUploading,
canSend: hasSendableContent && !disabled && !isSending && !isUploading && !hasFailedAttachments,
hasSendableContent,
inputAccessibilityDisabled: !inputEditable,
inputEditable,
Expand Down
Loading