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
@@ -0,0 +1,167 @@
import { createElement } from 'react';
import { TestRenderer } from '@/test/renderer';
import { describe, expect, it, vi } from 'vitest';

import '@/i18n';
import { type SessionModelOption } from '@/lib/hooks/use-session-model-options';

import { ChatToolbar } from './chat-toolbar';

vi.mock('react-native', () => ({
Keyboard: { dismiss: vi.fn() },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
View: 'View',
}));
vi.mock('expo-haptics', () => ({ selectionAsync: vi.fn() }));
vi.mock('expo-router', () => ({
useLocalSearchParams: () => ({}),
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock('@/components/ui/icons', () => ({
BookOpenCheck: 'BookOpenCheck',
Bot: 'Bot',
Brain: 'Brain',
Bug: 'Bug',
Check: 'Check',
ChevronDown: 'ChevronDown',
ClipboardPaste: 'ClipboardPaste',
Code: 'Code',
HelpCircle: 'HelpCircle',
NotebookPen: 'NotebookPen',
Star: 'Star',
Workflow: 'Workflow',
}));
vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' }));
vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
vi.mock('@/lib/hooks/use-available-models', () => ({
thinkingEffortLabel: (variant: string) => variant,
}));
vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({
foreground: '#111827',
mutedForeground: '#6F6A61',
primary: '#4F5A10',
warn: '#9F6612',
}),
}));
vi.mock('@/lib/picker-bridge', () => ({}));
vi.mock('@/lib/route-registry', () => ({
modelPickerSlot: { set: vi.fn() },
}));
vi.mock('@/lib/utils', () => ({
cn: (...parts: unknown[]) => parts.filter(Boolean).join(' '),
}));

// The model name from the explorer capture: 19 characters, wider than the
// space one nowrap row leaves after the shrink-0 mode chip and the effort badge.
const LONG_MODEL_NAME = 'DeepSeek V4.1 Flash';

const MODEL_OPTIONS: SessionModelOption[] = [
{
id: 'deepseek/deepseek-v4.1-flash',
name: LONG_MODEL_NAME,
displayId: 'deepseek/deepseek-v4.1-flash',
variants: ['low', 'medium'],
isPreferred: false,
showGatewayMetadata: true,
},
];

function renderToolbar(): TestRenderer.ReactTestRenderer {
const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
TestRenderer.act(() => {
ref.current = TestRenderer.create(
createElement(ChatToolbar, {
mode: 'code',
onModeChange: vi.fn<(mode: string) => void>(),
model: 'deepseek/deepseek-v4.1-flash',
variant: 'low',
modelOptions: MODEL_OPTIONS,
onModelSelect: vi.fn<(modelId: string, variant: string) => void>(),
onPaste: vi.fn<() => void>(),
})
);
});
const renderer = ref.current;
if (!renderer) {
throw new Error('renderer was not created');
}
return renderer;
}

describe('ChatToolbar long model name', () => {
it('lets the control row reflow instead of squeezing the model name to a few characters', () => {
const renderer = renderToolbar();
// A nowrap row gives the model chip only what the shrink-0 mode chip leaves,
// and the chip then sheds that from the label. Wrapping moves the model chip
// to its own line, where it keeps the full name.
const rows = renderer.root.findAll(
node =>
typeof node.type === 'string' &&
(node.type as string) === 'View' &&
typeof node.props.className === 'string' &&
node.props.className.includes('flex-wrap')
);
expect(rows).toHaveLength(1);
expect(rows[0]?.props.className).toContain('flex-row');
});

it('hands the full model name to the chip label, never a pre-shortened string', () => {
const renderer = renderToolbar();
const label = renderer.root.findAll(
node =>
typeof node.type === 'string' &&
(node.type as string) === 'Text' &&
node.props.children === LONG_MODEL_NAME
);
expect(label).toHaveLength(1);
expect(label[0]?.props.numberOfLines).toBe(1);
});

it('packs the paste button into the model chip row so it cannot wrap alone', () => {
const renderer = renderToolbar();
const modelChip = renderer.root.findAll(
node =>
typeof node.type === 'string' &&
(node.type as string) === 'Pressable' &&
typeof node.props.accessibilityLabel === 'string' &&
node.props.accessibilityLabel.startsWith(LONG_MODEL_NAME)
)[0];
const pasteButton = renderer.root.findAll(
node =>
typeof node.type === 'string' &&
(node.type as string) === 'Pressable' &&
node.props.accessibilityLabel === 'Paste from clipboard'
)[0];
expect(modelChip).toBeDefined();
expect(pasteButton).toBeDefined();

// The nearest shared ancestor is the row the chip and the button wrap in.
// It must be a plain row: a wrapping row would let the button leave the
// chip's line on its own.
const chipChain: TestRenderer.ReactTestInstance[] = [];
for (
let node: TestRenderer.ReactTestInstance | null | undefined = modelChip;
node;
node = node.parent
) {
chipChain.push(node);
}
const pasteChain: TestRenderer.ReactTestInstance[] = [];
for (
let node: TestRenderer.ReactTestInstance | null | undefined = pasteButton;
node;
node = node.parent
) {
pasteChain.push(node);
}
const shared = chipChain.find(candidate => pasteChain.includes(candidate));
expect(shared).toBeDefined();
expect(shared?.props.className).toContain('flex-row');
expect(shared?.props.className).not.toContain('flex-wrap');

// The button still ends the chip's line at its trailing edge.
expect(pasteButton?.props.className).toContain('ml-auto');
});
});
54 changes: 52 additions & 2 deletions apps/mobile/src/components/agents/chat-toolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,34 @@ function findElementByType(node: Node, typeName: string): Record<string, unknown
return null;
}

