Skip to content
Open
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 @@ -135,7 +135,7 @@ export function ChatComposerInputRow({
</View>
) : null}

{isStreaming ? (
{isStreaming && !canSend && !isSending ? (
<Pressable
onPress={onStop}
disabled={disabled}
Expand Down
68 changes: 55 additions & 13 deletions apps/mobile/src/components/agents/chat-composer-input-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ describe('resolveChatComposerControlState', () => {
hasText: true,
isFocused: false,
isSending: false,
isStreaming: false,
voiceInputActive: false,
});

Expand All @@ -26,11 +25,10 @@ describe('resolveChatComposerControlState', () => {
});
});

it('collapses send, voice, and toolbar when disabled, streaming, or sending', () => {
it('collapses send, voice, and toolbar when disabled or sending', () => {
for (const override of [
{ disabled: true, isStreaming: false, isSending: false },
{ disabled: false, isStreaming: true, isSending: false },
{ disabled: false, isStreaming: false, isSending: true },
{ disabled: true, isSending: false },
{ disabled: false, isSending: true },
]) {
const state = resolveChatComposerControlState({
attachmentsCount: 0,
Expand All @@ -39,7 +37,6 @@ describe('resolveChatComposerControlState', () => {
hasText: true,
isFocused: false,
isSending: override.isSending,
isStreaming: override.isStreaming,
voiceInputActive: false,
});

Expand All @@ -51,6 +48,57 @@ describe('resolveChatComposerControlState', () => {
}
});

it('keeps the input editable and toolbar enabled while streaming when text is present', () => {
const state = resolveChatComposerControlState({
attachmentsCount: 0,
attachmentMax: 5,
disabled: false,
hasText: true,
isFocused: false,
isSending: false,
voiceInputActive: false,
});

expect(state.inputEditable).toBe(true);
expect(state.inputAccessibilityDisabled).toBe(false);
expect(state.toolbarDisabled).toBe(false);
expect(state.voiceDisabled).toBe(false);
expect(state.canSend).toBe(true);
});

it('keeps the input editable while streaming with an empty draft (canSend stays false)', () => {
const state = resolveChatComposerControlState({
attachmentsCount: 0,
attachmentMax: 5,
disabled: false,
hasText: false,
isFocused: false,
isSending: false,
voiceInputActive: false,
});

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

it('still blocks send mid-stream when the parent disabled flag is on (e.g. read-only or capability gate)', () => {
const state = resolveChatComposerControlState({
attachmentsCount: 0,
attachmentMax: 5,
disabled: true,
hasText: true,
isFocused: false,
isSending: false,
voiceInputActive: false,
});

expect(state.canSend).toBe(false);
expect(state.inputEditable).toBe(false);
expect(state.toolbarDisabled).toBe(true);
});

it('does not allow send when the draft is empty even with attachments and no overrides', () => {
const state = resolveChatComposerControlState({
attachmentsCount: 2,
Expand All @@ -59,7 +107,6 @@ describe('resolveChatComposerControlState', () => {
hasText: false,
isFocused: false,
isSending: false,
isStreaming: false,
voiceInputActive: false,
});

Expand All @@ -76,7 +123,6 @@ describe('resolveChatComposerControlState', () => {
hasText: false,
isFocused: false,
isSending: false,
isStreaming: false,
voiceInputActive: false,
};

Expand All @@ -99,7 +145,6 @@ describe('resolveChatComposerControlState', () => {
hasText: true,
isFocused: false,
isSending: false,
isStreaming: false,
voiceInputActive: false,
});

Expand All @@ -113,8 +158,7 @@ describe('resolveChatComposerControlState', () => {
disabled: false,
hasText: true,
isFocused: false,
isSending: false,
isStreaming: true,
isSending: true,
voiceInputActive: false,
});

Expand All @@ -129,7 +173,6 @@ describe('resolveChatComposerControlState', () => {
hasText: true,
isFocused: false,
isSending: false,
isStreaming: false,
voiceInputActive: true,
});

Expand All @@ -146,7 +189,6 @@ describe('resolveChatComposerControlState', () => {
hasText: false,
isFocused: false,
isSending: false,
isStreaming: false,
voiceInputActive: false,
});

Expand Down
11 changes: 7 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 @@ -5,7 +5,6 @@ type ChatComposerControlInput = {
hasText: boolean;
isFocused: boolean;
isSending: boolean;
isStreaming: boolean;
voiceInputActive: boolean;
};

Expand Down Expand Up @@ -44,17 +43,21 @@ export function resolveChatComposerControlState(
hasText,
isFocused,
isSending,
isStreaming,
voiceInputActive,
} = input;
const toolbarDisabled = disabled || isStreaming || isSending;
// Streaming is intentionally NOT a composer gate. The user must be able to
// type and send while the agent runs (plan §3.3): the row component chooses
// Stop vs Send based on `isStreaming` + `hasText`. The session manager, the
// parent, and `disabled` already cover every other lock (read-only, missing
// model, blocking interaction, upload-in-progress, interrupt-in-flight).
const toolbarDisabled = disabled || isSending;
const voiceDisabled = toolbarDisabled;
const paperclipDisabled =
toolbarDisabled || voiceInputActive || attachmentsCount >= attachmentMax;
const inputEditable = !toolbarDisabled && !voiceInputActive;
const showToolbar = isFocused || hasText || attachmentsCount > 0 || voiceInputActive;
return {
canSend: hasText && !disabled && !isStreaming && !isSending,
canSend: hasText && !disabled && !isSending,
inputAccessibilityDisabled: !inputEditable,
inputEditable,
paperclipDisabled,
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/src/components/agents/chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,10 @@ export function ChatComposer({
});

// Compute base composer disabled before the voice hook so voice can react to it.
const toolbarDisabled = disabled || isStreaming || isSending;
// `isStreaming` is intentionally NOT a composer gate (see
// `chat-composer-input-state.ts`); the user must remain able to type and
// send while the agent runs.
const toolbarDisabled = disabled || isSending;
const voiceDisabled = toolbarDisabled;

const voiceInput = useVoiceInput({
Expand All @@ -173,7 +176,6 @@ export function ChatComposer({
hasText,
isFocused,
isSending,
isStreaming,
voiceInputActive: voiceInput.isActive,
});

Expand Down
181 changes: 181 additions & 0 deletions apps/mobile/src/components/agents/message-bubble.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { type MessageDeliveryState, type StoredMessage } from 'cloud-agent-sdk';
import { describe, expect, it, vi } from 'vitest';

vi.mock('react-native', () => ({
Pressable: 'Pressable',
View: 'View',
Platform: { OS: 'android' },
}));
vi.mock('react-native-reanimated', () => ({
default: { View: 'Animated.View' },
FadeIn: { duration: vi.fn() },
FadeOut: { duration: vi.fn() },
}));
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }));
vi.mock('expo-haptics', () => ({
notificationAsync: vi.fn(),
NotificationFeedbackType: { Success: 'success' },
}));
vi.mock('sonner-native', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('lucide-react-native', () => ({
Clock: () => null,
}));
vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({ mutedForeground: '#6F6A61' }),
}));
vi.mock('@/components/ui/bubble', () => ({
Bubble: ({ children }: { children?: unknown }) => children,
}));
vi.mock('@/components/ui/text', () => ({
Text: ({ children }: { children?: unknown }) => children,
}));
vi.mock('./chat-markdown-text', () => ({
ChatMarkdownText: () => null,
}));
vi.mock('./compaction-separator', () => ({
CompactionSeparator: () => null,
}));
vi.mock('./file-part-renderer', () => ({
FilePartRenderer: () => null,
}));
vi.mock('./part-renderer', () => ({
PartRenderer: () => null,
}));
vi.mock('./part-types', () => ({
isFilePart: () => false,
isTextPart: () => false,
}));
vi.mock('./use-message-copy', () => ({
useMessageCopy: () => ({ copyMessage: vi.fn() }),
}));

function userMessage(id: string): StoredMessage {
return {
info: {
id,
sessionID: 'ses_1',
role: 'user',
time: { created: 1_761_000_000_000 },
agent: 'build',
model: { providerID: 'openrouter', modelID: 'anthropic/claude-sonnet-4' },
},
parts: [
{
id: `${id}-text`,
sessionID: 'ses_1',
messageID: id,
type: 'text',
text: 'hi',
},
],
};
}

async function renderBubble(
message: StoredMessage,
deliveryState?: MessageDeliveryState
): Promise<unknown> {
const { MessageBubble } = await import('./message-bubble');
// eslint-disable-next-line new-cap
return MessageBubble({ message, deliveryState });
}

function findText(node: unknown, predicate: (text: string) => boolean): boolean {
if (typeof node === 'string') {
return predicate(node);
}
if (node == null || typeof node !== 'object') {
return false;
}
const element = node as { type?: unknown; props?: { children?: unknown } };
// The mock for the Text component is a plain function; we inspect the
// unrendered React element tree, so the string sits in props.children.
if (typeof element.props?.children === 'string' && predicate(element.props.children)) {
return true;
}
const children = element.props?.children;
if (Array.isArray(children)) {
return children.some(child => findText(child, predicate));
}
if (children && typeof children === 'object') {
return findText(children, predicate);
}
return false;
}

function hasAnimatedBadge(node: unknown): boolean {
if (node == null || typeof node !== 'object') {
return false;
}
const element = node as { type?: unknown; props?: Record<string, unknown> };
if (element.type === 'Animated.View') {
return true;
}
const children = element.props?.children;
if (Array.isArray(children)) {
return children.some(child => hasAnimatedBadge(child));
}
if (children && typeof children === 'object') {
return hasAnimatedBadge(children);
}
return false;
}

describe('MessageBubble queued badge', () => {
it('renders the Queued badge when deliveryState is queued on a user message', async () => {
const tree = await renderBubble(userMessage('m1'), { status: 'queued' });
expect(findText(tree, t => t === 'Queued')).toBe(true);
expect(hasAnimatedBadge(tree)).toBe(true);
});

it('does not render the Queued badge for a failed delivery state on a user message', async () => {
const tree = await renderBubble(userMessage('m2'), {
status: 'failed',
error: 'nope',
reason: 'exhausted',
});
expect(findText(tree, t => t === 'Queued')).toBe(false);
});

it('does not render the Queued badge when no delivery state is provided', async () => {
const tree = await renderBubble(userMessage('m3'));
expect(findText(tree, t => t === 'Queued')).toBe(false);
});

it('does not render the Queued badge for assistant messages even when delivery state is queued', async () => {
const base = userMessage('m4');
const assistant: StoredMessage = {
info: {
id: base.info.id,
sessionID: base.info.sessionID,
role: 'assistant',
time: { created: base.info.time.created },
parentID: 'm0',
modelID: 'anthropic/claude-sonnet-4',
providerID: 'kilo',
mode: 'code',
agent: 'build',
path: { cwd: '/', root: '/' },
cost: 0,
tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [],
};
const tree = await renderBubble(assistant, { status: 'queued' });
expect(findText(tree, t => t === 'Queued')).toBe(false);
});
});

describe('MessageBubble regressions', () => {
it('renders without error when deliveryState transitions from queued to undefined (badge unmounts on dequeue)', async () => {
const message = userMessage('m5');
const queuedTree = await renderBubble(message, { status: 'queued' });
expect(findText(queuedTree, t => t === 'Queued')).toBe(true);

// Same message, no more delivery state (as when `pendingMessages` drops
// the entry once the CLI/cloud-agent starts processing it) — the badge
// must be absent, not stuck from a prior render.
const dequeuedTree = await renderBubble(message);
expect(findText(dequeuedTree, t => t === 'Queued')).toBe(false);
});
});
Loading