diff --git a/web/src/components/MemoContent/ConditionalComponent.tsx b/web/src/components/MemoContent/ConditionalComponent.tsx
deleted file mode 100644
index 6bb1142e6..000000000
--- a/web/src/components/MemoContent/ConditionalComponent.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { Element } from "hast";
-import React from "react";
-import { isMentionElement, isTagElement, isTaskListItemElement } from "@/types/markdown";
-
-/**
- * Creates a conditional component that renders different components
- * based on AST node type detection
- *
- * @param CustomComponent - Custom component to render when condition matches
- * @param DefaultComponent - Default component/element to render otherwise
- * @param condition - Function to test AST node
- * @returns Conditional wrapper component
- */
-export const createConditionalComponent =
>(
- CustomComponent: React.ComponentType
,
- DefaultComponent: React.ComponentType
| keyof JSX.IntrinsicElements,
- condition: (node: Element) => boolean,
-) => {
- return (props: P & { node?: Element }) => {
- const { node, ...restProps } = props;
-
- // Check AST node to determine which component to use
- if (node && condition(node)) {
- return ;
- }
-
- // Render default component/element
- if (typeof DefaultComponent === "string") {
- return React.createElement(DefaultComponent, restProps);
- }
- return ;
- };
-};
-
-// Re-export type guards for convenience
-export { isMentionElement as isMentionNode, isTagElement as isTagNode, isTaskListItemElement as isTaskListItemNode };
diff --git a/web/src/components/MemoContent/MemoMarkdownRenderer.tsx b/web/src/components/MemoContent/MemoMarkdownRenderer.tsx
new file mode 100644
index 000000000..159c6f4dd
--- /dev/null
+++ b/web/src/components/MemoContent/MemoMarkdownRenderer.tsx
@@ -0,0 +1,139 @@
+import type { Element } from "hast";
+import type { Components } from "react-markdown";
+import ReactMarkdown from "react-markdown";
+import rehypeKatex from "rehype-katex";
+import rehypeRaw from "rehype-raw";
+import rehypeSanitize from "rehype-sanitize";
+import remarkBreaks from "remark-breaks";
+import remarkGfm from "remark-gfm";
+import remarkMath from "remark-math";
+import { isMentionElement, isTagElement, isTaskListItemElement } from "@/types/markdown";
+import { rehypeHeadingId } from "@/utils/rehype-plugins/rehype-heading-id";
+import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
+import { remarkMention } from "@/utils/remark-plugins/remark-mention";
+import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
+import { remarkSplitMixedTaskLists } from "@/utils/remark-plugins/remark-split-mixed-task-lists";
+import { remarkTag } from "@/utils/remark-plugins/remark-tag";
+import { CodeBlock } from "./CodeBlock";
+import { SANITIZE_SCHEMA } from "./constants";
+import { Mention } from "./Mention";
+import { Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
+import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
+import { Tag } from "./Tag";
+import { TaskListItem } from "./TaskListItem";
+import { TrustedIframe } from "./TrustedIframe";
+
+interface MemoMarkdownRendererProps {
+ content: string;
+ resolvedMentionUsernames: Set;
+}
+
+function getMentionUsername(node: Element, children?: React.ReactNode): string {
+ const dataMention = node.properties?.["data-mention"];
+ if (typeof dataMention === "string" && dataMention !== "") {
+ return dataMention;
+ }
+
+ const camelDataMention = (node.properties as Record | undefined)?.dataMention;
+ if (typeof camelDataMention === "string" && camelDataMention !== "") {
+ return camelDataMention;
+ }
+
+ const text = Array.isArray(children) ? children.join("") : children;
+ if (typeof text === "string" && text.startsWith("@")) {
+ return text.slice(1).toLowerCase();
+ }
+
+ return "";
+}
+
+export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames }: MemoMarkdownRendererProps) => {
+ const markdownComponents: Components = {
+ input: ({ node, ...inputProps }) => {
+ if (node && isTaskListItemElement(node)) {
+ return ;
+ }
+ return ;
+ },
+ span: ({ node, ...spanProps }) => {
+ if (node && isMentionElement(node)) {
+ const username = getMentionUsername(node, spanProps.children);
+ return ;
+ }
+ if (node && isTagElement(node)) {
+ return ;
+ }
+ return ;
+ },
+ h1: ({ children, ...props }) => (
+
+ {children}
+
+ ),
+ h2: ({ children, ...props }) => (
+
+ {children}
+
+ ),
+ h3: ({ children, ...props }) => (
+
+ {children}
+
+ ),
+ h4: ({ children, ...props }) => (
+
+ {children}
+
+ ),
+ h5: ({ children, ...props }) => (
+
+ {children}
+
+ ),
+ h6: ({ children, ...props }) => (
+
+ {children}
+
+ ),
+ p: ({ children, ...props }) => {children},
+ blockquote: ({ children, ...props }) => {children}
,
+ hr: (props) => ,
+ ul: ({ children, ...props }) => {children}
,
+ ol: ({ children, ...props }) => (
+
+ {children}
+
+ ),
+ li: ({ children, ...props }) => {children},
+ a: ({ children, ...props }) => {children},
+ code: ({ children, ...props }) => {children},
+ iframe: TrustedIframe,
+ img: (props) => ,
+ pre: CodeBlock,
+ table: ({ children, ...props }) => ,
+ thead: ({ children, ...props }) => {children},
+ tbody: ({ children, ...props }) => {children},
+ tr: ({ children, ...props }) => {children},
+ th: ({ children, ...props }) => {children},
+ td: ({ children, ...props }) => {children},
+ };
+
+ return (
+
+ {content}
+
+ );
+};
diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx
index 232d434bd..67fa19953 100644
--- a/web/src/components/MemoContent/index.tsx
+++ b/web/src/components/MemoContent/index.tsx
@@ -1,53 +1,14 @@
-import type { Element } from "hast";
import { ChevronDown, ChevronUp } from "lucide-react";
import { memo, useMemo } from "react";
-import ReactMarkdown from "react-markdown";
-import rehypeKatex from "rehype-katex";
-import rehypeRaw from "rehype-raw";
-import rehypeSanitize from "rehype-sanitize";
-import remarkBreaks from "remark-breaks";
-import remarkGfm from "remark-gfm";
-import remarkMath from "remark-math";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
-import { rehypeHeadingId } from "@/utils/rehype-plugins/rehype-heading-id";
-import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
-import { extractMentionUsernames, remarkMention } from "@/utils/remark-plugins/remark-mention";
-import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
-import { remarkSplitMixedTaskLists } from "@/utils/remark-plugins/remark-split-mixed-task-lists";
-import { remarkTag } from "@/utils/remark-plugins/remark-tag";
-import { CodeBlock } from "./CodeBlock";
-import { isMentionNode, isTagNode, isTaskListItemNode } from "./ConditionalComponent";
-import { COMPACT_MODE_CONFIG, SANITIZE_SCHEMA } from "./constants";
+import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention";
+import { COMPACT_MODE_CONFIG } from "./constants";
import { useCompactLabel, useCompactMode } from "./hooks";
-import { Mention } from "./Mention";
+import { MemoMarkdownRenderer } from "./MemoMarkdownRenderer";
import { useResolvedMentionUsernames } from "./MentionResolutionContext";
-import { Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
-import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
-import { Tag } from "./Tag";
-import { TaskListItem } from "./TaskListItem";
-import { TrustedIframe } from "./TrustedIframe";
import type { MemoContentProps } from "./types";
-function getMentionUsername(node: Element, children?: React.ReactNode): string {
- const dataMention = node.properties?.["data-mention"];
- if (typeof dataMention === "string" && dataMention !== "") {
- return dataMention;
- }
-
- const camelDataMention = (node.properties as Record | undefined)?.dataMention;
- if (typeof camelDataMention === "string" && camelDataMention !== "") {
- return camelDataMention;
- }
-
- const text = Array.isArray(children) ? children.join("") : children;
- if (typeof text === "string" && text.startsWith("@")) {
- return text.slice(1).toLowerCase();
- }
-
- return "";
-}
-
const MemoContent = (props: MemoContentProps) => {
const { className, contentClassName, content, onClick, onDoubleClick } = props;
const t = useTranslate();
@@ -79,104 +40,7 @@ const MemoContent = (props: MemoContentProps) => {
onMouseUp={onClick}
onDoubleClick={onDoubleClick}
>
- & { node?: Element }) => {
- const { node, ...rest } = inputProps;
- if (node && isTaskListItemNode(node)) {
- return ;
- }
- return ;
- }) as React.ComponentType>,
- span: ((spanProps: React.ComponentProps<"span"> & { node?: Element }) => {
- const { node, ...rest } = spanProps;
- if (node && isMentionNode(node)) {
- const username = getMentionUsername(node, spanProps.children);
- return ;
- }
- if (node && isTagNode(node)) {
- return ;
- }
- return ;
- }) as React.ComponentType>,
- // Headings
- h1: ({ children, ...props }) => (
-
- {children}
-
- ),
- h2: ({ children, ...props }) => (
-
- {children}
-
- ),
- h3: ({ children, ...props }) => (
-
- {children}
-
- ),
- h4: ({ children, ...props }) => (
-
- {children}
-
- ),
- h5: ({ children, ...props }) => (
-
- {children}
-
- ),
- h6: ({ children, ...props }) => (
-
- {children}
-
- ),
- // Block elements
- p: ({ children, ...props }) => {children},
- blockquote: ({ children, ...props }) => {children}
,
- hr: (props) => ,
- // Lists
- ul: ({ children, ...props }) => {children}
,
- ol: ({ children, ...props }) => (
-
- {children}
-
- ),
- li: ({ children, ...props }) => {children},
- // Inline elements
- a: ({ children, ...props }) => {children},
- code: ({ children, ...props }) => {children},
- iframe: TrustedIframe as React.ComponentType>,
- img: ({ ...props }) => ,
- // Code blocks
- pre: CodeBlock,
- // Tables
- table: ({ children, ...props }) => ,
- thead: ({ children, ...props }) => {children},
- tbody: ({ children, ...props }) => {children},
- tr: ({ children, ...props }) => {children},
- th: ({ children, ...props }) => {children},
- td: ({ children, ...props }) => {children},
- }}
- >
- {content}
-
+
{showCompactMode === "ALL" && (
, ReactMark
/**
* List item component for both regular and task list items
* Detects task items via the "task-list-item" class added by remark-gfm
- * Applies specialized styling for task checkboxes
*/
export const ListItem = ({ children, className, node: _node, ...domProps }: ListItemProps) => {
const isTaskListItem = className?.includes(TASK_LIST_ITEM_CLASS);
@@ -49,11 +48,9 @@ export const ListItem = ({ children, className, node: _node, ...domProps }: List
return (
button]:mr-2 [&>button]:align-middle",
- // Inline paragraph for task text
- "[&>p]:inline [&>p]:m-0",
+ "mt-0.5 leading-6 list-none grid grid-cols-[auto_1fr] items-center gap-x-2",
+ "[&>ul]:col-start-2 [&>ul]:col-span-1 [&>ol]:col-start-2 [&>ol]:col-span-1",
+ "[&>p:first-child]:contents [&>p:not(:first-child)]:col-start-2 [&>p:not(:first-child)]:col-span-1",
className,
)}
{...domProps}
diff --git a/web/src/components/MemoContent/markdown/README.md b/web/src/components/MemoContent/markdown/README.md
index a6ba08cc7..ece9757af 100644
--- a/web/src/components/MemoContent/markdown/README.md
+++ b/web/src/components/MemoContent/markdown/README.md
@@ -1,97 +1,18 @@
# Markdown Components
-Modern, type-safe React components for rendering markdown content via react-markdown.
+Small React components used by `MemoMarkdownRenderer` to style HTML emitted by `react-markdown`.
-## Architecture
+## Responsibilities
-### Component-Based Rendering
-Following patterns from popular AI chat apps (ChatGPT, Claude, Perplexity), we use React components instead of CSS selectors for markdown rendering. This provides:
+- Keep element styling local to each semantic HTML element.
+- Strip the `node` prop from DOM output through `ReactMarkdownProps`.
+- Preserve existing markdown behavior while avoiding structural fixes in CSS.
-- **Type Safety**: Full TypeScript support with proper prop types
-- **Maintainability**: Components are easier to test, modify, and understand
-- **Performance**: No CSS specificity conflicts, cleaner DOM
-- **Modularity**: Each element is independently styled and documented
+## Task Lists
-### Type System
+GFM task lists are normalized before rendering by `remarkSplitMixedTaskLists`.
-All components extend `ReactMarkdownProps` which includes the AST `node` prop passed by react-markdown. This is explicitly destructured as `node: _node` to:
-1. Filter it from DOM props (avoids `node="[object Object]"` in HTML)
-2. Keep it available for advanced use cases (e.g., detecting task lists)
-3. Maintain type safety without `as any` casts
-
-### GFM Task Lists
-
-Task lists (from remark-gfm) are handled by:
-- **Detection**: `contains-task-list` and `task-list-item` classes from remark-gfm
-- **Styling**: Tailwind utilities with arbitrary variants for nested elements
-- **Checkboxes**: Custom `TaskListItem` component with Radix UI checkbox
-- **Interactivity**: Updates memo content via `toggleTaskAtIndex` utility
-
-### Component Patterns
-
-Each component follows this structure:
-```tsx
-import { cn } from "@/lib/utils";
-import type { ReactMarkdownProps } from "./types";
-
-interface ComponentProps extends React.HTMLAttributes, ReactMarkdownProps {
- children?: React.ReactNode;
- // component-specific props
-}
-
-/**
- * JSDoc description
- */
-export const Component = ({ children, className, node: _node, ...props }: ComponentProps) => {
- return (
-
- {children}
-
- );
-};
-```
-
-## Components
-
-| Component | Element | Purpose |
-|-----------|---------|---------|
-| `Heading` | h1-h6 | Semantic headings with level-based styling |
-| `Paragraph` | p | Compact paragraphs with consistent spacing |
-| `Link` | a | External links with security attributes |
-| `List` | ul/ol | Regular and GFM task lists |
-| `ListItem` | li | List items with task checkbox support |
-| `Blockquote` | blockquote | Quotes with left border accent |
-| `InlineCode` | code | Inline code with background |
-| `Image` | img | Responsive images with rounded corners |
-| `HorizontalRule` | hr | Section separators |
-
-## Styling Approach
-
-- **Tailwind CSS**: All styling uses Tailwind utilities
-- **Design Tokens**: Colors use CSS variables (e.g., `--primary`, `--muted-foreground`)
-- **Responsive**: Max-width constraints, responsive images
-- **Accessibility**: Semantic HTML, proper ARIA attributes via Radix UI
-
-## Integration
-
-Components are mapped to HTML elements in `MemoContent/index.tsx`:
-
-```tsx
- {children},
- p: ({ children, ...props }) => {children},
- // ... more mappings
- }}
->
- {content}
-
-```
-
-## Future Enhancements
-
-- [ ] Syntax highlighting themes for code blocks
-- [ ] Table sorting/filtering interactions
-- [ ] Image lightbox/zoom functionality
-- [ ] Collapsible sections for long content
-- [ ] Copy button for code blocks
+- Mixed task/bullet lists are split into separate lists so regular bullets keep bullets.
+- Single-block split items are rendered as tight list items, preventing accidental `` wrappers.
+- `ListItem` uses a two-column grid: checkbox/control in the first column, task text and nested content in the second.
+- Loose task items keep paragraph structure; the first paragraph contributes its checkbox/text to the grid, while later paragraphs align with the text column.
diff --git a/web/src/utils/remark-plugins/remark-split-mixed-task-lists.ts b/web/src/utils/remark-plugins/remark-split-mixed-task-lists.ts
index 5957ebc83..4a1d9a99e 100644
--- a/web/src/utils/remark-plugins/remark-split-mixed-task-lists.ts
+++ b/web/src/utils/remark-plugins/remark-split-mixed-task-lists.ts
@@ -1,11 +1,18 @@
import type { List, ListItem, Root } from "mdast";
import type { Parent } from "unist";
-const isTaskListItem = (item: ListItem): boolean => typeof item.checked === "boolean";
+const isTaskItem = (item: ListItem): boolean => typeof item.checked === "boolean";
+
+const isSingleBlockItem = (item: ListItem): boolean => item.children.length <= 1;
+
+const hasLooseBlockItem = (item: ListItem): boolean => !isSingleBlockItem(item) && Boolean(item.spread);
+
+const normalizeSplitListItems = (items: ListItem[]): ListItem[] =>
+ items.map((item) => (isSingleBlockItem(item) ? { ...item, spread: false } : item));
const splitMixedList = (list: List): List[] => {
- const hasTaskItem = list.children.some(isTaskListItem);
- const hasRegularItem = list.children.some((item) => !isTaskListItem(item));
+ const hasTaskItem = list.children.some(isTaskItem);
+ const hasRegularItem = list.children.some((item) => !isTaskItem(item));
if (!hasTaskItem || !hasRegularItem) {
return [list];
@@ -13,7 +20,7 @@ const splitMixedList = (list: List): List[] => {
const groups: Array<{ isTaskGroup: boolean; items: ListItem[] }> = [];
for (const item of list.children) {
- const isTaskGroup = isTaskListItem(item);
+ const isTaskGroup = isTaskItem(item);
const previousGroup = groups.at(-1);
if (previousGroup && previousGroup.isTaskGroup === isTaskGroup) {
@@ -23,11 +30,14 @@ const splitMixedList = (list: List): List[] => {
}
}
- return groups.map(({ isTaskGroup, items }) => ({
- ...list,
- children: isTaskGroup ? items : items.map((item) => ({ ...item, spread: false })),
- spread: isTaskGroup ? list.spread : false,
- }));
+ return groups.map(({ items }) => {
+ const children = normalizeSplitListItems(items);
+ return {
+ ...list,
+ children,
+ spread: children.some(hasLooseBlockItem),
+ };
+ });
};
const splitMixedTaskListsInParent = (parent: Parent): void => {
diff --git a/web/tests/memo-content-list.test.tsx b/web/tests/memo-content-list.test.tsx
index cfc6ecb4a..9b5abfd01 100644
--- a/web/tests/memo-content-list.test.tsx
+++ b/web/tests/memo-content-list.test.tsx
@@ -34,6 +34,8 @@ describe("memo content lists", () => {
expect(html).toContain('
milk');
expect(html).not.toContain('\nmilk
');
expect(html).toContain(TASK_LIST_ITEM_CLASS);
+ expect(html).toContain("grid grid-cols-[auto_1fr] items-center gap-x-2");
+ expect(html).not.toMatch(/ {
@@ -41,5 +43,25 @@ describe("memo content lists", () => {
expect(html).toMatch(/
{
+ const html = renderListContent("- [ ] asdas\n - [ ] zzzz");
+
+ expect(html).toContain("grid grid-cols-[auto_1fr] items-center gap-x-2");
+ expect(html).toContain("[&>ul]:col-start-2");
+ expect(html).not.toContain("[&_ul.contains-task-list]:ml-6");
+ expect(html).toContain("zzzz");
+ });
+
+ it("keeps loose task list paragraphs while aligning the first line", () => {
+ const html = renderListContent("- [ ] plan\n\n keep details\n\n- [ ] zzzz");
+
+ expect(html).toMatch(/
- \s*
/);
+ expect(html).toContain("[&>p:first-child]:contents");
+ expect(html).toContain("[&>p:not(:first-child)]:col-start-2");
+ expect(html).toContain("
keep details
");
+ expect(html).toContain("zzzz");
});
});