diff --git a/apps/mobile/src/components/agents/chat-toolbar-model-name.mounted.test.tsx b/apps/mobile/src/components/agents/chat-toolbar-model-name.mounted.test.tsx new file mode 100644 index 0000000000..000c76a9ff --- /dev/null +++ b/apps/mobile/src/components/agents/chat-toolbar-model-name.mounted.test.tsx @@ -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'); + }); +}); diff --git a/apps/mobile/src/components/agents/chat-toolbar.test.ts b/apps/mobile/src/components/agents/chat-toolbar.test.ts index c68a572d38..752fefe477 100644 --- a/apps/mobile/src/components/agents/chat-toolbar.test.ts +++ b/apps/mobile/src/components/agents/chat-toolbar.test.ts @@ -38,6 +38,34 @@ function findElementByType(node: Node, typeName: string): Record } | 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 | 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, @@ -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; @@ -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({ diff --git a/apps/mobile/src/components/agents/chat-toolbar.tsx b/apps/mobile/src/components/agents/chat-toolbar.tsx index b7911e2556..5507b29ce0 100644 --- a/apps/mobile/src/components/agents/chat-toolbar.tsx +++ b/apps/mobile/src/components/agents/chat-toolbar.tsx @@ -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; @@ -70,13 +70,18 @@ export function ChatToolbar({ lockLabel={modelLocked ? modelLockLabel : undefined} /> ); - - return ( - - {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. + + {modelSelector} {onPaste ? ( ); + + 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. + + {order === 'model-first' ? modelSelectorWithPaste : modeSelector} + {order === 'model-first' ? modeSelector : modelSelectorWithPaste} + + ); }