From 23d7a8eaf9b31afae5d7d7d7f047eb2b36232403 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 12 Aug 2026 00:18:30 +0200 Subject: [PATCH] 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 --- nix/npm-deps.hash | 2 +- package-lock.json | 1 + packages/app/package.json | 1 + .../app/src/assistant-file-links/link.tsx | 40 ++------ .../src/components/markdown/link-children.ts | 21 ++++ .../app/src/components/markdown/link-text.tsx | 41 ++++++++ .../src/components/markdown/renderer.test.ts | 72 +++++++++++++- .../app/src/components/markdown/renderer.tsx | 14 ++- packages/app/src/components/message.tsx | 12 +-- .../markdown-preview/document.test.ts | 84 ++++++++++++++++ .../file-pane/markdown-preview/document.ts | 70 ++++++++++++++ .../src/file-pane/markdown-preview/index.tsx | 95 +++++++++++++++++++ packages/app/src/file-pane/pane.tsx | 8 +- 13 files changed, 411 insertions(+), 50 deletions(-) create mode 100644 packages/app/src/components/markdown/link-children.ts create mode 100644 packages/app/src/components/markdown/link-text.tsx create mode 100644 packages/app/src/file-pane/markdown-preview/document.test.ts create mode 100644 packages/app/src/file-pane/markdown-preview/document.ts create mode 100644 packages/app/src/file-pane/markdown-preview/index.tsx diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index c955eb4271..f2b4328a7f 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-m6anpp3ESvWCVQAoTsOhnTMpoewhpJTeVha2Gv4Xnc4= +sha256-L73o8u6OsY3SsA5t2s5mWsMsMNc3qr5bCE4cd1NpbgI= diff --git a/package-lock.json b/package-lock.json index de6b745bf5..e95d8602aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/packages/app/package.json b/packages/app/package.json index cd15f23a4e..18bdb283f9 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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" }, diff --git a/packages/app/src/assistant-file-links/link.tsx b/packages/app/src/assistant-file-links/link.tsx index d99188cff4..9b2838c3d2 100644 --- a/packages/app/src/assistant-file-links/link.tsx +++ b/packages/app/src/assistant-file-links/link.tsx @@ -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>( - () => [style, hovered && { textDecorationLine: "underline" as const }], - [style, hovered], - ); const linkPress = useMemo( () => ({ onPress, accessibilityRole: "link" }), [onPress], @@ -114,19 +97,14 @@ export function AssistantMarkdownLink({ onAuxClickCapture={preventAnchorNavigation} style={LINK_ANCHOR_STYLE} > - - - {children} - - + {children} + ); diff --git a/packages/app/src/components/markdown/link-children.ts b/packages/app/src/components/markdown/link-children.ts new file mode 100644 index 0000000000..9a81042c86 --- /dev/null +++ b/packages/app/src/components/markdown/link-children.ts @@ -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 }>(child)) return child; + return cloneElement(child, { style: [child.props.style, { color }] }); + }); +} + +const MARKDOWN_LINK_HOVER_STYLE: TextStyle = { textDecorationLine: "underline" }; + +export function markdownLinkTextStyle( + style: StyleProp, + hovered: boolean, +): StyleProp { + return [style, hovered && MARKDOWN_LINK_HOVER_STYLE]; +} diff --git a/packages/app/src/components/markdown/link-text.tsx b/packages/app/src/components/markdown/link-text.tsx new file mode 100644 index 0000000000..18b1b6c93b --- /dev/null +++ b/packages/app/src/components/markdown/link-text.tsx @@ -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; + dataSet?: Record; + 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 ( + + + {children} + + + ); +} diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 9412d46332..1dc279a2d5 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -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 }) => + createElement("span", { style: flattenStyle(style) }, children), +})); + +function flattenStyle(style: StyleProp): 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(); + }); +}); diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index ddb82ee337..d9b0cc2be1 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -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; @@ -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 ( + + {children} + + ); + } return ( - {children} + {colorMarkdownLinkChildren(children, styles.link.color)} ), }; diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index b6a0162fdf..1615c7b73c 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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 }; - return cloneElement(child, { - style: [childProps.style, { color: styles.link.color }], - } as Partial<{ style: StyleProp }>); - })} + {colorMarkdownLinkChildren(children, styles.link.color)} ), image: ( diff --git a/packages/app/src/file-pane/markdown-preview/document.test.ts b/packages/app/src/file-pane/markdown-preview/document.test.ts new file mode 100644 index 0000000000..020d7246d6 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/document.test.ts @@ -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, + }); + }); +}); diff --git a/packages/app/src/file-pane/markdown-preview/document.ts b/packages/app/src/file-pane/markdown-preview/document.ts new file mode 100644 index 0000000000..7e2fff5206 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/document.ts @@ -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 { + 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); +} diff --git a/packages/app/src/file-pane/markdown-preview/index.tsx b/packages/app/src/file-pane/markdown-preview/index.tsx new file mode 100644 index 0000000000..1e8a3bfbd6 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/index.tsx @@ -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 ( + + + {document.frontMatter.length > 0 ? ( + + {document.frontMatter.map((row, index) => ( + 0 && styles.frontMatterRowBorder]} + > + + + {row.key} + + + + + {row.value} + + + + ))} + + ) : null} + + + + ); +} + +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, + }, +})); diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 07b0ee2671..c412504e0c 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -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({ - + ); @@ -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],