diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index fd1300740..753bb9948 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -49,7 +49,7 @@ import { FileSymlink, } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { SPACING, type Theme } from "@/styles/theme"; +import { type Theme } from "@/styles/theme"; import { useIsCompactFormFactor } from "@/constants/layout"; import Animated, { Easing, @@ -67,7 +67,7 @@ import type { AgentAttachment } from "@server/shared/messages"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import { buildToolCallPresentation } from "@/tool-calls/presentation"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; -import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list"; +import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list"; import { useStableEvent } from "@/hooks/use-stable-event"; import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; @@ -1554,39 +1554,14 @@ function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphVi return {children}; } -// 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; + spacing: { marginTop: number; marginBottom: number }; children: ReactNode; } -function MarkdownListView({ baseStyle, marginBottom, children }: MarkdownListViewProps) { - const style = useMemo( - () => [baseStyle, { marginTop: MARKDOWN_LIST_MARGIN_TOP, marginBottom }], - [baseStyle, marginBottom], - ); +function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) { + const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]); return {children}; } @@ -1748,7 +1723,7 @@ export const AssistantMessage = memo(function AssistantMessage({ {children} @@ -1762,7 +1737,7 @@ export const AssistantMessage = memo(function AssistantMessage({ {children} diff --git a/packages/app/src/utils/markdown-list.test.ts b/packages/app/src/utils/markdown-list.test.ts index 91731fd02..a30a69a9e 100644 --- a/packages/app/src/utils/markdown-list.test.ts +++ b/packages/app/src/utils/markdown-list.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getMarkdownListMarker } from "./markdown-list"; +import { getMarkdownListMarker, getMarkdownListSpacing } from "./markdown-list"; describe("getMarkdownListMarker", () => { it("returns a bullet marker for unordered list items", () => { @@ -39,3 +39,49 @@ describe("getMarkdownListMarker", () => { }); }); }); + +describe("getMarkdownListSpacing", () => { + it("keeps top-level list spacing as a section boundary", () => { + const paragraph = { type: "paragraph" }; + const list = { type: "bullet_list" }; + const body = { type: "body", children: [list, paragraph] }; + + expect(getMarkdownListSpacing(list, [body])).toEqual({ + marginTop: 4, + marginBottom: 16, + }); + }); + + it("does not add bottom spacing after a list at the end of a markdown block", () => { + const list = { type: "bullet_list" }; + const body = { type: "body", children: [list] }; + + expect(getMarkdownListSpacing(list, [body])).toEqual({ + marginTop: 4, + marginBottom: 0, + }); + }); + + it("uses a smaller gap between adjacent top-level lists", () => { + const list = { type: "bullet_list" }; + const body = { type: "body", children: [list, { type: "ordered_list" }] }; + + expect(getMarkdownListSpacing(list, [body])).toEqual({ + marginTop: 4, + marginBottom: 8, + }); + }); + + it("does not add section spacing after a nested list", () => { + expect( + getMarkdownListSpacing({ type: "bullet_list" }, [ + { type: "list_item" }, + { type: "bullet_list" }, + { type: "body" }, + ]), + ).toEqual({ + marginTop: 4, + marginBottom: 0, + }); + }); +}); diff --git a/packages/app/src/utils/markdown-list.ts b/packages/app/src/utils/markdown-list.ts index be9cbdc4f..3dd515024 100644 --- a/packages/app/src/utils/markdown-list.ts +++ b/packages/app/src/utils/markdown-list.ts @@ -1,3 +1,5 @@ +import { SPACING } from "@/styles/theme"; + interface MarkdownNode { type?: string; index?: number; @@ -10,6 +12,11 @@ interface MarkdownNode { const LIST_BULLET = "•"; const DEFAULT_ORDERED_LIST_MARKUP = "."; +const MARKDOWN_LIST_MARGIN_TOP = SPACING[1]; +const MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE = SPACING[4]; +const MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST = SPACING[2]; +const MARKDOWN_NESTED_LIST_MARGIN_BOTTOM = 0; +const MARKDOWN_TERMINAL_LIST_MARGIN_BOTTOM = 0; function toParentNodes(parent: unknown): MarkdownNode[] { if (Array.isArray(parent)) { @@ -76,6 +83,41 @@ export function getMarkdownNextSiblingType( return undefined; } +function isListType(type: string | undefined): boolean { + return type === "bullet_list" || type === "ordered_list"; +} + +function hasListItemAncestor(parent: unknown): boolean { + return toParentNodes(parent).some((ancestor) => ancestor?.type === "list_item"); +} + +export function getMarkdownListSpacing( + node: MarkdownNode, + parent: unknown, +): { marginTop: number; marginBottom: number } { + if (hasListItemAncestor(parent)) { + return { + marginTop: MARKDOWN_LIST_MARGIN_TOP, + marginBottom: MARKDOWN_NESTED_LIST_MARGIN_BOTTOM, + }; + } + + const nextType = getMarkdownNextSiblingType(node, parent); + if (!nextType) { + return { + marginTop: MARKDOWN_LIST_MARGIN_TOP, + marginBottom: MARKDOWN_TERMINAL_LIST_MARGIN_BOTTOM, + }; + } + + return { + marginTop: MARKDOWN_LIST_MARGIN_TOP, + marginBottom: isListType(nextType) + ? MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST + : MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE, + }; +} + export function getMarkdownListMarker( node: MarkdownNode, parent: unknown,