diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index cc6c086b1f3..4bd769a6c98 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -7,6 +7,7 @@ import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activ import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types' import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileMarkdown } from '../components/MobileMarkdown' +import { MobileNativeChatNoticeRow } from './MobileNativeChatNoticeRow' import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' import { ToolRun } from './MobileNativeChatToolRun' import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status' @@ -191,6 +192,14 @@ function MobileNativeChatMessageImpl({ /> ) : null + const notice = + message.role === 'system' + ? message.blocks.find((block) => isTextBlock(block) && block.tone !== undefined) + : undefined + if (notice && isTextBlock(notice)) { + return + } + return ( <> diff --git a/mobile/src/session/MobileNativeChatNoticeRow.test.tsx b/mobile/src/session/MobileNativeChatNoticeRow.test.tsx new file mode 100644 index 00000000000..611e65e7978 --- /dev/null +++ b/mobile/src/session/MobileNativeChatNoticeRow.test.tsx @@ -0,0 +1,101 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MobileNativeChatMessage } from './MobileNativeChatMessage' +import { colors } from '../theme/mobile-theme' + +const mocks = vi.hoisted(() => ({ + openURL: vi.fn(async () => {}), + copy: vi.fn() +})) +vi.mock('react-native', () => ({ + Image: 'Image', + Pressable: 'Pressable', + ScrollView: 'ScrollView', + Text: 'Text', + View: 'View', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Linking: { openURL: mocks.openURL } +})) +vi.mock('expo-clipboard', () => ({ setStringAsync: mocks.copy })) +vi.mock('lucide-react-native', () => ({ + ArrowUp: 'ArrowUp', + Copy: 'Copy', + AlertCircle: 'AlertCircle', + AlertTriangle: 'AlertTriangle', + Info: 'Info' +})) +vi.mock('../components/pr-sidebar/MermaidDiagram', () => ({ + MermaidDiagram: 'MermaidDiagram' +})) +vi.mock('./MobileNativeChatToolRun', () => ({ ToolRun: 'ToolRun' })) +vi.mock('./MobileNativeChatTurnStatus', () => ({ + MobileNativeChatTurnStatus: 'MobileNativeChatTurnStatus' +})) + +let renderer: ReactTestRenderer | null = null +const text = 'Please run `/login`. Read [enrollment](https://example.test/enroll).' +function render(role: NativeChatMessage['role'], tone?: 'notice' | 'warning' | 'error') { + const onOpenFile = vi.fn() + act(() => { + renderer = create( + createElement(MobileNativeChatMessage, { + message: { + id: 'record', + role, + timestamp: null, + source: 'transcript', + blocks: [{ type: 'text', text, ...(tone ? { tone } : {}) }] + }, + fontScale: 1.2, + onOpenFile + }) + ) + }) + return { root: renderer!.root, onOpenFile } +} +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.clearAllMocks() +}) + +describe('mobile system notice presentation', () => { + it.each([ + ['notice', 'System notice', 'Info', colors.textMuted], + ['warning', 'System warning', 'AlertTriangle', colors.statusAmber], + ['error', 'System error', 'AlertCircle', colors.statusRed] + ] as const)('keeps %s severity distinct from assistant content', (tone, label, icon, color) => { + const { root } = render('system', tone) + expect(root.findByType(icon).props.color).toBe(color) + expect(root.findAllByType('Text').some((node) => node.props.children === label)).toBe(true) + expect(root.findAllByProps({ accessibilityLabel: 'Copy message' })).toHaveLength(0) + expect( + root.findAllByProps({ + accessibilityLabel: 'Scroll this message to top' + }) + ).toHaveLength(0) + }) + + it('uses markdown web links without turning slash commands into file actions', async () => { + const { root, onOpenFile } = render('system', 'notice') + const command = root.findAllByType('Text').find((node) => node.props.children === '/login')! + expect(command).toBeDefined() + expect(command.props.onPress).toBeUndefined() + const link = root.findAllByType('Text').find((node) => node.props.children === 'enrollment')! + await act(async () => link.props.onPress()) + expect(mocks.openURL).toHaveBeenCalledWith('https://example.test/enroll') + expect(onOpenFile).not.toHaveBeenCalled() + }) + + it('preserves user bubbles and assistant controls without notice impersonation', () => { + const { root } = render('user') + expect(root.findAllByType('Text').some((node) => node.props.children === text)).toBe(true) + expect(root.findAllByType('Info')).toHaveLength(0) + act(() => renderer?.unmount()) + const assistant = render('assistant', 'notice').root + expect(assistant.findAllByProps({ accessibilityLabel: 'Copy message' })).toHaveLength(1) + expect(assistant.findAllByType('Info')).toHaveLength(0) + }) +}) diff --git a/mobile/src/session/MobileNativeChatNoticeRow.tsx b/mobile/src/session/MobileNativeChatNoticeRow.tsx new file mode 100644 index 00000000000..0bbc871ae4d --- /dev/null +++ b/mobile/src/session/MobileNativeChatNoticeRow.tsx @@ -0,0 +1,45 @@ +import { StyleSheet, Text, View } from 'react-native' +import { AlertCircle, AlertTriangle, Info } from 'lucide-react-native' +import type { NativeChatTextBlock } from '../../../src/shared/native-chat-types' +import { MobileMarkdown } from '../components/MobileMarkdown' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export function MobileNativeChatNoticeRow({ + block, + fontScale +}: { + block: NativeChatTextBlock + fontScale: number +}): React.JSX.Element { + const error = block.tone === 'error' + const warning = block.tone === 'warning' + const Icon = error ? AlertCircle : warning ? AlertTriangle : Info + const color = error ? colors.statusRed : warning ? colors.statusAmber : colors.textMuted + const label = error ? 'System error' : warning ? 'System warning' : 'System notice' + return ( + + + + + {label} + + + + + ) +} + +const styles = StyleSheet.create({ + notice: { + marginHorizontal: spacing.lg, + marginVertical: spacing.sm, + padding: spacing.md, + gap: spacing.sm, + borderRadius: radii.row, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + heading: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + label: { fontWeight: '500' } +})