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
89 changes: 89 additions & 0 deletions apps/mobile/src/components/agents/markdown-table.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest';

import { MarkdownTable } from './markdown-table';

import { type MarkdownPalette } from './markdown-palette';

// Stub native modules that markdown-table.tsx imports at module scope.
// `useState` returns `true` so the modal renders its children, exposing
// the close Pressable in the element tree for direct-call assertions.
vi.mock('react', () => ({
Comment thread
iscekic marked this conversation as resolved.
useState: () => [true, vi.fn()],
}));
vi.mock('react-native', () => ({
Modal: 'Modal',
Pressable: 'Pressable',
ScrollView: 'ScrollView',
Text: 'Text',
View: 'View',
useWindowDimensions: () => ({ width: 390, height: 844 }),
}));
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
}));
vi.mock('lucide-react-native', () => ({
Table2: 'Table2',
X: 'X',
}));
vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({
foreground: '#000000',
}),
}));

const mockPalette: MarkdownPalette = {
textColor: '#000000',
mutedTextColor: '#888888',
codeBackground: '#f5f5f5',
borderColor: '#cccccc',
surfaceColor: '#ffffff',
};

const header: React.ReactNode[][] = [['Column 1']];
const rows: React.ReactNode[][][] = [[['Row 1']]];

/** Rendered element shape from direct-call component tests (mocked native primitives). */
type RenderedElement = {
type: string;
props: Record<string, unknown> & {
children?: RenderedElement | RenderedElement[];
};
};

/** Walk the element tree for a Pressable with accessibilityLabel="Close table". */
function findClosePressable(element: unknown): RenderedElement | null {
if (!element || typeof element !== 'object') {
return null;
}
const node = element as RenderedElement;
if (node.type === 'Pressable' && node.props.accessibilityLabel === 'Close table') {
return node;
}
const children = node.props.children;
if (children) {
const list = Array.isArray(children) ? children : [children];
for (const child of list) {
const found = findClosePressable(child);
if (found) {
return found;
}
}
}
return null;
}

