Improve Markdown file preview readability (#3240)

* feat(app): improve markdown file previews

Keep rendered documents on the chat reading rail, surface YAML front matter as readable metadata, and make shared Markdown links retain the chat accent and hover treatment.

* fix(app): preserve unsafe frontmatter source

* test(app): cover markdown link behavior
This commit is contained in:
Mohamed Boudra
2026-08-12 00:18:30 +02:00
committed by GitHub
parent 7b167784d7
commit 23d7a8eaf9
13 changed files with 411 additions and 50 deletions
+1 -1
View File
@@ -1 +1 @@
sha256-m6anpp3ESvWCVQAoTsOhnTMpoewhpJTeVha2Gv4Xnc4=
sha256-L73o8u6OsY3SsA5t2s5mWsMsMNc3qr5bCE4cd1NpbgI=
+1
View File
@@ -36179,6 +36179,7 @@
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"use-sync-external-store": "^1.6.0",
"yaml": "^2.8.4",
"zod": "^4.4.3",
"zustand": "^5.0.9"
},
+1
View File
@@ -119,6 +119,7 @@
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"use-sync-external-store": "^1.6.0",
"yaml": "^2.8.4",
"zod": "^4.4.3",
"zustand": "^5.0.9"
},
+9 -31
View File
@@ -1,16 +1,9 @@
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import {
Platform,
Pressable,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from "react-native";
import { useMemo, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import { Platform, Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { isNative, isWeb } from "@/constants/platform";
import { MarkdownTextSpan } from "@/components/markdown-text";
import { MarkdownLinkText } from "@/components/markdown/link-text";
import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -39,7 +32,6 @@ export function AssistantMarkdownLink({
monoSurface,
children,
}: AssistantMarkdownLinkProps) {
const [hovered, setHovered] = useState(false);
const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source);
const { configRef } = useAssistantFileLinkResolverContext();
const workspaceRoot = configRef.current.workspaceRoot;
@@ -55,15 +47,6 @@ export function AssistantMarkdownLink({
event.stopPropagation();
onAuxPress();
});
const handleHoverIn = useStableEvent(() => {
setHovered(true);
onHoverIn();
});
const handleHoverOut = useStableEvent(() => setHovered(false));
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
() => [style, hovered && { textDecorationLine: "underline" as const }],
[style, hovered],
);
const linkPress = useMemo<AssistantLinkPress>(
() => ({ onPress, accessibilityRole: "link" }),
[onPress],
@@ -114,19 +97,14 @@ export function AssistantMarkdownLink({
onAuxClickCapture={preventAnchorNavigation}
style={LINK_ANCHOR_STYLE}
>
<Pressable
accessibilityRole="link"
<MarkdownLinkText
dataSet={monoSurface ? MARKDOWN_CODE_LINK_DATASET : undefined}
style={style}
onPress={onPress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
onHoverIn={onHoverIn}
>
<Text
dataSet={monoSurface ? MARKDOWN_CODE_LINK_DATASET : undefined}
style={hoveredTextStyle}
>
{children}
</Text>
</Pressable>
{children}
</MarkdownLinkText>
</a>
);
@@ -0,0 +1,21 @@
import { Children, cloneElement, isValidElement, type ReactNode } from "react";
import type { StyleProp, TextStyle } from "react-native";
export function colorMarkdownLinkChildren(
children: ReactNode,
color: TextStyle["color"],
): ReactNode {
return Children.map(children, (child) => {
if (!isValidElement<{ style?: StyleProp<TextStyle> }>(child)) return child;
return cloneElement(child, { style: [child.props.style, { color }] });
});
}
const MARKDOWN_LINK_HOVER_STYLE: TextStyle = { textDecorationLine: "underline" };
export function markdownLinkTextStyle(
style: StyleProp<TextStyle>,
hovered: boolean,
): StyleProp<TextStyle> {
return [style, hovered && MARKDOWN_LINK_HOVER_STYLE];
}
@@ -0,0 +1,41 @@
import { useMemo, useState, type ReactNode } from "react";
import { Pressable, Text, type StyleProp, type TextStyle } from "react-native";
import { useStableEvent } from "@/hooks/use-stable-event";
import { markdownLinkTextStyle } from "./link-children";
interface MarkdownLinkTextProps {
style: StyleProp<TextStyle>;
dataSet?: Record<string, string>;
onPress(): void;
onHoverIn?(): void;
children?: ReactNode;
}
export function MarkdownLinkText({
style,
dataSet,
onPress,
onHoverIn,
children,
}: MarkdownLinkTextProps) {
const [hovered, setHovered] = useState(false);
const handleHoverIn = useStableEvent(() => {
setHovered(true);
onHoverIn?.();
});
const handleHoverOut = useStableEvent(() => setHovered(false));
const textStyle = useMemo(() => markdownLinkTextStyle(style, hovered), [hovered, style]);
return (
<Pressable
accessibilityRole="link"
onPress={onPress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
>
<Text dataSet={dataSet} style={textStyle}>
{children}
</Text>
</Pressable>
);
}
@@ -1,5 +1,48 @@
import { describe, expect, it } from "vitest";
/**
* @vitest-environment jsdom
*/
import * as React from "react";
import { createElement, type ReactNode } from "react";
import { fireEvent, render } from "@testing-library/react";
import { Text, type StyleProp, type TextStyle } from "react-native";
import { describe, expect, it, vi } from "vitest";
import { resolveInlineImageSize } from "./inline-image-size";
import { colorMarkdownLinkChildren } from "./link-children";
import { MarkdownLinkText } from "./link-text";
vi.stubGlobal("React", React);
vi.mock("react-native", () => ({
Pressable: ({
accessibilityRole,
children,
onHoverIn,
onHoverOut,
onPress,
}: {
accessibilityRole?: string;
children?: ReactNode;
onHoverIn?(): void;
onHoverOut?(): void;
onPress?(): void;
}) =>
createElement(
"div",
{
role: accessibilityRole,
onClick: onPress,
onMouseEnter: onHoverIn,
onMouseLeave: onHoverOut,
},
children,
),
Text: ({ children, style }: { children?: ReactNode; style?: StyleProp<TextStyle> }) =>
createElement("span", { style: flattenStyle(style) }, children),
}));
function flattenStyle(style: StyleProp<TextStyle>): TextStyle {
return Object.assign({}, ...(Array.isArray(style) ? style.filter(Boolean) : [style]));
}
describe("resolveInlineImageSize", () => {
it("respects a one-sided explicit width using natural aspect ratio", () => {
@@ -27,3 +70,30 @@ describe("resolveInlineImageSize", () => {
});
});
});
describe("shared Markdown links", () => {
it("renders accent text and underlines it while hovered", () => {
const onPress = vi.fn();
const children = colorMarkdownLinkChildren(
createElement(Text, { style: { color: "white" } }, "Paseo"),
"rgb(0, 122, 255)",
);
const view = render(
createElement(MarkdownLinkText, { style: { color: "rgb(0, 122, 255)" }, onPress }, children),
);
const link = view.getByRole("link");
const linkText = link.firstElementChild as HTMLElement;
expect((view.getByText("Paseo") as HTMLElement).style.color).toBe("rgb(0, 122, 255)");
expect(linkText.style.textDecorationLine).toBe("");
fireEvent.mouseEnter(link);
expect(linkText.style.textDecorationLine).toBe("underline");
fireEvent.mouseLeave(link);
expect(linkText.style.textDecorationLine).toBe("");
fireEvent.click(link);
expect(onPress).toHaveBeenCalledOnce();
});
});
@@ -31,6 +31,7 @@ import { markdownNodeContainsType } from "@/utils/markdown-ast";
import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/markdown-styles";
import type { Theme } from "@/styles/theme";
import { openExternalUrl } from "@/utils/open-external-url";
import { isNative } from "@/constants/platform";
import {
splitHtmlishMarkdown,
type MarkdownDisplayPart,
@@ -38,6 +39,8 @@ import {
} from "./html-ish";
import { resolveInlineImageSize, type InlineImageDimensions } from "./inline-image-size";
import { groupMarkdownParts, type MarkdownPartGroup } from "./part-groups";
import { colorMarkdownLinkChildren } from "./link-children";
import { MarkdownLinkText } from "./link-text";
export type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
@@ -478,6 +481,15 @@ function SharedMarkdownLink({
if (onLinkPress?.(href) === false) return;
void openExternalUrl(href);
}, [href, onLinkPress]);
const style = useMemo(() => [inheritedStyles, linkStyle], [inheritedStyles, linkStyle]);
if (!isNative) {
return (
<MarkdownLinkText style={style} onPress={handlePress}>
{children}
</MarkdownLinkText>
);
}
return (
<MarkdownInheritedText
@@ -715,7 +727,7 @@ export function createSharedMarkdownRules(): RenderRules {
linkStyle={styles.link}
onLinkPress={onLinkPress}
>
{children}
{colorMarkdownLinkChildren(children, styles.link.color)}
</SharedMarkdownLink>
),
};
+2 -10
View File
@@ -23,9 +23,6 @@ import {
useCallback,
createContext,
useContext,
isValidElement,
Children,
cloneElement,
} from "react";
import type { ComponentType, ReactNode } from "react";
import type MarkdownIt from "markdown-it";
@@ -77,6 +74,7 @@ import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { MarkdownFenceBlock } from "@/components/markdown/fence";
import type { MarkdownPhase } from "@/components/markdown/fence/types";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import { colorMarkdownLinkChildren } from "@/components/markdown/link-children";
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
@@ -1863,13 +1861,7 @@ export const AssistantMessage = memo(function AssistantMessage({
source={getMarkdownLinkSource(node)}
style={styles.link}
>
{Children.map(children, (child) => {
if (!isValidElement(child)) return child;
const childProps = child.props as { style?: StyleProp<TextStyle> };
return cloneElement(child, {
style: [childProps.style, { color: styles.link.color }],
} as Partial<{ style: StyleProp<TextStyle> }>);
})}
{colorMarkdownLinkChildren(children, styles.link.color)}
</AssistantMarkdownLink>
),
image: (
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { parseMarkdownPreviewDocument } from "./document";
describe("parseMarkdownPreviewDocument", () => {
it("separates YAML front matter from the Markdown body", () => {
expect(
parseMarkdownPreviewDocument(`---
name: release-beta
description: Cut a beta release
allowed-tools:
- Bash
- Read
---
# Release beta
Ship it.
`),
).toEqual({
frontMatter: [
{ key: "name", value: "release-beta" },
{ key: "description", value: "Cut a beta release" },
{ key: "allowed-tools", value: "Bash, Read" },
],
body: "# Release beta\n\nShip it.\n",
});
});
it("formats nested metadata without flattening its meaning", () => {
expect(
parseMarkdownPreviewDocument(`---
metadata:
audience: maintainers
stable: true
---
Body
`),
).toEqual({
frontMatter: [{ key: "metadata", value: "audience: maintainers\nstable: true" }],
body: "Body\n",
});
});
it("leaves the document intact when front matter is invalid", () => {
const source = `---
name: [broken
---
Body
`;
expect(parseMarkdownPreviewDocument(source)).toEqual({
frontMatter: [],
body: source,
});
});
it("leaves the document intact when YAML conversion rejects excessive aliases", () => {
const aliases = Array.from({ length: 101 }, () => "*item").join(", ");
const source = `---
item: &item value
items: [${aliases}]
---
Body
`;
expect(parseMarkdownPreviewDocument(source)).toEqual({
frontMatter: [],
body: source,
});
});
it("does not treat a thematic break inside the document as front matter", () => {
const source = `Introduction
---
Body
`;
expect(parseMarkdownPreviewDocument(source)).toEqual({
frontMatter: [],
body: source,
});
});
});
@@ -0,0 +1,70 @@
import { parseDocument, stringify } from "yaml";
export interface MarkdownFrontMatterRow {
key: string;
value: string;
}
export interface MarkdownPreviewDocument {
frontMatter: MarkdownFrontMatterRow[];
body: string;
}
const FRONT_MATTER_PATTERN = /^\uFEFF?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
export function parseMarkdownPreviewDocument(source: string): MarkdownPreviewDocument {
const match = FRONT_MATTER_PATTERN.exec(source);
if (!match) {
return { frontMatter: [], body: source };
}
try {
return parseFrontMatter(match, source);
} catch {
return { frontMatter: [], body: source };
}
}
function parseFrontMatter(match: RegExpExecArray, source: string): MarkdownPreviewDocument {
const document = parseDocument(match[1], { schema: "core" });
if (document.errors.length > 0) {
return { frontMatter: [], body: source };
}
const metadata = document.toJS({ maxAliasCount: 100 });
if (!isMetadataRecord(metadata)) {
return { frontMatter: [], body: source };
}
const frontMatter = Object.entries(metadata).map(([key, value]) => ({
key,
value: formatFrontMatterValue(value),
}));
return {
frontMatter,
body: source.slice(match[0].length),
};
}
function isMetadataRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function formatFrontMatterValue(value: unknown): string {
if (Array.isArray(value) && value.every(isScalar)) {
return value.map(formatScalar).join(", ");
}
if (isScalar(value)) {
return formatScalar(value);
}
return stringify(value).trim();
}
function isScalar(value: unknown): value is string | number | boolean | null {
return value === null || ["string", "number", "boolean"].includes(typeof value);
}
function formatScalar(value: string | number | boolean | null): string {
return value === null ? "null" : String(value);
}
@@ -0,0 +1,95 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { parseMarkdownPreviewDocument } from "./document";
export function FileMarkdownPreview({ source }: { source: string }) {
const document = useMemo(() => parseMarkdownPreviewDocument(source), [source]);
return (
<View style={styles.outerGutter}>
<View style={styles.readingFrame}>
{document.frontMatter.length > 0 ? (
<View style={styles.frontMatterTable} testID="markdown-front-matter">
{document.frontMatter.map((row, index) => (
<View
key={row.key}
style={[styles.frontMatterRow, index > 0 && styles.frontMatterRowBorder]}
>
<View style={styles.frontMatterKeyCell}>
<Text selectable style={styles.frontMatterKey}>
{row.key}
</Text>
</View>
<View style={styles.frontMatterValueCell}>
<Text selectable style={styles.frontMatterValue}>
{row.value}
</Text>
</View>
</View>
))}
</View>
) : null}
<MarkdownRenderer text={document.body} />
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
outerGutter: {
width: "100%",
paddingHorizontal: {
xs: theme.spacing[3],
md: theme.spacing[4],
},
paddingVertical: theme.spacing[4],
},
readingFrame: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
frontMatterTable: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
marginBottom: theme.spacing[6],
},
frontMatterRow: {
flexDirection: "row",
alignItems: "stretch",
},
frontMatterRowBorder: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
},
frontMatterKeyCell: {
flexBasis: "30%",
maxWidth: 180,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface2,
},
frontMatterValueCell: {
flex: 1,
minWidth: 0,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
frontMatterKey: {
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
frontMatterValue: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
}));
+2 -6
View File
@@ -12,7 +12,6 @@ import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/dae
import { Image as RNImage, ScrollView as RNScrollView, Text, View } from "react-native";
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
@@ -36,6 +35,7 @@ import { useAppSettings } from "@/hooks/use-settings";
import { useLiveFile } from "./live-file/hook";
import { FilePanelBar } from "./bar";
import { FileHtmlPreview } from "./html-preview";
import { FileMarkdownPreview } from "./markdown-preview";
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
import { createFileObservationSource } from "./editor/observation-source";
import { FileEditorView } from "./editor/view";
@@ -305,10 +305,9 @@ function FilePreviewBody({
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewMarkdownScrollContent}
showsVerticalScrollIndicator
>
<MarkdownRenderer text={preview.content ?? ""} />
<FileMarkdownPreview source={preview.content ?? ""} />
</RNScrollView>
</View>
);
@@ -881,9 +880,6 @@ const styles = StyleSheet.create((theme) => ({
previewCodeScrollContent: {
padding: theme.spacing[4],
},
previewMarkdownScrollContent: {
padding: theme.spacing[4],
},
previewImageScrollContent: {
flexGrow: 1,
padding: theme.spacing[4],