function childTypesOf(node: Node): unknown[] {
const props = (node as { props?: Record<string, unknown> } | null | undefined)?.props ?? {};
const children = props.children;
return (Array.isArray(children) ? children : [children]).map(child =>
child !== null && typeof child === 'object' ? (child as { type?: unknown }).type : undefined
);
}

/** The row that directly holds every one of `typeNames`, if there is one. */
function findRowHolding(node: Node, typeNames: string[]): Record<string, unknown> | null {
if (node === null || typeof node !== 'object') {
return null;
}
const childTypes = childTypesOf(node);
if (typeNames.every(typeName => childTypes.includes(typeName))) {
return node.props ?? {};
}
const props = node.props ?? {};
const children = props.children;
for (const child of Array.isArray(children) ? children : [children]) {
const found = findRowHolding(child as Node, typeNames);
if (found) {
return found;
}
}
return null;
}

function defaultProps() {
return {
mode: 'code' as AgentMode,
Expand Down Expand Up @@ -85,7 +113,7 @@ describe('ChatToolbar', () => {
expect(props.disabled).toBe(false);
});

it('keeps mode, model, and paste on one row', () => {
it('lets the control row reflow so a long model chip keeps its own width', () => {
const onPaste = vi.fn(() => undefined);
// eslint-disable-next-line new-cap -- plain function call, matching repo test convention
const element = ChatToolbar({ ...defaultProps(), onPaste }) as Node;
Expand All @@ -97,12 +125,34 @@ describe('ChatToolbar', () => {
? element.props.className
: '';
expect(className).toContain('flex-row');
expect(className).not.toContain('flex-wrap');
// The mode chip is shrink-0, so a nowrap row would squeeze the model name
// down to a few characters. Wrapping gives the model chip its own line.
expect(className).toContain('flex-wrap');

const pasteButtonProps = findElementByType(element, 'ComposerPasteButton') ?? {};
expect(pasteButtonProps.className).toContain('shrink-0');
});

it('packs the paste button with the model chip so it never wraps to a line of its own', () => {
const onPaste = vi.fn(() => undefined);
// eslint-disable-next-line new-cap -- plain function call, matching repo test convention
const element = ChatToolbar({ ...defaultProps(), onPaste }) as Node;

// A paste button that is a sibling of the chips overflows the first line on
// its own and wraps to an empty row below the model chip. One inner row
// holding both makes the outer wrap move them together.
const packRow = findRowHolding(element, ['ModelSelector', 'ComposerPasteButton']);
expect(packRow).not.toBeNull();
const packClassName = typeof packRow?.className === 'string' ? packRow.className : '';
expect(packClassName).toContain('flex-row');
expect(packClassName).not.toContain('flex-wrap');

// On the chip's line the button still keeps the trailing edge, as it did
// when every item fit on the first line.
const pasteButtonProps = findElementByType(element, 'ComposerPasteButton') ?? {};
expect(pasteButtonProps.className).toContain('ml-auto');
});

it('locks only the model picker when modelLocked is true', () => {
// eslint-disable-next-line new-cap -- plain function call, matching repo test convention
const element = ChatToolbar({
Expand Down
39 changes: 31 additions & 8 deletions apps/mobile/src/components/agents/chat-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type ChatToolbarProps = {
disabled?: boolean;
isLoadingModels?: boolean;
order?: ChatToolbarOrder;
/** When set, an always-present paste button renders at the row's trailing edge. */
/** When set, an always-present paste button renders at the end of the model chip's line. */
onPaste?: () => void;
/** Disabled state for the paste button; the composer's input rule owns it. */
pasteDisabled?: boolean;
Expand Down Expand Up @@ -70,13 +70,18 @@ export function ChatToolbar({
lockLabel={modelLocked ? modelLockLabel : undefined}
/>
);

return (
<View
className={cn('flex-row items-center gap-2 px-3 py-2.5', disabled && 'opacity-50', className)}
>
{order === 'model-first' ? modelSelector : modeSelector}
{order === 'model-first' ? modeSelector : modelSelector}
// The paste button rides with the model chip as one wrap unit. As a sibling
// of the chips it is the item that overflows the full first line, so it wraps
// alone onto the next line and strands itself at the far edge with an empty
// row to its left. Packed, the chip and the button move to the next line
// together and the button stays at the end of the chip's line.
const modelSelectorWithPaste = (
// Content-sized for the wrap decision (grow leaves the basis at auto), so
// the outer row still sees the chip's real width and wraps the unit instead
// of squeezing the chip; on its line the unit fills the row and the paste
// keeps the trailing edge.
<View className="min-w-0 grow flex-row items-center gap-2">
{modelSelector}
{onPaste ? (
<ComposerPasteButton
size="sm"
Expand All @@ -87,4 +92,22 @@ export function ChatToolbar({
) : null}
</View>
);

return (
// The chips reflow instead of shrinking each other: the mode chip is
// `shrink-0`, so in a nowrap row the only flexible part is the model chip,
// and a long model name ("DeepSeek V4.1 Flash") collapses to "Dee..." next
// to the effort badge. Wrapping moves the model chip to its own line, where
// it has the full row width to show the selected model.
<View
className={cn(
'flex-row flex-wrap items-center gap-2 px-3 py-2.5',
disabled && 'opacity-50',
className
)}
>
{order === 'model-first' ? modelSelectorWithPaste : modeSelector}
{order === 'model-first' ? modeSelector : modelSelectorWithPaste}
</View>
);
}
Loading