Tighten markdown list spacing around prose
Lists previously had a static marginVertical that stacked with paragraph.marginBottom, producing 20px gaps and oversized whitespace around paragraphs sandwiched between lists. Spacing is now context-aware via the render rule: list -> list keeps the normal paragraph gap (back-to-back lists read as continuous content) while p <-> list transitions get a slightly larger gap (lists read as their own section). Static margins can't satisfy both because RN doesn't collapse margins, so the bullet_list / ordered_list rule inspects the next sibling type and picks marginBottom accordingly.
This commit is contained in:
@@ -49,7 +49,7 @@ import {
|
|||||||
FileSymlink,
|
FileSymlink,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import type { Theme } from "@/styles/theme";
|
import { SPACING, type Theme } from "@/styles/theme";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -69,7 +69,7 @@ import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
|||||||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||||
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
||||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list";
|
||||||
import { type AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver";
|
import { type AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver";
|
||||||
import { useAssistantFileLinkResolver } from "@/hooks/use-assistant-file-link-resolver";
|
import { useAssistantFileLinkResolver } from "@/hooks/use-assistant-file-link-resolver";
|
||||||
import type { ToastApi } from "@/components/toast-host";
|
import type { ToastApi } from "@/components/toast-host";
|
||||||
@@ -1651,6 +1651,42 @@ function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphVi
|
|||||||
return <View style={style}>{children}</View>;
|
return <View style={style}>{children}</View>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List spacing in markdown:
|
||||||
|
// - p -> list and list -> p use a slightly larger gap than p -> p, so lists
|
||||||
|
// read as their own section against surrounding prose.
|
||||||
|
// - list -> list keeps the normal p-to-p gap; back-to-back lists are
|
||||||
|
// continuous content, not section breaks.
|
||||||
|
//
|
||||||
|
// Paragraph's marginBottom is SPACING[3] = 12 (and marginTop is 0). To produce
|
||||||
|
// 16px gaps on p<->list transitions and 12px on list<->list, we add a constant
|
||||||
|
// marginTop on lists (4) and switch marginBottom by next-sibling type:
|
||||||
|
// p -> list = p.marginBottom(12) + list.marginTop(4) = 16
|
||||||
|
// list -> p = list.marginBottom(16) + p.marginTop(0) = 16
|
||||||
|
// list -> list = list.marginBottom(8) + list.marginTop(4) = 12
|
||||||
|
const MARKDOWN_LIST_MARGIN_TOP = SPACING[1]; // 4
|
||||||
|
const MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE = SPACING[4]; // 16
|
||||||
|
const MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST = SPACING[2]; // 8
|
||||||
|
|
||||||
|
function getMarkdownListContextMarginBottom(node: ASTNode, parent: ASTNode[]): number {
|
||||||
|
const nextType = getMarkdownNextSiblingType(node, parent);
|
||||||
|
const nextIsList = nextType === "bullet_list" || nextType === "ordered_list";
|
||||||
|
return nextIsList ? MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST : MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkdownListViewProps {
|
||||||
|
baseStyle: ViewStyle;
|
||||||
|
marginBottom: number;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownListView({ baseStyle, marginBottom, children }: MarkdownListViewProps) {
|
||||||
|
const style = useMemo(
|
||||||
|
() => [baseStyle, { marginTop: MARKDOWN_LIST_MARGIN_TOP, marginBottom }],
|
||||||
|
[baseStyle, marginBottom],
|
||||||
|
);
|
||||||
|
return <View style={style}>{children}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
export const AssistantMessage = memo(function AssistantMessage({
|
export const AssistantMessage = memo(function AssistantMessage({
|
||||||
message,
|
message,
|
||||||
timestamp: _timestamp,
|
timestamp: _timestamp,
|
||||||
@@ -1823,22 +1859,30 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
bullet_list: (
|
bullet_list: (
|
||||||
node: ASTNode,
|
node: ASTNode,
|
||||||
children: ReactNode[],
|
children: ReactNode[],
|
||||||
_parent: ASTNode[],
|
parent: ASTNode[],
|
||||||
styles: MarkdownStyles,
|
styles: MarkdownStyles,
|
||||||
) => (
|
) => (
|
||||||
<View key={node.key} style={styles.bullet_list}>
|
<MarkdownListView
|
||||||
|
key={node.key}
|
||||||
|
baseStyle={styles.bullet_list}
|
||||||
|
marginBottom={getMarkdownListContextMarginBottom(node, parent)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</MarkdownListView>
|
||||||
),
|
),
|
||||||
ordered_list: (
|
ordered_list: (
|
||||||
node: ASTNode,
|
node: ASTNode,
|
||||||
children: ReactNode[],
|
children: ReactNode[],
|
||||||
_parent: ASTNode[],
|
parent: ASTNode[],
|
||||||
styles: MarkdownStyles,
|
styles: MarkdownStyles,
|
||||||
) => (
|
) => (
|
||||||
<View key={node.key} style={styles.ordered_list}>
|
<MarkdownListView
|
||||||
|
key={node.key}
|
||||||
|
baseStyle={styles.ordered_list}
|
||||||
|
marginBottom={getMarkdownListContextMarginBottom(node, parent)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</MarkdownListView>
|
||||||
),
|
),
|
||||||
list_item: (
|
list_item: (
|
||||||
node: ASTNode,
|
node: ASTNode,
|
||||||
@@ -2567,6 +2611,26 @@ function ExpandableBadgeLabelRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HACK: lucide ships every icon inside a 24×24 viewBox where the path
|
||||||
|
// doesn't touch the edges — there's per-icon internal padding. The layout
|
||||||
|
// already places the SVG element's box on the rail, but the visible glyph
|
||||||
|
// inside the SVG sits inset by a few pixels (and the inset amount differs
|
||||||
|
// per icon — chevron-right paints only in the right half of its viewBox,
|
||||||
|
// regular tool icons paint roughly the full viewBox minus ~1 unit margin).
|
||||||
|
//
|
||||||
|
// Lucide has no viewBox knob, so the only way to nudge the visible glyph
|
||||||
|
// flush with the rail is a per-icon negative margin. Cosmetic; not exact —
|
||||||
|
// every lucide icon has slightly different padding and we're not measuring
|
||||||
|
// each one. Two buckets is the compromise:
|
||||||
|
// - LUCIDE_TOOL_ICON_NUDGE_LEFT: regular tool icons (path mostly fills
|
||||||
|
// the viewBox); needs ~1px left shift.
|
||||||
|
// - LUCIDE_CHEVRON_NUDGE_LEFT: chevron-right (path in right half of
|
||||||
|
// viewBox, and we scale it 1.3×); needs ~4px left shift.
|
||||||
|
// If we ever want this exact, the principled fix is a custom <Svg> wrapper
|
||||||
|
// with a tight viewBox per icon — see option (2) in the design discussion.
|
||||||
|
const LUCIDE_TOOL_ICON_NUDGE_LEFT: ViewStyle = { marginLeft: -1 };
|
||||||
|
const LUCIDE_CHEVRON_NUDGE_LEFT: ViewStyle = { marginLeft: -4 };
|
||||||
|
|
||||||
function renderExpandableBadgeIcon({
|
function renderExpandableBadgeIcon({
|
||||||
isError,
|
isError,
|
||||||
isActive,
|
isActive,
|
||||||
@@ -2577,14 +2641,20 @@ function renderExpandableBadgeIcon({
|
|||||||
ThemedIcon: ComponentType<{ size?: number; uniProps?: typeof foregroundColorMapping }> | null;
|
ThemedIcon: ComponentType<{ size?: number; uniProps?: typeof foregroundColorMapping }> | null;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return <ThemedTriangleAlertIcon size={12} opacity={0.8} uniProps={destructiveColorMapping} />;
|
return (
|
||||||
|
<View style={LUCIDE_TOOL_ICON_NUDGE_LEFT}>
|
||||||
|
<ThemedTriangleAlertIcon size={12} opacity={0.8} uniProps={destructiveColorMapping} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (ThemedIcon) {
|
if (ThemedIcon) {
|
||||||
return (
|
return (
|
||||||
<ThemedIcon
|
<View style={LUCIDE_TOOL_ICON_NUDGE_LEFT}>
|
||||||
size={12}
|
<ThemedIcon
|
||||||
uniProps={isActive ? foregroundColorMapping : mutedForegroundColorMapping}
|
size={12}
|
||||||
/>
|
uniProps={isActive ? foregroundColorMapping : mutedForegroundColorMapping}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -2942,6 +3012,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
() => [
|
() => [
|
||||||
expandableBadgeStylesheet.chevron,
|
expandableBadgeStylesheet.chevron,
|
||||||
isExpanded && expandableBadgeStylesheet.chevronExpanded,
|
isExpanded && expandableBadgeStylesheet.chevronExpanded,
|
||||||
|
LUCIDE_CHEVRON_NUDGE_LEFT,
|
||||||
],
|
],
|
||||||
[isExpanded],
|
[isExpanded],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -252,13 +252,11 @@ export function createMarkdownStyles(theme: Theme) {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
bullet_list: {
|
bullet_list: {
|
||||||
marginVertical: theme.spacing[2],
|
|
||||||
paddingLeft: 0,
|
paddingLeft: 0,
|
||||||
width: "100%" as const,
|
width: "100%" as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
ordered_list: {
|
ordered_list: {
|
||||||
marginVertical: theme.spacing[2],
|
|
||||||
paddingLeft: 0,
|
paddingLeft: 0,
|
||||||
width: "100%" as const,
|
width: "100%" as const,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -60,6 +60,22 @@ function parseOrderedListStart(node: MarkdownNode): number {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMarkdownNextSiblingType(
|
||||||
|
node: MarkdownNode,
|
||||||
|
parent: unknown,
|
||||||
|
): string | undefined {
|
||||||
|
const ancestors = toParentNodes(parent);
|
||||||
|
for (let i = ancestors.length - 1; i >= 0; i--) {
|
||||||
|
const ancestor = ancestors[i];
|
||||||
|
if (!Array.isArray(ancestor?.children)) continue;
|
||||||
|
const idx = ancestor.children.indexOf(node);
|
||||||
|
if (idx >= 0) {
|
||||||
|
return ancestor.children[idx + 1]?.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function getMarkdownListMarker(
|
export function getMarkdownListMarker(
|
||||||
node: MarkdownNode,
|
node: MarkdownNode,
|
||||||
parent: unknown,
|
parent: unknown,
|
||||||
|
|||||||
Reference in New Issue
Block a user