fix(mobile): preserve system notice presentation in native chat

This commit is contained in:
Merge Sim
2026-09-07 18:54:48 -07:00
parent fa32c9b8aa
commit e893a1cb52
3 changed files with 155 additions and 0 deletions
@@ -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 <MobileNativeChatNoticeRow block={notice} fontScale={fontScale} />
}
return (
<>
<View style={[styles.row, isUser && styles.rowUser]}>
@@ -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)
})
})
@@ -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 (
<View style={styles.notice}>
<View style={styles.heading}>
<Icon size={typography.bodySize} color={color} />
<Text style={[styles.label, { color, fontSize: typography.bodySize * fontScale }]}>
{label}
</Text>
</View>
<MobileMarkdown content={block.text} textScale={1.25 * fontScale} />
</View>
)
}
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' }
})