Improve markdown styling and enable text selection on mobile

- Fix agent chat bottom scroll padding by adding overflow hidden to inputAreaContainer
- Create shared markdown-styles.ts with comprehensive styling for tables, headings, code blocks, blockquotes, and horizontal rules
- Enable text selection in AssistantMessage and AgentThoughtMessage by overriding markdown render rules with selectable={true}
- Path chips remain non-selectable to preserve onPress navigation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-12-23 12:32:52 +07:00
co-authored by Claude Opus 4.5
parent adc56da129
commit 38a4c70aec
4 changed files with 499 additions and 236 deletions
@@ -1139,6 +1139,7 @@ const styles = StyleSheet.create(((theme: any) => ({
minHeight: FOOTER_HEIGHT, minHeight: FOOTER_HEIGHT,
alignItems: "center", alignItems: "center",
width: "100%", width: "100%",
overflow: "hidden",
}, },
inputAreaContent: { inputAreaContent: {
width: "100%", width: "100%",
@@ -42,6 +42,7 @@ import {
extractReadEntries, extractReadEntries,
} from "@/utils/tool-call-parsers"; } from "@/utils/tool-call-parsers";
import { ToolCallSheetProvider } from "./tool-call-sheet"; import { ToolCallSheetProvider } from "./tool-call-sheet";
import { createMarkdownStyles } from "@/styles/markdown-styles";
const MAX_CHAT_WIDTH = 960; const MAX_CHAT_WIDTH = 960;
type PermissionResolvedMessage = Extract< type PermissionResolvedMessage = Extract<
@@ -594,62 +595,7 @@ function PermissionRequestCard({
); );
const markdownStyles = useMemo( const markdownStyles = useMemo(
() => ({ () => createMarkdownStyles(theme),
body: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
text: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
paragraph: {
marginBottom: theme.spacing[1],
},
strong: {
fontWeight: theme.fontWeight.semibold,
},
bullet_list: {
marginBottom: theme.spacing[1],
},
ordered_list: {
marginBottom: theme.spacing[1],
},
list_item: {
flexDirection: "row" as const,
marginBottom: theme.spacing[1],
},
code_inline: {
fontFamily: "monospace",
backgroundColor: theme.colors.card,
paddingHorizontal: theme.spacing[1],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.sm,
},
code_block: {
fontFamily: "monospace",
backgroundColor: theme.colors.card,
padding: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
fence: {
fontFamily: "monospace",
backgroundColor: theme.colors.card,
padding: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
blockquote: {
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.border,
paddingLeft: theme.spacing[3],
marginBottom: theme.spacing[2],
},
blockquote_text: {
color: theme.colors.mutedForeground,
},
}),
[theme] [theme]
); );
+131 -180
View File
@@ -24,6 +24,7 @@ import {
} from "lucide-react-native"; } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { baseColors, theme } from "@/styles/theme"; import { baseColors, theme } from "@/styles/theme";
import { createMarkdownStyles, createCompactMarkdownStyles } from "@/styles/markdown-styles";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import type { TodoEntry, ThoughtStatus } from "@/types/stream"; import type { TodoEntry, ThoughtStatus } from "@/types/stream";
@@ -161,27 +162,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.bold, fontWeight: theme.fontWeight.bold,
}, },
// Markdown styles // Used in custom markdownRules for inline code styling
markdownBody: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: 24,
},
markdownText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
markdownParagraph: {
marginTop: 0,
marginBottom: theme.spacing[2],
},
markdownStrong: {
fontWeight: theme.fontWeight.bold,
},
markdownEm: {
fontStyle: "italic" as const,
},
markdownCodeInline: { markdownCodeInline: {
backgroundColor: theme.colors.secondary, backgroundColor: theme.colors.secondary,
color: theme.colors.secondaryForeground, color: theme.colors.secondaryForeground,
@@ -191,47 +172,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
fontFamily: "monospace", fontFamily: "monospace",
fontSize: 13, fontSize: 13,
}, },
markdownCodeBlock: { // Used in custom markdownRules for path chip styling
backgroundColor: theme.colors.secondary,
color: theme.colors.secondaryForeground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
fontFamily: "monospace",
fontSize: 13,
},
markdownFence: {
backgroundColor: theme.colors.secondary,
borderColor: theme.colors.border,
color: theme.colors.secondaryForeground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
marginVertical: theme.spacing[2],
fontFamily: "monospace",
fontSize: 13,
},
markdownLink: {
color: theme.colors.primary,
textDecorationLine: "underline" as const,
},
markdownList: {
marginBottom: theme.spacing[2],
},
markdownListItem: {
marginBottom: theme.spacing[1],
},
markdownBlockquote: {
backgroundColor: theme.colors.secondary,
borderLeftWidth: 4,
borderLeftColor: theme.colors.primary,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
marginVertical: theme.spacing[2],
borderRadius: theme.borderRadius.sm,
},
markdownBlockquoteText: {
color: theme.colors.foreground,
fontStyle: "italic" as const,
},
pathChip: { pathChip: {
backgroundColor: theme.colors.secondary, backgroundColor: theme.colors.secondary,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
@@ -391,95 +332,75 @@ export const AssistantMessage = memo(function AssistantMessage({
const lastPathRef = useRef<string | null>(null); const lastPathRef = useRef<string | null>(null);
const markdownStyles = useMemo( const markdownStyles = useMemo(
() => ({ () => createMarkdownStyles(theme),
body: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: 24,
},
text: {
color: theme.colors.foreground,
},
paragraph: {
marginTop: 0,
marginBottom: theme.spacing[2],
},
strong: {
fontWeight: theme.fontWeight.bold,
},
em: {
fontStyle: "italic" as const,
},
code_inline: {
backgroundColor: theme.colors.secondary,
color: theme.colors.secondaryForeground,
paddingHorizontal: theme.spacing[2],
paddingVertical: 2,
borderRadius: theme.borderRadius.sm,
fontFamily: "monospace",
fontSize: 13,
},
code_block: {
backgroundColor: theme.colors.secondary,
color: theme.colors.secondaryForeground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
fontFamily: "monospace",
fontSize: 13,
},
fence: {
backgroundColor: theme.colors.secondary,
borderColor: theme.colors.border,
color: theme.colors.secondaryForeground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
marginVertical: theme.spacing[2],
fontFamily: "monospace",
fontSize: 13,
},
link: {
color: theme.colors.primary,
textDecorationLine: "underline" as const,
},
bullet_list: {
marginBottom: theme.spacing[2],
},
ordered_list: {
marginBottom: theme.spacing[2],
},
list_item: {
marginBottom: theme.spacing[1],
},
blockquote: {
backgroundColor: theme.colors.secondary,
borderLeftWidth: 4,
borderLeftColor: theme.colors.primary,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
marginVertical: theme.spacing[2],
borderRadius: theme.borderRadius.sm,
},
blockquote_text: {
color: theme.colors.foreground,
fontStyle: "italic" as const,
},
}),
[theme] [theme]
); );
const markdownRules = useMemo(() => { const markdownRules = useMemo(() => {
if (!onInlinePathPress) {
return undefined;
}
return { return {
code_inline: (node: any) => { text: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]} selectable>
{node.content}
</Text>
),
textgroup: (
node: any,
children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]} selectable>
{children}
</Text>
),
code_block: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]} selectable>
{node.content}
</Text>
),
fence: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]} selectable>
{node.content}
</Text>
),
code_inline: (
node: any,
_children: ReactNode[],
_parent: any,
_styles: any,
inheritedStyles: any = {}
) => {
const content = node.content ?? ""; const content = node.content ?? "";
const parsed = parseInlinePathToken(content, lastPathRef); const parsed = onInlinePathPress
? parseInlinePathToken(content, lastPathRef)
: null;
if (!parsed) { if (!parsed) {
return ( return (
<Text key={node.key} style={assistantMessageStylesheet.markdownCodeInline}> <Text
key={node.key}
style={[inheritedStyles, assistantMessageStylesheet.markdownCodeInline]}
selectable
>
{content} {content}
</Text> </Text>
); );
@@ -489,6 +410,7 @@ export const AssistantMessage = memo(function AssistantMessage({
<Text <Text
key={node.key} key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)} onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={false}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]} style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
> >
{content} {content}
@@ -1020,42 +942,7 @@ export function AgentThoughtMessage({ message, status = "ready" }: AgentThoughtM
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const markdownContent = useMemo(() => message?.trim() ?? "", [message]); const markdownContent = useMemo(() => message?.trim() ?? "", [message]);
const markdownStyles = useMemo( const markdownStyles = useMemo(
() => ({ () => createCompactMarkdownStyles(theme),
body: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
text: {
color: theme.colors.foreground,
},
paragraph: {
marginBottom: theme.spacing[2],
},
strong: {
fontWeight: theme.fontWeight.semibold,
},
em: {
fontStyle: "italic" as const,
},
code_inline: {
backgroundColor: theme.colors.secondary,
color: theme.colors.secondaryForeground,
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.sm,
fontFamily: "monospace",
fontSize: theme.fontSize.xs,
},
code_block: {
backgroundColor: theme.colors.secondary,
color: theme.colors.secondaryForeground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
fontFamily: "monospace",
fontSize: theme.fontSize.sm,
},
}),
[theme] [theme]
); );
@@ -1063,12 +950,76 @@ export function AgentThoughtMessage({ message, status = "ready" }: AgentThoughtM
setIsExpanded((prev) => !prev); setIsExpanded((prev) => !prev);
}, []); }, []);
const markdownRules = useMemo(() => {
return {
text: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]} selectable>
{node.content}
</Text>
),
textgroup: (
node: any,
children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]} selectable>
{children}
</Text>
),
code_block: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]} selectable>
{node.content}
</Text>
),
fence: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]} selectable>
{node.content}
</Text>
),
code_inline: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]} selectable>
{node.content}
</Text>
),
};
}, []);
const renderDetails = useCallback(() => { const renderDetails = useCallback(() => {
if (!markdownContent) { if (!markdownContent) {
return <Text style={agentThoughtStylesheet.emptyText}>No captured thinking</Text>; return <Text style={agentThoughtStylesheet.emptyText}>No captured thinking</Text>;
} }
return <Markdown style={markdownStyles}>{markdownContent}</Markdown>; return (
}, [markdownContent, markdownStyles]); <Markdown style={markdownStyles} rules={markdownRules}>
{markdownContent}
</Markdown>
);
}, [markdownContent, markdownRules, markdownStyles]);
return ( return (
<ExpandableBadge <ExpandableBadge
+365
View File
@@ -0,0 +1,365 @@
import type { Theme } from "./theme";
/**
* Creates comprehensive markdown styles for react-native-markdown-display.
*
* Usage:
* const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
* <Markdown style={markdownStyles}>{content}</Markdown>
*/
export function createMarkdownStyles(theme: Theme) {
return {
// =========================================================================
// BASE STYLES
// =========================================================================
body: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
text: {
color: theme.colors.foreground,
},
paragraph: {
marginTop: 0,
marginBottom: theme.spacing[3],
flexWrap: "wrap" as const,
flexDirection: "row" as const,
alignItems: "flex-start" as const,
justifyContent: "flex-start" as const,
},
// =========================================================================
// HEADINGS
// =========================================================================
heading1: {
fontSize: theme.fontSize["3xl"],
fontWeight: theme.fontWeight.bold,
color: theme.colors.foreground,
marginTop: theme.spacing[6],
marginBottom: theme.spacing[3],
lineHeight: 32,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
paddingBottom: theme.spacing[2],
},
heading2: {
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.bold,
color: theme.colors.foreground,
marginTop: theme.spacing[6],
marginBottom: theme.spacing[3],
lineHeight: 28,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
paddingBottom: theme.spacing[2],
},
heading3: {
fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
marginTop: theme.spacing[4],
marginBottom: theme.spacing[2],
lineHeight: 26,
},
heading4: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
marginTop: theme.spacing[4],
marginBottom: theme.spacing[2],
lineHeight: 24,
},
heading5: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
marginTop: theme.spacing[3],
marginBottom: theme.spacing[1],
lineHeight: 22,
},
heading6: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.mutedForeground,
marginTop: theme.spacing[3],
marginBottom: theme.spacing[1],
lineHeight: 20,
textTransform: "uppercase" as const,
letterSpacing: 0.5,
},
// =========================================================================
// TEXT FORMATTING
// =========================================================================
strong: {
fontWeight: theme.fontWeight.bold,
},
em: {
fontStyle: "italic" as const,
},
s: {
textDecorationLine: "line-through" as const,
color: theme.colors.mutedForeground,
},
link: {
color: theme.colors.primary,
textDecorationLine: "underline" as const,
},
blocklink: {
color: theme.colors.primary,
textDecorationLine: "underline" as const,
},
// =========================================================================
// CODE
// =========================================================================
code_inline: {
backgroundColor: theme.colors.secondary,
color: theme.colors.secondaryForeground,
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.sm,
fontFamily: "monospace",
fontSize: theme.fontSize.sm,
},
code_block: {
backgroundColor: theme.colors.secondary,
color: theme.colors.secondaryForeground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
fontFamily: "monospace",
fontSize: theme.fontSize.sm,
marginVertical: theme.spacing[2],
},
fence: {
backgroundColor: theme.colors.secondary,
color: theme.colors.secondaryForeground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
fontFamily: "monospace",
fontSize: theme.fontSize.sm,
marginVertical: theme.spacing[3],
},
pre: {
marginVertical: theme.spacing[2],
},
// =========================================================================
// TABLES
// =========================================================================
table: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
marginVertical: theme.spacing[3],
},
thead: {
backgroundColor: theme.colors.secondary,
},
tbody: {},
th: {
padding: theme.spacing[2],
borderBottomWidth: 1,
borderRightWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.secondary,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
textAlign: "left" as const,
},
tr: {
borderBottomWidth: 1,
borderColor: theme.colors.border,
flexDirection: "row" as const,
},
td: {
padding: theme.spacing[2],
borderRightWidth: 1,
borderColor: theme.colors.border,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
flex: 1,
},
// =========================================================================
// LISTS
// =========================================================================
bullet_list: {
marginVertical: theme.spacing[2],
paddingLeft: theme.spacing[1],
},
ordered_list: {
marginVertical: theme.spacing[2],
paddingLeft: theme.spacing[1],
},
list_item: {
marginBottom: theme.spacing[1],
flexDirection: "row" as const,
alignItems: "flex-start" as const,
},
bullet_list_content: {
flex: 1,
flexWrap: "wrap" as const,
},
ordered_list_content: {
flex: 1,
flexWrap: "wrap" as const,
},
bullet_list_icon: {
color: theme.colors.mutedForeground,
marginRight: theme.spacing[2],
fontSize: theme.fontSize.base,
lineHeight: 22,
},
ordered_list_icon: {
color: theme.colors.mutedForeground,
marginRight: theme.spacing[2],
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
lineHeight: 22,
minWidth: 20,
},
// =========================================================================
// BLOCKQUOTE
// =========================================================================
blockquote: {
backgroundColor: theme.colors.muted,
borderLeftWidth: 4,
borderLeftColor: theme.colors.primary,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
marginVertical: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
// =========================================================================
// HORIZONTAL RULE
// =========================================================================
hr: {
backgroundColor: theme.colors.border,
height: 1,
marginVertical: theme.spacing[6],
},
// =========================================================================
// IMAGES
// =========================================================================
image: {
borderRadius: theme.borderRadius.md,
marginVertical: theme.spacing[2],
},
// =========================================================================
// BREAKS
// =========================================================================
hardbreak: {
height: theme.spacing[2],
},
softbreak: {},
};
}
/**
* Creates a smaller variant of markdown styles for compact UI elements
* like thought bubbles, tooltips, or side panels.
*/
export function createCompactMarkdownStyles(theme: Theme) {
const baseStyles = createMarkdownStyles(theme);
return {
...baseStyles,
body: {
...baseStyles.body,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
heading1: {
...baseStyles.heading1,
fontSize: theme.fontSize.xl,
marginTop: theme.spacing[4],
marginBottom: theme.spacing[2],
lineHeight: 26,
},
heading2: {
...baseStyles.heading2,
fontSize: theme.fontSize.lg,
marginTop: theme.spacing[3],
marginBottom: theme.spacing[2],
lineHeight: 24,
},
heading3: {
...baseStyles.heading3,
fontSize: theme.fontSize.base,
marginTop: theme.spacing[3],
marginBottom: theme.spacing[1],
lineHeight: 22,
},
paragraph: {
...baseStyles.paragraph,
marginBottom: theme.spacing[2],
},
code_inline: {
...baseStyles.code_inline,
fontSize: theme.fontSize.xs,
},
code_block: {
...baseStyles.code_block,
fontSize: theme.fontSize.xs,
padding: theme.spacing[2],
},
fence: {
...baseStyles.fence,
fontSize: theme.fontSize.xs,
padding: theme.spacing[2],
},
};
}