describe('MarkdownTable close button', () => {
it('renders a close Pressable with accessibilityLabel "Close table" and hitSlop 8', () => {
// eslint-disable-next-line new-cap
const element = MarkdownTable({ palette: mockPalette, header, rows });
const closeButton = findClosePressable(element);

expect(closeButton).not.toBeNull();
if (!closeButton) {
throw new Error('closeButton should not be null');
}
expect(closeButton.props.accessibilityLabel).toBe('Close table');
expect(closeButton.props.accessibilityRole).toBe('button');
expect(closeButton.props.hitSlop).toBe(8);
});
});
4 changes: 3 additions & 1 deletion apps/mobile/src/components/agents/markdown-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function formatTableSummary(columnCount: number, rowCount: number): string {
// messages) and fights the swipe-to-reply pan gesture. Instead we render a
// compact "View table" chip inline and show the full table in a modal, where
// it can scroll both ways with the whole screen available.

export function MarkdownTable({ palette, header, rows }: Readonly<MarkdownTableProps>) {
const [open, setOpen] = useState(false);
const colors = useThemeColors();
Expand Down Expand Up @@ -92,8 +93,9 @@ export function MarkdownTable({ palette, header, rows }: Readonly<MarkdownTableP
setOpen(false);
}}
className="h-10 w-10 items-center justify-center rounded-md bg-secondary active:opacity-70"
accessibilityRole="button"
accessibilityLabel="Close table"
accessibilityRole="button"
hitSlop={8}
>
<X size={20} color={colors.foreground} />
</Pressable>
Expand Down
23 changes: 3 additions & 20 deletions apps/mobile/src/components/agents/message-bubble-test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,12 @@ export function assistantMessage(id: string): StoredMessage {

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

export function findText(node: unknown, predicate: (text: string) => boolean): boolean {
Expand All @@ -75,24 +76,6 @@ export function findText(node: unknown, predicate: (text: string) => boolean): b
return false;
}

export 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;
}

export function findElementByType(
node: unknown,
typeName: string,
Expand Down
131 changes: 106 additions & 25 deletions apps/mobile/src/components/agents/message-bubble.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { describe, expect, it, vi } from 'vitest';

import {
assistantMessage,
findElementByType,
findText,
hasAnimatedBadge,
renderBubble,
userMessage,
} from './message-bubble-test-utils';
Expand All @@ -13,11 +13,6 @@ vi.mock('react-native', () => ({
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(),
Expand Down Expand Up @@ -56,44 +51,130 @@ vi.mock('./use-message-copy', () => ({
useMessageCopy: () => ({ copyMessage: vi.fn() }),
}));

const BADGE_CLASS = 'flex-row items-center gap-1 self-end pr-1';

describe('MessageBubble queued badge', () => {
it('renders the Queued badge when deliveryState is queued on a user message', async () => {
it('renders the Queued badge visible when deliveryState is queued', async () => {
const tree = await renderBubble(userMessage('m1'), { status: 'queued' });
expect(findText(tree, t => t === 'Queued')).toBe(true);
expect(hasAnimatedBadge(tree)).toBe(true);
const badge = findElementByType(
tree,
'View',
p => typeof p.className === 'string' && p.className.includes(BADGE_CLASS)
);
expect(badge).not.toBeNull();
if (!badge) {
throw new Error('expected badge');
}
expect(badge.props.accessible).toBe(true);
expect(badge.props.accessibilityRole).toBe('text');
expect(badge.props.accessibilityLabel).toBe('Message queued');
expect(badge.props.accessibilityElementsHidden).toBeUndefined();
expect(badge.props.importantForAccessibility).toBeUndefined();
expect(badge.props.pointerEvents).toBe('auto');
expect(typeof badge.props.className).toBe('string');
expect(badge.props.className).toContain('opacity-100');
});

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('renders a held badge slot when holdQueuedSlot is set but deliveryState is absent', async () => {
const tree = await renderBubble(userMessage('m2'), undefined, true);
expect(findText(tree, t => t === 'Queued')).toBe(true);
const badge = findElementByType(
tree,
'View',
p => typeof p.className === 'string' && p.className.includes(BADGE_CLASS)
);
expect(badge).not.toBeNull();
if (!badge) {
throw new Error('expected badge');
}
expect(badge.props.accessible).toBe(false);
expect(badge.props.accessibilityRole).toBeUndefined();
expect(badge.props.accessibilityLabel).toBeUndefined();
expect(badge.props.accessibilityElementsHidden).toBe(true);
expect(badge.props.importantForAccessibility).toBe('no-hide-descendants');
expect(badge.props.pointerEvents).toBe('none');
expect(typeof badge.props.className).toBe('string');
expect(badge.props.className).toContain('opacity-0');
});

it('does not render the Queued badge when no delivery state is provided', async () => {
it('does not render the badge when neither deliveryState nor holdQueuedSlot is set', 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 tree = await renderBubble(assistantMessage('m4'), { status: 'queued' });
it('badge row is structurally identical between queued and held-only states', async () => {
const queuedTree = await renderBubble(userMessage('m4'), { status: 'queued' });
const heldTree = await renderBubble(userMessage('m4'), undefined, true);
const queuedBadge = findElementByType(
queuedTree,
'View',
p => typeof p.className === 'string' && p.className.includes(BADGE_CLASS)
);
const heldBadge = findElementByType(
heldTree,
'View',
p => typeof p.className === 'string' && p.className.includes(BADGE_CLASS)
);
expect(queuedBadge).not.toBeNull();
expect(heldBadge).not.toBeNull();
if (!queuedBadge || !heldBadge) {
throw new Error('expected badges');
}
// Both render as plain Views with the same structural Tailwind classes
// (only opacity differs).
const baseClass = 'flex-row items-center gap-1 self-end pr-1';
expect(typeof queuedBadge.props.className).toBe('string');
expect((queuedBadge.props.className as string).replace(/ opacity-[^\s]+/, '')).toBe(baseClass);
expect(typeof heldBadge.props.className).toBe('string');
expect((heldBadge.props.className as string).replace(/ opacity-[^\s]+/, '')).toBe(baseClass);
// Both contain the Queued text child
expect(findText(heldBadge, t => t === 'Queued')).toBe(true);
});

it('does not render the badge for assistant messages when delivery state is queued', async () => {
const tree = await renderBubble(assistantMessage('m5'), { status: 'queued' });
expect(findText(tree, t => t === 'Queued')).toBe(false);
});
});

describe('MessageBubble failed delivery state', () => {
it('does not render the badge for a failed delivery state on a user message', async () => {
const tree = await renderBubble(userMessage('m6'), {
status: 'failed',
error: 'nope',
reason: 'exhausted',
});
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');
it('holds badge slot when queued and holdQueuedSlot is set after dequeue', async () => {
const message = userMessage('m7');
// Queued: badge visible with Queued text
const queuedTree = await renderBubble(message, { status: 'queued' });
expect(findText(queuedTree, t => t === 'Queued')).toBe(true);
// Dequeued with holdQueuedSlot: badge stays mounted but invisible
const heldTree = await renderBubble(message, undefined, true);
expect(findText(heldTree, t => t === 'Queued')).toBe(true);
const badge = findElementByType(
heldTree,
'View',
p => typeof p.className === 'string' && p.className.includes(BADGE_CLASS)
);
expect(badge).not.toBeNull();
if (!badge) {
throw new Error('expected badge');
}
expect(badge.props.accessible).toBe(false);
expect(badge.props.pointerEvents).toBe('none');
});

// 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);
it('does not render badge when holdQueuedSlot is not set after dequeue', async () => {
const message = userMessage('m8');
const heldTree = await renderBubble(message);
expect(findText(heldTree, t => t === 'Queued')).toBe(false);
});
});

Expand Down
33 changes: 23 additions & 10 deletions apps/mobile/src/components/agents/message-bubble.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { type MessageDeliveryState, type StoredMessage } from '@kilocode/cloud-agent-sdk';
import { Clock } from 'lucide-react-native';
import { type AccessibilityActionEvent, Pressable, View } from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';

import { Bubble } from '@/components/ui/bubble';
import { Text } from '@/components/ui/text';
Expand All @@ -28,6 +27,12 @@ type MessageBubbleProps = {
deliveryState?: MessageDeliveryState;
/** Opens the message-details sheet; long-press never triggers the copy ActionSheet. */
onLongPressDetails?: (message: StoredMessage) => void;
/**
* When true, the badge row stays mounted for layout stability even after the
* message has dequeued (during streaming). Visible badge is gated on
* deliveryState !== 'queued'; the hidden slot retains the same height.
*/
holdQueuedSlot?: boolean;
};

export function MessageBubble({
Expand All @@ -39,6 +44,7 @@ export function MessageBubble({
onOpenChildSession,
deliveryState,
onLongPressDetails,
holdQueuedSlot,
}: Readonly<MessageBubbleProps>) {
const isUser = message.info.role === 'user';
const { copyMessage } = useMessageCopy();
Expand Down Expand Up @@ -78,7 +84,8 @@ export function MessageBubble({
.map(p => p.text)
.join('');
const fileParts = message.parts.filter(isFilePart);
const showQueuedBadge = deliveryState?.status === 'queued';
const isQueued = deliveryState?.status === 'queued';
const hasBadgeSlot = isQueued || holdQueuedSlot;
const a11y = buildAgentMessageBubbleAccessibilityProps({ isUser: true, canCopy: true });

return (
Expand All @@ -94,17 +101,23 @@ export function MessageBubble({
))}
</InMessageBubbleContext.Provider>
</Bubble>
{showQueuedBadge ? (
<Animated.View
entering={FadeIn.duration(150)}
exiting={FadeOut.duration(120)}
accessibilityRole="text"
accessibilityLabel="Message queued"
className="flex-row items-center gap-1 self-end pr-1"
{hasBadgeSlot ? (
<View
accessibilityRole={isQueued ? 'text' : undefined}
accessibilityLabel={isQueued ? 'Message queued' : undefined}
accessible={isQueued}
{...(!isQueued
? {
accessibilityElementsHidden: true as const,
importantForAccessibility: 'no-hide-descendants' as const,
}
: {})}
pointerEvents={isQueued ? 'auto' : 'none'}
className={`flex-row items-center gap-1 self-end pr-1 ${isQueued ? 'opacity-100' : 'opacity-0'}`}
Comment thread
iscekic marked this conversation as resolved.
>
<Clock size={12} color={colors.mutedForeground} />
<Text className="text-xs text-muted-foreground">Queued</Text>
</Animated.View>
</View>
) : null}
</View>
{a11y.accessibilityActions.length > 0 ? (
Expand Down
Loading