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' }
+})