Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions packages/ui/components/ListMarker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React from 'react';

/**
* Shared list-item marker used by both the main Viewer and the plan-diff
* clean view. Renders the appropriate glyph(s) for a list item given its
* level, ordered flag, display index, and checkbox state.
*
* Rendering rules:
* - Ordered items render `${orderedIndex}.` with tabular-nums so digit
* widths stay stable across e.g. `9.` → `10.`.
* - Checkbox items render the circle / checkmark SVG.
* - Ordered + checkbox renders BOTH: numeral first, checkbox second
* (matches GitHub's `1. [ ] task` rendering).
* - Plain bullets fall back to `•` / `◦` / `▪` by level.
*
* Interactivity is opt-in: the Viewer passes `interactive` + `onToggle`
* for click-to-toggle checkboxes; the diff view leaves both undefined.
*/
interface ListMarkerProps {
level: number;
ordered?: boolean;
orderedIndex?: number | null;
checked?: boolean; // undefined = not a checkbox
interactive?: boolean;
onToggle?: () => void;
}

const BULLET_BY_LEVEL = ['\u2022', '\u25E6', '\u25AA'];

export const ListMarker: React.FC<ListMarkerProps> = ({
level,
ordered,
orderedIndex,
checked,
interactive,
onToggle,
}) => {
const isCheckbox = checked !== undefined;
const showNumeral = !!ordered && orderedIndex != null;
const bullet = BULLET_BY_LEVEL[Math.min(level, BULLET_BY_LEVEL.length - 1)];

const handleClick = interactive && onToggle
? (e: React.MouseEvent) => { e.stopPropagation(); onToggle(); }
: undefined;

return (
<span
className={`select-none shrink-0 flex items-center gap-1${interactive ? ' cursor-pointer' : ''}`}
onClick={handleClick}
role={interactive ? 'checkbox' : undefined}
aria-checked={interactive ? checked : undefined}
>
{showNumeral && (
<span className="text-primary/60 tabular-nums text-right" style={{ minWidth: '1.5rem' }}>
{orderedIndex}.
</span>
)}
{isCheckbox ? (
checked ? (
<svg className="w-4 h-4 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : (
<svg className={`w-4 h-4 text-muted-foreground/50${interactive ? ' hover:text-muted-foreground transition-colors' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="9" />
</svg>
)
) : !showNumeral ? (
<span className="text-primary/60">{bullet}</span>
) : null}
</span>
);
};
72 changes: 41 additions & 31 deletions packages/ui/components/Viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { createPortal } from 'react-dom';
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css';
import { Block, Annotation, AnnotationType, EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
import { Frontmatter } from '../utils/parser';
import { Frontmatter, computeListIndices } from '../utils/parser';
import { ListMarker } from './ListMarker';
import { AnnotationToolbar } from './AnnotationToolbar';
import { FloatingQuickLabelPicker } from './FloatingQuickLabelPicker';

Expand Down Expand Up @@ -530,11 +531,25 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
{!frontmatter && blocks.length > 0 && blocks[0].type !== 'heading' && <div className="mt-4" />}
{groupBlocks(blocks).map(group =>
group.type === 'list-group' ? (
<div key={group.key} data-pinpoint-group="list" className="py-1 -mx-2 px-2">
{group.blocks.map(block => (
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={block.id} block={block} onOpenLinkedDoc={onOpenLinkedDoc} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} />
))}
</div>
(() => {
const indices = computeListIndices(group.blocks);
return (
<div key={group.key} data-pinpoint-group="list" className="py-1 -mx-2 px-2">
{group.blocks.map((block, i) => (
<BlockRenderer
imageBaseDir={imageBaseDir}
onImageClick={(src, alt) => setLightbox({ src, alt })}
key={block.id}
block={block}
orderedIndex={indices[i]}
onOpenLinkedDoc={onOpenLinkedDoc}
onToggleCheckbox={onToggleCheckbox}
checkboxOverrides={checkboxOverrides}
/>
))}
</div>
);
})()
) : group.block.type === 'code' && isMermaidLanguage(group.block.language) ? (
<MermaidBlock key={group.block.id} block={group.block} />
) : group.block.type === 'code' && isGraphvizLanguage(group.block.language) ? (
Expand Down Expand Up @@ -967,7 +982,8 @@ const BlockRenderer: React.FC<{
onImageClick?: (src: string, alt: string) => void;
onToggleCheckbox?: (blockId: string, checked: boolean) => void;
checkboxOverrides?: Map<string, boolean>;
}> = ({ block, onOpenLinkedDoc, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides }) => {
orderedIndex?: number | null;
}> = ({ block, onOpenLinkedDoc, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex }) => {
switch (block.type) {
case 'heading':
const Tag = `h${block.level || 1}` as React.ElementType;
Expand All @@ -979,15 +995,23 @@ const BlockRenderer: React.FC<{

return <Tag className={styles} data-block-id={block.id} data-block-type="heading"><InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} /></Tag>;

case 'blockquote':
case 'blockquote': {
// Content may span multiple merged `>` lines. Split on blank-line
// paragraph breaks so `> a\n>\n> b` renders as two <p> children.
const paragraphs = block.content.split(/\n\n+/);
return (
<blockquote
className="border-l-2 border-primary/50 pl-4 my-4 text-muted-foreground italic"
data-block-id={block.id}
>
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} />
{paragraphs.map((para, i) => (
<p key={i} className={i > 0 ? 'mt-2' : ''}>
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={para} onOpenLinkedDoc={onOpenLinkedDoc} />
</p>
))}
</blockquote>
);
}

case 'list-item': {
const indent = (block.level || 0) * 1.25; // 1.25rem per level
Expand All @@ -1002,28 +1026,14 @@ const BlockRenderer: React.FC<{
data-block-id={block.id}
style={{ marginLeft: `${indent}rem` }}
>
<span
className={`select-none shrink-0 flex items-center${isInteractive ? ' cursor-pointer' : ''}`}
onClick={isInteractive ? (e) => { e.stopPropagation(); onToggleCheckbox!(block.id, !isChecked); } : undefined}
role={isInteractive ? 'checkbox' : undefined}
aria-checked={isInteractive ? isChecked : undefined}
>
{isCheckbox ? (
isChecked ? (
<svg className="w-4 h-4 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : (
<svg className={`w-4 h-4 text-muted-foreground/50${isInteractive ? ' hover:text-muted-foreground transition-colors' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="9" />
</svg>
)
) : (
<span className="text-primary/60">
{(block.level || 0) === 0 ? '•' : (block.level || 0) === 1 ? '◦' : '▪'}
</span>
)}
</span>
<ListMarker
level={block.level || 0}
ordered={block.ordered}
orderedIndex={orderedIndex}
checked={isChecked}
interactive={isInteractive}
onToggle={isInteractive ? () => onToggleCheckbox!(block.id, !isChecked) : undefined}
/>
<span className={`text-sm leading-relaxed ${isCheckbox && isChecked ? 'text-muted-foreground line-through' : 'text-foreground/90'}`}>
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} />
</span>
Expand Down
76 changes: 33 additions & 43 deletions packages/ui/components/plan-diff/PlanCleanDiffView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

import React, { useEffect, useRef, useState, useCallback } from "react";
import hljs from "highlight.js";
import { parseMarkdownToBlocks } from "../../utils/parser";
import { parseMarkdownToBlocks, computeListIndices } from "../../utils/parser";
import { ListMarker } from "../ListMarker";
import type { Block, Annotation, EditorMode, ImageAttachment } from "../../types";
import { AnnotationType } from "../../types";
import type { PlanDiffBlock } from "../../utils/planDiffEngine";
Expand Down Expand Up @@ -409,17 +410,29 @@ const MarkdownChunk: React.FC<{ content: string }> = ({ content }) => {
() => parseMarkdownToBlocks(content),
[content]
);
// Compute ordered-list display indices across the entire chunk so every
// list-item gets the right numeral even though we don't group here.
// Non-list blocks pass through as `null` and act as streak-breaks — same
// behavior as the main Viewer's per-group counter.
const orderedIndices = React.useMemo(
() => computeListIndices(blocks),
[blocks]
);

return (
<>
{blocks.map((block) => (
<SimpleBlockRenderer key={block.id} block={block} />
{blocks.map((block, i) => (
<SimpleBlockRenderer
key={block.id}
block={block}
orderedIndex={orderedIndices[i]}
/>
))}
</>
);
};

const SimpleBlockRenderer: React.FC<{ block: Block }> = ({ block }) => {
const SimpleBlockRenderer: React.FC<{ block: Block; orderedIndex?: number | null }> = ({ block, orderedIndex }) => {
switch (block.type) {
case "heading": {
const Tag = `h${block.level || 1}` as keyof React.JSX.IntrinsicElements;
Expand All @@ -437,12 +450,20 @@ const SimpleBlockRenderer: React.FC<{ block: Block }> = ({ block }) => {
);
}

case "blockquote":
case "blockquote": {
// Split on blank-line paragraph breaks so merged `> a\n>\n> b`
// renders as two <p> children instead of collapsing to one line.
const paragraphs = block.content.split(/\n\n+/);
return (
<blockquote className="border-l-2 border-primary/50 pl-4 my-4 text-muted-foreground italic">
<InlineMarkdown text={block.content} />
{paragraphs.map((para, i) => (
<p key={i} className={i > 0 ? "mt-2" : ""}>
<InlineMarkdown text={para} />
</p>
))}
</blockquote>
);
}

case "list-item": {
const indent = (block.level || 0) * 1.25;
Expand All @@ -452,43 +473,12 @@ const SimpleBlockRenderer: React.FC<{ block: Block }> = ({ block }) => {
className="flex gap-3 my-1.5"
style={{ marginLeft: `${indent}rem` }}
>
<span className="select-none shrink-0 flex items-center">
{isCheckbox ? (
block.checked ? (
<svg
className="w-4 h-4 text-success"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
) : (
<svg
className="w-4 h-4 text-muted-foreground/50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="12" cy="12" r="9" />
</svg>
)
) : (
<span className="text-primary/60">
{(block.level || 0) === 0
? "\u2022"
: (block.level || 0) === 1
? "\u25E6"
: "\u25AA"}
</span>
)}
</span>
<ListMarker
level={block.level || 0}
ordered={block.ordered}
orderedIndex={orderedIndex}
checked={block.checked}
/>
<span
className={`text-sm leading-relaxed ${isCheckbox && block.checked ? "text-muted-foreground line-through" : "text-foreground/90"}`}
>
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export interface Block {
level?: number; // For headings (1-6) or list indentation
language?: string; // For code blocks (e.g., 'rust', 'typescript')
checked?: boolean; // For checkbox list items (true = checked, false = unchecked, undefined = not a checkbox)
ordered?: boolean; // For list items: true when source marker was \d+.
orderedStart?: number; // For ordered list items: integer parsed from the marker (e.g. 5 for "5.")
order: number; // Sorting order
startLine: number; // 1-based line number in source
}
Expand Down
Loading