@plannotator/ui 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (305) hide show
  1. package/README.md +60 -0
  2. package/assets/diff-options.png +0 -0
  3. package/assets/icon-codex.png +0 -0
  4. package/assets/look-flat.png +0 -0
  5. package/assets/look-grid.png +0 -0
  6. package/assets/review-sections.png +0 -0
  7. package/assets/review-tree.png +0 -0
  8. package/assets/workspaces.webp +0 -0
  9. package/components/AISettingsTab.tsx +145 -0
  10. package/components/ActionMenu.tsx +101 -0
  11. package/components/AgentControls.tsx +216 -0
  12. package/components/AgentsTab.tsx +1294 -0
  13. package/components/AnnotationPanel.tsx +731 -0
  14. package/components/AnnotationSidebar.tsx +86 -0
  15. package/components/AnnotationToolbar.tsx +323 -0
  16. package/components/AnnotationToolstrip.tsx +355 -0
  17. package/components/ApproveDropdown.tsx +170 -0
  18. package/components/AttachmentsButton.tsx +410 -0
  19. package/components/BlockRenderer.tsx +163 -0
  20. package/components/BorderTrail.tsx +38 -0
  21. package/components/CodeFilePicker.tsx +64 -0
  22. package/components/CodeFilePopout.tsx +602 -0
  23. package/components/CodePathValidationContext.tsx +14 -0
  24. package/components/CommentPopover.tsx +512 -0
  25. package/components/CompletionOverlay.tsx +99 -0
  26. package/components/ConfirmDialog.tsx +128 -0
  27. package/components/DocBadges.tsx +231 -0
  28. package/components/EditorAnnotationCard.tsx +65 -0
  29. package/components/ExportModal.tsx +565 -0
  30. package/components/FloatingQuickLabelPicker.tsx +144 -0
  31. package/components/GitHubIcon.tsx +16 -0
  32. package/components/GitLabIcon.tsx +30 -0
  33. package/components/GraphvizBlock.tsx +511 -0
  34. package/components/ImageAnnotator/Canvas.tsx +118 -0
  35. package/components/ImageAnnotator/Toolbar.tsx +218 -0
  36. package/components/ImageAnnotator/index.tsx +272 -0
  37. package/components/ImageAnnotator/types.ts +39 -0
  38. package/components/ImageAnnotator/utils.ts +147 -0
  39. package/components/ImageThumbnail.tsx +126 -0
  40. package/components/ImportModal.tsx +146 -0
  41. package/components/InlineMarkdown.tsx +1094 -0
  42. package/components/KeyboardShortcuts.tsx +208 -0
  43. package/components/Landing.tsx +496 -0
  44. package/components/ListItemBody.tsx +62 -0
  45. package/components/ListMarker.tsx +73 -0
  46. package/components/LookAndFeelAnnouncementDialog.tsx +230 -0
  47. package/components/MarkdownEditor.tsx +44 -0
  48. package/components/MenuVersionSection.tsx +91 -0
  49. package/components/MermaidBlock.tsx +579 -0
  50. package/components/ModeToggle.tsx +68 -0
  51. package/components/OpenInAppButton.tsx +299 -0
  52. package/components/OverlayScrollArea.tsx +82 -0
  53. package/components/PermissionModeSetup.tsx +92 -0
  54. package/components/PinpointOverlay.tsx +99 -0
  55. package/components/PlanAIAnnouncementDialog.tsx +187 -0
  56. package/components/PlanHeaderMenu.tsx +299 -0
  57. package/components/PopoutDialog.tsx +91 -0
  58. package/components/Popover.tsx +27 -0
  59. package/components/ProviderIcons.tsx +51 -0
  60. package/components/PullRequestIcon.tsx +12 -0
  61. package/components/QuickLabelDropdown.tsx +63 -0
  62. package/components/RenderedMarkdown.tsx +54 -0
  63. package/components/RepoIcon.tsx +12 -0
  64. package/components/ResizeHandle.tsx +86 -0
  65. package/components/ReviewAgentsIcon.tsx +18 -0
  66. package/components/SearchableSelect.tsx +157 -0
  67. package/components/Settings.tsx +2220 -0
  68. package/components/SparklesIcon.tsx +45 -0
  69. package/components/StickyHeaderLane.tsx +260 -0
  70. package/components/TableOfContents.tsx +157 -0
  71. package/components/TaterSpritePullup.tsx +34 -0
  72. package/components/TaterSpriteRunning.tsx +54 -0
  73. package/components/TaterSpriteSitting.tsx +37 -0
  74. package/components/TextShimmer.tsx +57 -0
  75. package/components/ThemeProvider.tsx +162 -0
  76. package/components/ThemeTab.tsx +139 -0
  77. package/components/ToolbarButtons.tsx +116 -0
  78. package/components/Tooltip.tsx +45 -0
  79. package/components/Viewer.tsx +977 -0
  80. package/components/ai/AIProviderBar.tsx +95 -0
  81. package/components/ai/DocumentAIChatPanel.tsx +313 -0
  82. package/components/blocks/AlertBlock.tsx +58 -0
  83. package/components/blocks/Callout.tsx +60 -0
  84. package/components/blocks/CodeBlock.tsx +75 -0
  85. package/components/blocks/HtmlBlock.tsx +122 -0
  86. package/components/blocks/MathBlock.tsx +36 -0
  87. package/components/blocks/TableBlock.tsx +146 -0
  88. package/components/blocks/TablePopout.tsx +279 -0
  89. package/components/blocks/TableToolbar.tsx +153 -0
  90. package/components/blocks/proseBody.tsx +102 -0
  91. package/components/core/button.tsx +44 -0
  92. package/components/core/textarea.tsx +26 -0
  93. package/components/diagramLanguages.ts +14 -0
  94. package/components/goal-setup/GoalSetupSurface.tsx +1354 -0
  95. package/components/html-viewer/HtmlViewer.tsx +386 -0
  96. package/components/html-viewer/bridge-script.ts +505 -0
  97. package/components/html-viewer/index.ts +1 -0
  98. package/components/html-viewer/useHtmlAnnotation.ts +400 -0
  99. package/components/icons/AgentIcons.tsx +66 -0
  100. package/components/icons/AppIcon.tsx +56 -0
  101. package/components/icons/MessagesIcon.tsx +11 -0
  102. package/components/icons/ObsidianIcons.tsx +208 -0
  103. package/components/icons/app/android-studio.svg +369 -0
  104. package/components/icons/app/antigravity.svg +97 -0
  105. package/components/icons/app/cursor.svg +16 -0
  106. package/components/icons/app/file-explorer.svg +20 -0
  107. package/components/icons/app/finder.png +0 -0
  108. package/components/icons/app/ghostty.svg +13 -0
  109. package/components/icons/app/iterm2.svg +13 -0
  110. package/components/icons/app/powershell.svg +14 -0
  111. package/components/icons/app/sublime-text.svg +17 -0
  112. package/components/icons/app/terminal.png +0 -0
  113. package/components/icons/app/textmate.png +0 -0
  114. package/components/icons/app/vscode.svg +39 -0
  115. package/components/icons/app/warp.png +0 -0
  116. package/components/icons/app/xcode.png +0 -0
  117. package/components/icons/app/zed-dark.svg +15 -0
  118. package/components/icons/app/zed.svg +15 -0
  119. package/components/icons/themeIcons.tsx +47 -0
  120. package/components/mermaidSvg.ts +33 -0
  121. package/components/plan-diff/PlanCleanDiffView.tsx +884 -0
  122. package/components/plan-diff/PlanDiffBadge.tsx +48 -0
  123. package/components/plan-diff/PlanDiffModeSwitcher.tsx +103 -0
  124. package/components/plan-diff/PlanDiffViewer.tsx +214 -0
  125. package/components/plan-diff/PlanRawDiffView.tsx +102 -0
  126. package/components/plan-diff/VSCodeIcon.tsx +132 -0
  127. package/components/settings/HooksTab.tsx +208 -0
  128. package/components/sidebar/ArchiveBrowser.tsx +98 -0
  129. package/components/sidebar/CountBadge.tsx +12 -0
  130. package/components/sidebar/FileBrowser.tsx +498 -0
  131. package/components/sidebar/MessagesBrowser.tsx +109 -0
  132. package/components/sidebar/SidebarContainer.tsx +353 -0
  133. package/components/sidebar/SidebarTabs.tsx +155 -0
  134. package/components/sidebar/VersionBrowser.tsx +141 -0
  135. package/components/types.d.ts +7 -0
  136. package/components/ui/badge.tsx +44 -0
  137. package/components/ui/button.tsx +83 -0
  138. package/components/ui/card.tsx +58 -0
  139. package/components/ui/dialog.tsx +106 -0
  140. package/components/ui/dropdown-menu.tsx +240 -0
  141. package/components/ui/state-pill.tsx +45 -0
  142. package/components/ui/tabs.tsx +46 -0
  143. package/components/ui/textarea.tsx +25 -0
  144. package/config/configStore.ts +214 -0
  145. package/config/index.ts +4 -0
  146. package/config/reviewView.ts +42 -0
  147. package/config/settings.ts +310 -0
  148. package/config/useConfig.ts +20 -0
  149. package/configure.ts +69 -0
  150. package/globals.d.ts +14 -0
  151. package/hooks/pfm/useCodeFilePopout.ts +111 -0
  152. package/hooks/useAIChat.ts +532 -0
  153. package/hooks/useAIProviderConfig.ts +115 -0
  154. package/hooks/useActiveSection.ts +78 -0
  155. package/hooks/useAgentJobs.ts +306 -0
  156. package/hooks/useAgentSettings.ts +579 -0
  157. package/hooks/useAgents.ts +90 -0
  158. package/hooks/useAnnotationDraft.ts +523 -0
  159. package/hooks/useAnnotationHighlighter.ts +1170 -0
  160. package/hooks/useArchive.ts +171 -0
  161. package/hooks/useAutoClose.ts +104 -0
  162. package/hooks/useCodeAnnotationDraft.ts +194 -0
  163. package/hooks/useCodeFilePopout.ts +1 -0
  164. package/hooks/useDismissOnOutsideAndEscape.ts +50 -0
  165. package/hooks/useDraggable.ts +108 -0
  166. package/hooks/useEditorAnnotations.ts +64 -0
  167. package/hooks/useExternalAnnotationHighlights.ts +105 -0
  168. package/hooks/useExternalAnnotations.ts +268 -0
  169. package/hooks/useFileBrowser.ts +402 -0
  170. package/hooks/useInputMethodSwitch.ts +89 -0
  171. package/hooks/useIsMobile.ts +17 -0
  172. package/hooks/useLinkedDoc.ts +494 -0
  173. package/hooks/useOverlayViewport.ts +36 -0
  174. package/hooks/usePinpoint.ts +182 -0
  175. package/hooks/usePlanDiff.ts +176 -0
  176. package/hooks/usePrintMode.ts +27 -0
  177. package/hooks/useResizablePanel.ts +173 -0
  178. package/hooks/useScrollViewport.ts +38 -0
  179. package/hooks/useSharing.ts +441 -0
  180. package/hooks/useSidebar.ts +55 -0
  181. package/hooks/useUpdateCheck.ts +132 -0
  182. package/hooks/useValidatedCodePaths.ts +94 -0
  183. package/icons/GitUser.tsx +17 -0
  184. package/lib/utils.ts +11 -0
  185. package/package.json +117 -0
  186. package/plannotator.webp +0 -0
  187. package/print.css +427 -0
  188. package/shortcuts/code-review/ai.shortcuts.ts +25 -0
  189. package/shortcuts/code-review/allFilesDiff.shortcuts.ts +46 -0
  190. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +30 -0
  191. package/shortcuts/code-review/fileTree.shortcuts.ts +35 -0
  192. package/shortcuts/code-review/prComments.shortcuts.ts +18 -0
  193. package/shortcuts/code-review/suggestionModal.shortcuts.ts +25 -0
  194. package/shortcuts/code-review/tourDialog.shortcuts.ts +18 -0
  195. package/shortcuts/core.ts +399 -0
  196. package/shortcuts/index.ts +21 -0
  197. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +25 -0
  198. package/shortcuts/plan-review/annotationToolbar.shortcuts.ts +39 -0
  199. package/shortcuts/plan-review/commentPopover.shortcuts.ts +21 -0
  200. package/shortcuts/plan-review/goalSetup.shortcuts.ts +10 -0
  201. package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +42 -0
  202. package/shortcuts/plan-review/inputMethod.shortcuts.ts +30 -0
  203. package/shortcuts/plan-review/sidebar.shortcuts.ts +34 -0
  204. package/shortcuts/plan-review/viewer.shortcuts.ts +24 -0
  205. package/shortcuts/runtime.ts +259 -0
  206. package/sprite_package_additional/index.html +34 -0
  207. package/sprite_package_additional/sprite.png +0 -0
  208. package/sprite_package_new/index.html +34 -0
  209. package/sprite_package_new/sprite.png +0 -0
  210. package/sprite_package_pulluphang/index.html +34 -0
  211. package/sprite_package_pulluphang/sprite.png +0 -0
  212. package/styles.css +1 -0
  213. package/theme.css +893 -0
  214. package/themes/adwaita.css +62 -0
  215. package/themes/andromeeda.css +66 -0
  216. package/themes/aurora-x.css +66 -0
  217. package/themes/ayu-dark.css +66 -0
  218. package/themes/caffeine.css +113 -0
  219. package/themes/catppuccin.css +62 -0
  220. package/themes/claude-plus.css +60 -0
  221. package/themes/cursor-hc.css +34 -0
  222. package/themes/cursor-midnight.css +34 -0
  223. package/themes/cursor.css +62 -0
  224. package/themes/dark-plus.css +66 -0
  225. package/themes/doom-64.css +109 -0
  226. package/themes/dracula.css +33 -0
  227. package/themes/everforest-hard.css +62 -0
  228. package/themes/everforest-soft.css +62 -0
  229. package/themes/everforest.css +62 -0
  230. package/themes/github.css +66 -0
  231. package/themes/gruvbox.css +62 -0
  232. package/themes/houston.css +66 -0
  233. package/themes/kanagawa-dragon.css +34 -0
  234. package/themes/kanagawa-lotus.css +34 -0
  235. package/themes/kanagawa-wave.css +34 -0
  236. package/themes/laserwave.css +66 -0
  237. package/themes/material.css +62 -0
  238. package/themes/min.css +66 -0
  239. package/themes/monokai-pro.css +34 -0
  240. package/themes/neutral.css +59 -0
  241. package/themes/night-owl.css +66 -0
  242. package/themes/nord.css +66 -0
  243. package/themes/one-dark-pro.css +66 -0
  244. package/themes/one-light.css +66 -0
  245. package/themes/paulmillr.css +34 -0
  246. package/themes/plannotator.css +60 -0
  247. package/themes/plastic.css +66 -0
  248. package/themes/poimandres.css +66 -0
  249. package/themes/quantum-rose.css +109 -0
  250. package/themes/red.css +66 -0
  251. package/themes/rose-pine.css +62 -0
  252. package/themes/simple.css +124 -0
  253. package/themes/slack.css +66 -0
  254. package/themes/snazzy-light.css +66 -0
  255. package/themes/soft-pop.css +60 -0
  256. package/themes/solar-dusk.css +109 -0
  257. package/themes/solarized.css +66 -0
  258. package/themes/synthwave-84.css +34 -0
  259. package/themes/terminal.css +62 -0
  260. package/themes/tinacious.css +57 -0
  261. package/themes/tokyo-night.css +62 -0
  262. package/themes/vesper.css +62 -0
  263. package/themes/vitesse-black.css +66 -0
  264. package/themes/vitesse.css +62 -0
  265. package/types.ts +264 -0
  266. package/utils/agentSwitch.ts +71 -0
  267. package/utils/aiChatFormat.ts +33 -0
  268. package/utils/aiPrompt.ts +29 -0
  269. package/utils/aiProvider.ts +217 -0
  270. package/utils/anchors.ts +11 -0
  271. package/utils/annotateAgentTerminal.ts +29 -0
  272. package/utils/annotationHelpers.ts +101 -0
  273. package/utils/bear.ts +59 -0
  274. package/utils/blockTargeting.ts +240 -0
  275. package/utils/callback.ts +99 -0
  276. package/utils/commentContent.ts +8 -0
  277. package/utils/defaultNotesApp.ts +20 -0
  278. package/utils/diffFonts.ts +33 -0
  279. package/utils/editorMode.ts +31 -0
  280. package/utils/fileBrowser.ts +40 -0
  281. package/utils/generateId.ts +7 -0
  282. package/utils/generateIdentity.ts +29 -0
  283. package/utils/identity.ts +112 -0
  284. package/utils/inlineTransforms.ts +41 -0
  285. package/utils/inputMethod.ts +17 -0
  286. package/utils/lookAndFeelAnnouncement.ts +18 -0
  287. package/utils/obsidian.ts +201 -0
  288. package/utils/octarine.ts +53 -0
  289. package/utils/parser.ts +991 -0
  290. package/utils/permissionMode.ts +77 -0
  291. package/utils/planAIAnnouncement.ts +17 -0
  292. package/utils/planAgentInstructions.ts +137 -0
  293. package/utils/planDiffEngine.ts +590 -0
  294. package/utils/planSave.ts +49 -0
  295. package/utils/platform.ts +11 -0
  296. package/utils/quickLabels.ts +77 -0
  297. package/utils/reviewAgentInstructions.ts +182 -0
  298. package/utils/sanitizeHtml.ts +45 -0
  299. package/utils/sharing.ts +350 -0
  300. package/utils/slugify.ts +37 -0
  301. package/utils/storage.ts +141 -0
  302. package/utils/themeRegistry.ts +567 -0
  303. package/utils/uiPreferences.ts +34 -0
  304. package/utils/upload.ts +56 -0
  305. package/utils/wideMode.ts +48 -0
@@ -0,0 +1,884 @@
1
+ /**
2
+ * PlanCleanDiffView — Rendered/clean diff mode
3
+ *
4
+ * Shows the new plan content rendered as markdown, with colored left borders
5
+ * indicating what changed. Annotation uses block-level hover (like code block
6
+ * hover in Viewer) — no text selection, no web-highlighter.
7
+ */
8
+
9
+ import React, { useEffect, useRef, useState, useCallback } from "react";
10
+ import hljs from "highlight.js";
11
+ import { parseMarkdownToBlocks, computeListIndices } from "../../utils/parser";
12
+ import { ListItemBody } from "../ListItemBody";
13
+ import type { Block, Annotation, EditorMode, ImageAttachment } from "../../types";
14
+ import { AnnotationType } from "../../types";
15
+ import type {
16
+ PlanDiffBlock,
17
+ InlineDiffToken,
18
+ InlineDiffWrap,
19
+ } from "../../utils/planDiffEngine";
20
+ import type { QuickLabel } from "../../utils/quickLabels";
21
+ import { AnnotationToolbar } from "../AnnotationToolbar";
22
+ import { CommentPopover } from "../CommentPopover";
23
+ import { FloatingQuickLabelPicker } from "../FloatingQuickLabelPicker";
24
+ import { getIdentity } from "../../utils/identity";
25
+
26
+ interface PlanCleanDiffViewProps {
27
+ blocks: PlanDiffBlock[];
28
+ annotations?: Annotation[];
29
+ onAddAnnotation?: (ann: Annotation) => void;
30
+ onSelectAnnotation?: (id: string | null) => void;
31
+ selectedAnnotationId?: string | null;
32
+ mode?: EditorMode;
33
+ /**
34
+ * When true (default), modified blocks that passed the qualification gate
35
+ * render with inline word-level highlights. When false, every modified
36
+ * block falls back to the stacked old-struck / new-green layout — the
37
+ * "Classic" diff view exposed in the mode switcher.
38
+ */
39
+ wordLevel?: boolean;
40
+ }
41
+
42
+ export const PlanCleanDiffView: React.FC<PlanCleanDiffViewProps> = ({
43
+ blocks,
44
+ annotations = [],
45
+ onAddAnnotation,
46
+ onSelectAnnotation,
47
+ selectedAnnotationId = null,
48
+ mode = "selection",
49
+ wordLevel = true,
50
+ }) => {
51
+ const modeRef = useRef<EditorMode>(mode);
52
+ const onAddAnnotationRef = useRef(onAddAnnotation);
53
+ const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
54
+ const exitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
55
+
56
+ const [hoveredBlock, setHoveredBlock] = useState<{
57
+ element: HTMLElement;
58
+ block: PlanDiffBlock;
59
+ index: number;
60
+ diffContext: Annotation['diffContext'];
61
+ } | null>(null);
62
+ const [isExiting, setIsExiting] = useState(false);
63
+
64
+ const [commentPopover, setCommentPopover] = useState<{
65
+ anchorEl: HTMLElement;
66
+ contextText: string;
67
+ initialText?: string;
68
+ block: PlanDiffBlock;
69
+ index: number;
70
+ diffContext: Annotation['diffContext'];
71
+ } | null>(null);
72
+
73
+ const [quickLabelPicker, setQuickLabelPicker] = useState<{
74
+ anchorEl: HTMLElement;
75
+ block: PlanDiffBlock;
76
+ index: number;
77
+ diffContext: Annotation['diffContext'];
78
+ } | null>(null);
79
+
80
+ useEffect(() => { modeRef.current = mode; }, [mode]);
81
+ useEffect(() => { onAddAnnotationRef.current = onAddAnnotation; }, [onAddAnnotation]);
82
+
83
+ // Clean up timers on unmount
84
+ useEffect(() => {
85
+ return () => {
86
+ if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
87
+ if (exitTimerRef.current) clearTimeout(exitTimerRef.current);
88
+ };
89
+ }, []);
90
+
91
+ // Scroll to selected annotation's diff block
92
+ // Only depends on selectedAnnotationId — annotations ref is read but not a trigger
93
+ const annotationsRef = useRef(annotations);
94
+ annotationsRef.current = annotations;
95
+
96
+ useEffect(() => {
97
+ if (!selectedAnnotationId) return;
98
+
99
+ const ann = annotationsRef.current.find(a => a.id === selectedAnnotationId);
100
+ if (!ann?.blockId?.startsWith('diff-block-')) return;
101
+
102
+ const idx = ann.blockId.replace('diff-block-', '');
103
+ const el = document.querySelector(`[data-diff-block-index="${idx}"]`);
104
+ if (!el) return;
105
+
106
+ el.classList.add('annotation-highlight', 'focused');
107
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
108
+
109
+ const timer = setTimeout(() => {
110
+ el.classList.remove('annotation-highlight', 'focused');
111
+ }, 2000);
112
+ return () => clearTimeout(timer);
113
+ }, [selectedAnnotationId]);
114
+
115
+ // Build set of annotated block IDs for highlight rings
116
+ const annotatedBlockIds = React.useMemo(() => {
117
+ const set = new Set<string>();
118
+ annotations.forEach(ann => {
119
+ if (ann.diffContext && ann.blockId) {
120
+ set.add(ann.blockId);
121
+ }
122
+ });
123
+ return set;
124
+ }, [annotations]);
125
+
126
+ /**
127
+ * Resolve content for a diff block section (handles modified blocks with
128
+ * old/new sides). For inline-diff modified blocks — one clickable target
129
+ * with diffContext 'modified' — we capture BOTH sides in git-diff shape
130
+ * so comments about a struck-through deleted word preserve that word in
131
+ * the exported feedback, instead of sending only the new content.
132
+ */
133
+ const getBlockContent = useCallback((block: PlanDiffBlock, diffContext: Annotation['diffContext']) => {
134
+ if (block.type === 'modified') {
135
+ if (diffContext === 'removed') return block.oldContent || block.content;
136
+ if (
137
+ diffContext === 'modified' &&
138
+ block.oldContent &&
139
+ block.oldContent !== block.content
140
+ ) {
141
+ return `- ${block.oldContent.trimEnd()}\n+ ${block.content.trimEnd()}`;
142
+ }
143
+ }
144
+ return block.content;
145
+ }, []);
146
+
147
+ const createDiffAnnotation = useCallback((
148
+ block: PlanDiffBlock,
149
+ index: number,
150
+ diffContext: Annotation['diffContext'],
151
+ type: AnnotationType,
152
+ text?: string,
153
+ images?: ImageAttachment[],
154
+ isQuickLabel?: boolean,
155
+ quickLabelTip?: string,
156
+ ) => {
157
+ const content = getBlockContent(block, diffContext);
158
+ const now = Date.now();
159
+
160
+ const newAnnotation: Annotation = {
161
+ id: `diff-${now}-${index}`,
162
+ blockId: `diff-block-${index}`,
163
+ startOffset: 0,
164
+ endOffset: content.length,
165
+ type,
166
+ text,
167
+ originalText: content,
168
+ createdA: now,
169
+ author: getIdentity(),
170
+ images,
171
+ diffContext,
172
+ ...(isQuickLabel ? { isQuickLabel: true } : {}),
173
+ ...(quickLabelTip ? { quickLabelTip } : {}),
174
+ };
175
+
176
+ onAddAnnotationRef.current?.(newAnnotation);
177
+ }, [getBlockContent]);
178
+
179
+ // Hover handlers
180
+ const handleHover = useCallback((element: HTMLElement, block: PlanDiffBlock, index: number, diffContext: Annotation['diffContext']) => {
181
+ if (hoverTimeoutRef.current) {
182
+ clearTimeout(hoverTimeoutRef.current);
183
+ hoverTimeoutRef.current = null;
184
+ }
185
+ setIsExiting(false);
186
+ if (!commentPopover && !quickLabelPicker) {
187
+ setHoveredBlock({ element, block, index, diffContext });
188
+ }
189
+ }, [commentPopover, quickLabelPicker]);
190
+
191
+ const handleLeave = useCallback(() => {
192
+ hoverTimeoutRef.current = setTimeout(() => {
193
+ setIsExiting(true);
194
+ exitTimerRef.current = setTimeout(() => {
195
+ setHoveredBlock(null);
196
+ setIsExiting(false);
197
+ exitTimerRef.current = null;
198
+ }, 150);
199
+ }, 100);
200
+ }, []);
201
+
202
+ // Toolbar handlers
203
+ const handleAnnotate = (type: AnnotationType) => {
204
+ if (!hoveredBlock) return;
205
+ createDiffAnnotation(hoveredBlock.block, hoveredBlock.index, hoveredBlock.diffContext, type);
206
+ setHoveredBlock(null);
207
+ setIsExiting(false);
208
+ };
209
+
210
+ const handleQuickLabel = (label: QuickLabel) => {
211
+ if (!hoveredBlock) return;
212
+ createDiffAnnotation(
213
+ hoveredBlock.block, hoveredBlock.index, hoveredBlock.diffContext,
214
+ AnnotationType.COMMENT, `${label.emoji} ${label.text}`, undefined, true, label.tip
215
+ );
216
+ setHoveredBlock(null);
217
+ setIsExiting(false);
218
+ };
219
+
220
+ const handleToolbarClose = () => {
221
+ setHoveredBlock(null);
222
+ setIsExiting(false);
223
+ };
224
+
225
+ const handleRequestComment = (initialChar?: string) => {
226
+ if (!hoveredBlock) return;
227
+ const content = getBlockContent(hoveredBlock.block, hoveredBlock.diffContext);
228
+ setCommentPopover({
229
+ anchorEl: hoveredBlock.element,
230
+ contextText: content.slice(0, 80),
231
+ initialText: initialChar,
232
+ block: hoveredBlock.block,
233
+ index: hoveredBlock.index,
234
+ diffContext: hoveredBlock.diffContext,
235
+ });
236
+ setHoveredBlock(null);
237
+ };
238
+
239
+ const handleCommentSubmit = (text: string, images?: ImageAttachment[]) => {
240
+ if (!commentPopover) return;
241
+ createDiffAnnotation(
242
+ commentPopover.block, commentPopover.index, commentPopover.diffContext,
243
+ AnnotationType.COMMENT, text, images
244
+ );
245
+ setCommentPopover(null);
246
+ };
247
+
248
+ const handleCommentClose = useCallback(() => {
249
+ setCommentPopover(null);
250
+ }, []);
251
+
252
+ const handleFloatingQuickLabel = useCallback((label: QuickLabel) => {
253
+ if (!quickLabelPicker) return;
254
+ createDiffAnnotation(
255
+ quickLabelPicker.block, quickLabelPicker.index, quickLabelPicker.diffContext,
256
+ AnnotationType.COMMENT, `${label.emoji} ${label.text}`, undefined, true, label.tip
257
+ );
258
+ setQuickLabelPicker(null);
259
+ }, [quickLabelPicker, createDiffAnnotation]);
260
+
261
+ const handleQuickLabelPickerDismiss = useCallback(() => {
262
+ setQuickLabelPicker(null);
263
+ }, []);
264
+
265
+ // Mode-aware click on hovered block
266
+ const handleBlockClick = useCallback((block: PlanDiffBlock, index: number, element: HTMLElement, diffContext: Annotation['diffContext']) => {
267
+ if (modeRef.current === 'redline') {
268
+ createDiffAnnotation(block, index, diffContext, AnnotationType.DELETION);
269
+ } else if (modeRef.current === 'quickLabel') {
270
+ setQuickLabelPicker({ anchorEl: element, block, index, diffContext });
271
+ } else {
272
+ // selection or comment → open the comment popover directly on click
273
+ const content = getBlockContent(block, diffContext);
274
+ setCommentPopover({
275
+ anchorEl: element,
276
+ contextText: content.slice(0, 80),
277
+ block,
278
+ index,
279
+ diffContext,
280
+ });
281
+ }
282
+ }, [createDiffAnnotation, getBlockContent]);
283
+
284
+ // Check if a block index has been annotated (for highlight ring)
285
+ const isBlockAnnotated = (index: number) => annotatedBlockIds.has(`diff-block-${index}`);
286
+
287
+ return (
288
+ <div className="space-y-1">
289
+ {blocks.map((block, index) => (
290
+ <DiffBlockRenderer
291
+ key={index}
292
+ block={block}
293
+ index={index}
294
+ hoveredIndex={hoveredBlock?.index ?? null}
295
+ hoveredDiffContext={hoveredBlock?.diffContext}
296
+ isBlockAnnotated={isBlockAnnotated}
297
+ wordLevel={wordLevel}
298
+ onHover={onAddAnnotation ? (el, diffContext) => handleHover(el, block, index, diffContext) : undefined}
299
+ onLeave={onAddAnnotation ? handleLeave : undefined}
300
+ onClick={onAddAnnotation ? (el, diffContext) => handleBlockClick(block, index, el, diffContext) : undefined}
301
+ />
302
+ ))}
303
+
304
+ {/* Block hover toolbar (selection mode) */}
305
+ {hoveredBlock && !commentPopover && !quickLabelPicker && (
306
+ <AnnotationToolbar
307
+ element={hoveredBlock.element}
308
+ positionMode="top-right"
309
+ onAnnotate={handleAnnotate}
310
+ onClose={handleToolbarClose}
311
+ onRequestComment={handleRequestComment}
312
+ onQuickLabel={handleQuickLabel}
313
+ isExiting={isExiting}
314
+ onMouseEnter={() => {
315
+ if (hoverTimeoutRef.current) {
316
+ clearTimeout(hoverTimeoutRef.current);
317
+ hoverTimeoutRef.current = null;
318
+ }
319
+ setIsExiting(false);
320
+ }}
321
+ onMouseLeave={handleLeave}
322
+ />
323
+ )}
324
+
325
+ {/* Comment popover */}
326
+ {commentPopover && (
327
+ <CommentPopover
328
+ anchorEl={commentPopover.anchorEl}
329
+ contextText={commentPopover.contextText}
330
+ isGlobal={false}
331
+ initialText={commentPopover.initialText}
332
+ onSubmit={handleCommentSubmit}
333
+ onClose={handleCommentClose}
334
+ />
335
+ )}
336
+
337
+ {/* Quick label picker */}
338
+ {quickLabelPicker && (
339
+ <FloatingQuickLabelPicker
340
+ anchorEl={quickLabelPicker.anchorEl}
341
+ onSelect={handleFloatingQuickLabel}
342
+ onDismiss={handleQuickLabelPickerDismiss}
343
+ />
344
+ )}
345
+ </div>
346
+ );
347
+ };
348
+
349
+ // --- DiffBlockRenderer with hover support ---
350
+
351
+ interface DiffBlockRendererProps {
352
+ block: PlanDiffBlock;
353
+ index: number;
354
+ hoveredIndex: number | null;
355
+ hoveredDiffContext?: Annotation['diffContext'];
356
+ isBlockAnnotated: (index: number) => boolean;
357
+ /** When false, force block-level fallback even if inlineTokens is populated. */
358
+ wordLevel: boolean;
359
+ onHover?: (element: HTMLElement, diffContext: Annotation['diffContext']) => void;
360
+ onLeave?: () => void;
361
+ onClick?: (element: HTMLElement, diffContext: Annotation['diffContext']) => void;
362
+ }
363
+
364
+ const DiffBlockRenderer: React.FC<DiffBlockRendererProps> = ({
365
+ block, index, hoveredIndex, hoveredDiffContext, isBlockAnnotated, wordLevel, onHover, onLeave, onClick,
366
+ }) => {
367
+ const hoverProps = (diffContext: Annotation['diffContext']) => onHover ? {
368
+ onMouseEnter: (e: React.MouseEvent<HTMLElement>) => onHover(e.currentTarget, diffContext),
369
+ onMouseLeave: () => onLeave?.(),
370
+ onClick: onClick ? (e: React.MouseEvent<HTMLElement>) => onClick(e.currentTarget, diffContext) : undefined,
371
+ style: { cursor: 'pointer' } as React.CSSProperties,
372
+ } : {};
373
+
374
+ const isHovered = (diffContext: Annotation['diffContext']) =>
375
+ hoveredIndex === index && hoveredDiffContext === diffContext;
376
+
377
+ const ringClass = (diffContext: Annotation['diffContext']) => {
378
+ if (isHovered(diffContext)) return 'ring-1 ring-primary/30 rounded';
379
+ if (isBlockAnnotated(index)) return 'ring-2 ring-accent rounded outline-offset-2';
380
+ return '';
381
+ };
382
+
383
+ switch (block.type) {
384
+ case "unchanged":
385
+ return (
386
+ <div className="plan-diff-unchanged opacity-60 hover:opacity-100 transition-opacity">
387
+ <MarkdownChunk content={block.content} />
388
+ </div>
389
+ );
390
+
391
+ case "added":
392
+ return (
393
+ <div
394
+ className={`plan-diff-added transition-shadow ${ringClass('added')}`}
395
+ data-diff-block-index={index}
396
+ {...hoverProps('added')}
397
+ >
398
+ <MarkdownChunk content={block.content} />
399
+ </div>
400
+ );
401
+
402
+ case "removed":
403
+ return (
404
+ <div
405
+ className={`plan-diff-removed line-through decoration-destructive/30 opacity-70 transition-shadow ${ringClass('removed')}`}
406
+ data-diff-block-index={index}
407
+ {...hoverProps('removed')}
408
+ >
409
+ <MarkdownChunk content={block.content} />
410
+ </div>
411
+ );
412
+
413
+ case "modified":
414
+ // When the engine populated inlineTokens, we render a single in-context
415
+ // block with <ins>/<del> spans inside the structural wrapper. Falls
416
+ // back to the stacked strike-through rendering when tokens are absent
417
+ // (gate rejected: code/table/structural mismatch/inline-code hazard).
418
+ if (wordLevel && block.inlineTokens && block.inlineWrap) {
419
+ return (
420
+ <InlineModifiedBlock
421
+ tokens={block.inlineTokens}
422
+ wrap={block.inlineWrap}
423
+ index={index}
424
+ ringClass={ringClass('modified')}
425
+ hoverProps={hoverProps('modified')}
426
+ />
427
+ );
428
+ }
429
+ return (
430
+ <div data-diff-block-index={index}>
431
+ <div
432
+ className={`plan-diff-removed line-through decoration-destructive/30 opacity-60 transition-shadow ${ringClass('removed')}`}
433
+ {...hoverProps('removed')}
434
+ >
435
+ <MarkdownChunk content={block.oldContent!} />
436
+ </div>
437
+ <div
438
+ className={`plan-diff-added transition-shadow ${ringClass('modified')}`}
439
+ {...hoverProps('modified')}
440
+ >
441
+ <MarkdownChunk content={block.content} />
442
+ </div>
443
+ </div>
444
+ );
445
+
446
+ default:
447
+ return null;
448
+ }
449
+ };
450
+
451
+ // --- Shared block-rendering style helpers ---
452
+ // Kept as module-scope constants so InlineModifiedBlock and SimpleBlockRenderer
453
+ // share a single source of truth for heading/paragraph/list-item styling.
454
+
455
+ const HEADING_STYLE_BY_LEVEL: Record<number, string> = {
456
+ 1: "text-2xl font-bold mb-4 mt-6 first:mt-0 tracking-tight",
457
+ 2: "text-xl font-semibold mb-3 mt-8 text-foreground/90",
458
+ 3: "text-base font-semibold mb-2 mt-6 text-foreground/80",
459
+ };
460
+ const HEADING_STYLE_FALLBACK = "text-base font-semibold mb-2 mt-4";
461
+ const headingStyleFor = (level: number): string =>
462
+ HEADING_STYLE_BY_LEVEL[level] || HEADING_STYLE_FALLBACK;
463
+
464
+ const PARAGRAPH_CLASS = "mb-4 leading-relaxed text-foreground/90 text-[15px]";
465
+ const LIST_ITEM_ROW_CLASS = "flex items-start gap-3 my-1.5";
466
+ const listItemIndentRem = (level: number): string => `${level * 1.25}rem`;
467
+ const listItemTextClass = (isCheckbox: boolean, checked?: boolean): string =>
468
+ `text-sm leading-relaxed ${isCheckbox && checked ? "text-muted-foreground line-through" : "text-foreground/90"}`;
469
+
470
+ // --- Inline word-diff renderer for modified blocks ---
471
+
472
+ interface InlineModifiedBlockProps {
473
+ tokens: InlineDiffToken[];
474
+ wrap: InlineDiffWrap;
475
+ index: number;
476
+ ringClass: string;
477
+ hoverProps: {
478
+ onMouseEnter?: (e: React.MouseEvent<HTMLElement>) => void;
479
+ onMouseLeave?: () => void;
480
+ onClick?: (e: React.MouseEvent<HTMLElement>) => void;
481
+ style?: React.CSSProperties;
482
+ };
483
+ }
484
+
485
+ /**
486
+ * Renders a 'modified' diff block in-context: one structural wrapper
487
+ * (h1-h6, p, or list-item div) containing a single InlineMarkdown parse
488
+ * over a unified string with <ins>/<del> tags wrapping changed tokens.
489
+ * Preserves markdown AST context across token boundaries (bold pairs,
490
+ * links) which per-token rendering would break.
491
+ */
492
+ const InlineModifiedBlock: React.FC<InlineModifiedBlockProps> = ({
493
+ tokens,
494
+ wrap,
495
+ index,
496
+ ringClass,
497
+ hoverProps,
498
+ }) => {
499
+ const unified = tokens
500
+ .map((t) => {
501
+ if (t.type === "added") return `<ins>${t.value}</ins>`;
502
+ if (t.type === "removed") return `<del>${t.value}</del>`;
503
+ return t.value;
504
+ })
505
+ .join("");
506
+
507
+ // Modified blocks rendered inline carry BOTH additions and deletions, so
508
+ // their border/background uses the amber "modified" class — not the green
509
+ // "added" one. Inline <ins>/<del> word highlights render on top unchanged.
510
+ const wrapperBase = `plan-diff-modified transition-shadow ${ringClass}`;
511
+ const { style: hoverStyle, ...hoverRest } = hoverProps;
512
+
513
+ if (wrap.type === "heading") {
514
+ const level = wrap.level || 1;
515
+ const Tag = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
516
+ return (
517
+ <Tag
518
+ data-diff-block-index={index}
519
+ className={`${headingStyleFor(level)} ${wrapperBase}`}
520
+ style={hoverStyle}
521
+ {...hoverRest}
522
+ >
523
+ <InlineMarkdown text={unified} />
524
+ </Tag>
525
+ );
526
+ }
527
+
528
+ if (wrap.type === "list-item") {
529
+ const listLevel = wrap.listLevel || 0;
530
+ const isCheckbox = wrap.checked !== undefined;
531
+ return (
532
+ <div
533
+ data-diff-block-index={index}
534
+ className={`${LIST_ITEM_ROW_CLASS} ${wrapperBase}`}
535
+ style={{ marginLeft: listItemIndentRem(listLevel), ...hoverStyle }}
536
+ {...hoverRest}
537
+ >
538
+ <ListItemBody
539
+ level={listLevel}
540
+ ordered={wrap.ordered}
541
+ orderedIndex={wrap.orderedStart ?? 1}
542
+ checked={wrap.checked}
543
+ textClassName={listItemTextClass(isCheckbox, wrap.checked)}
544
+ content={unified}
545
+ renderInline={(text) => <InlineMarkdown text={text} />}
546
+ />
547
+ </div>
548
+ );
549
+ }
550
+
551
+ // paragraph
552
+ return (
553
+ <p
554
+ data-diff-block-index={index}
555
+ className={`${PARAGRAPH_CLASS} ${wrapperBase}`}
556
+ style={hoverStyle}
557
+ {...hoverRest}
558
+ >
559
+ <InlineMarkdown text={unified} />
560
+ </p>
561
+ );
562
+ };
563
+
564
+ // --- Rendering components (unchanged from main) ---
565
+
566
+ const MarkdownChunk: React.FC<{ content: string }> = ({ content }) => {
567
+ const blocks = React.useMemo(
568
+ () => parseMarkdownToBlocks(content),
569
+ [content]
570
+ );
571
+ // Compute ordered-list display indices across the entire chunk so every
572
+ // list-item gets the right numeral even though we don't group here.
573
+ // Non-list blocks pass through as `null` and act as streak-breaks — same
574
+ // behavior as the main Viewer's per-group counter.
575
+ const orderedIndices = React.useMemo(
576
+ () => computeListIndices(blocks),
577
+ [blocks]
578
+ );
579
+
580
+ return (
581
+ <>
582
+ {blocks.map((block, i) => (
583
+ <SimpleBlockRenderer
584
+ key={block.id}
585
+ block={block}
586
+ orderedIndex={orderedIndices[i]}
587
+ />
588
+ ))}
589
+ </>
590
+ );
591
+ };
592
+
593
+ const SimpleBlockRenderer: React.FC<{ block: Block; orderedIndex?: number | null }> = ({ block, orderedIndex }) => {
594
+ switch (block.type) {
595
+ case "heading": {
596
+ const level = block.level || 1;
597
+ const Tag = `h${level}` as keyof React.JSX.IntrinsicElements;
598
+ return (
599
+ <Tag className={headingStyleFor(level)}>
600
+ <InlineMarkdown text={block.content} />
601
+ </Tag>
602
+ );
603
+ }
604
+
605
+ case "blockquote": {
606
+ // Split on blank-line paragraph breaks so merged `> a\n>\n> b`
607
+ // renders as two <p> children instead of collapsing to one line.
608
+ const paragraphs = block.content.split(/\n\n+/);
609
+ return (
610
+ <blockquote className="border-l-2 border-primary/50 pl-4 my-4 text-muted-foreground italic">
611
+ {paragraphs.map((para, i) => (
612
+ <p key={i} className={i > 0 ? "mt-2" : ""}>
613
+ <InlineMarkdown text={para} />
614
+ </p>
615
+ ))}
616
+ </blockquote>
617
+ );
618
+ }
619
+
620
+ case "list-item": {
621
+ const listLevel = block.level || 0;
622
+ const isCheckbox = block.checked !== undefined;
623
+ return (
624
+ <div
625
+ className={LIST_ITEM_ROW_CLASS}
626
+ style={{ marginLeft: listItemIndentRem(listLevel) }}
627
+ >
628
+ <ListItemBody
629
+ level={listLevel}
630
+ ordered={block.ordered}
631
+ orderedIndex={orderedIndex}
632
+ checked={block.checked}
633
+ textClassName={listItemTextClass(isCheckbox, block.checked)}
634
+ content={block.content}
635
+ renderInline={(text) => <InlineMarkdown text={text} />}
636
+ />
637
+ </div>
638
+ );
639
+ }
640
+
641
+ case "code":
642
+ return <SimpleCodeBlock block={block} />;
643
+
644
+ case "hr":
645
+ return <hr className="border-border/30 my-8" />;
646
+
647
+ case "table": {
648
+ const lines = block.content.split('\n').filter(line => line.trim());
649
+ if (lines.length === 0) return null;
650
+ const parseRow = (line: string): string[] =>
651
+ line.replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map(cell => cell.trim().replace(/\\\|/g, '|'));
652
+ const headers = parseRow(lines[0]);
653
+ const rows: string[][] = [];
654
+ for (let i = 1; i < lines.length; i++) {
655
+ const line = lines[i].trim();
656
+ if (/^[\|\-:\s]+$/.test(line)) continue;
657
+ rows.push(parseRow(line));
658
+ }
659
+ return (
660
+ <div className="my-4 overflow-x-auto">
661
+ <table className="min-w-full border-collapse text-sm">
662
+ <thead>
663
+ <tr className="border-b border-border">
664
+ {headers.map((header, i) => (
665
+ <th key={i} className="px-3 py-2 text-left font-semibold text-foreground/90 bg-muted/30">
666
+ <InlineMarkdown text={header} />
667
+ </th>
668
+ ))}
669
+ </tr>
670
+ </thead>
671
+ <tbody>
672
+ {rows.map((row, rowIdx) => (
673
+ <tr key={rowIdx} className="border-b border-border/50">
674
+ {row.map((cell, cellIdx) => (
675
+ <td key={cellIdx} className="px-3 py-2 text-foreground/80">
676
+ <InlineMarkdown text={cell} />
677
+ </td>
678
+ ))}
679
+ </tr>
680
+ ))}
681
+ </tbody>
682
+ </table>
683
+ </div>
684
+ );
685
+ }
686
+
687
+ default:
688
+ return (
689
+ <p className={PARAGRAPH_CLASS}>
690
+ <InlineMarkdown text={block.content} />
691
+ </p>
692
+ );
693
+ }
694
+ };
695
+
696
+ const SimpleCodeBlock: React.FC<{ block: Block }> = ({ block }) => {
697
+ const codeRef = useRef<HTMLElement>(null);
698
+
699
+ useEffect(() => {
700
+ if (codeRef.current) {
701
+ codeRef.current.removeAttribute("data-highlighted");
702
+ codeRef.current.className = `hljs font-mono${block.language ? ` language-${block.language}` : ""}`;
703
+ hljs.highlightElement(codeRef.current);
704
+ }
705
+ }, [block.content, block.language]);
706
+
707
+ return (
708
+ <div className="relative group my-5">
709
+ <pre className="bg-muted/50 border border-border/30 rounded-lg overflow-x-auto">
710
+ <code
711
+ ref={codeRef}
712
+ className={`hljs font-mono${block.language ? ` language-${block.language}` : ""}`}
713
+ >
714
+ {block.content}
715
+ </code>
716
+ </pre>
717
+ {block.language && (
718
+ <span className="absolute top-2 right-2 text-[9px] font-mono text-muted-foreground/50">
719
+ {block.language}
720
+ </span>
721
+ )}
722
+ </div>
723
+ );
724
+ };
725
+
726
+ /**
727
+ * Block dangerous link protocols (javascript:, data:, vbscript:, file:) from
728
+ * rendering as clickable anchors in the diff view. Plan content is attacker-
729
+ * influenced (Claude pulls from source comments, READMEs, fetched URLs), so
730
+ * a malicious `[click me](javascript:...)` link embedded in a plan must not
731
+ * render as a live <a>. Mirrors the same guard in Viewer.tsx; returns null
732
+ * for blocked schemes so the caller can render the anchor text as plain
733
+ * text instead of a clickable link.
734
+ */
735
+ const DANGEROUS_PROTOCOL = /^\s*(javascript|data|vbscript|file)\s*:/i;
736
+ function sanitizeLinkUrl(url: string): string | null {
737
+ if (DANGEROUS_PROTOCOL.test(url)) return null;
738
+ return url;
739
+ }
740
+
741
+ const InlineMarkdown: React.FC<{ text: string }> = ({ text }) => {
742
+ const parts: React.ReactNode[] = [];
743
+ let remaining = text;
744
+ let key = 0;
745
+ let previousChar = "";
746
+
747
+ while (remaining.length > 0) {
748
+ // Plan-diff word markers: <ins>...</ins> and <del>...</del>. These are
749
+ // emitted by PlanCleanDiffView's modified-block renderer when the
750
+ // diff engine populates `inlineTokens`. Content is recursively parsed
751
+ // so inline formatting inside a diff token (e.g., **bold** on an
752
+ // added word) still renders.
753
+ let match = remaining.match(/^<(ins|del)>([\s\S]+?)<\/\1>/);
754
+ if (match) {
755
+ const tag = match[1] as "ins" | "del";
756
+ const className =
757
+ tag === "ins" ? "plan-diff-word-added" : "plan-diff-word-removed";
758
+ if (tag === "ins") {
759
+ parts.push(
760
+ <ins key={key++} className={className}>
761
+ <InlineMarkdown text={match[2]} />
762
+ </ins>
763
+ );
764
+ } else {
765
+ parts.push(
766
+ <del key={key++} className={className}>
767
+ <InlineMarkdown text={match[2]} />
768
+ </del>
769
+ );
770
+ }
771
+ remaining = remaining.slice(match[0].length);
772
+ previousChar = match[0][match[0].length - 1] || previousChar;
773
+ continue;
774
+ }
775
+
776
+ // Bold: **text** ([\s\S]+? allows matching across hard line breaks)
777
+ match = remaining.match(/^\*\*([\s\S]+?)\*\*/);
778
+ if (match) {
779
+ parts.push(
780
+ <strong key={key++} className="font-semibold">
781
+ <InlineMarkdown text={match[1]} />
782
+ </strong>
783
+ );
784
+ remaining = remaining.slice(match[0].length);
785
+ previousChar = match[0][match[0].length - 1] || previousChar;
786
+ continue;
787
+ }
788
+
789
+ // Italic: *text* or _text_ (avoid intraword underscores)
790
+ match = remaining.match(/^\*([\s\S]+?)\*/);
791
+ if (match) {
792
+ parts.push(<em key={key++}><InlineMarkdown text={match[1]} /></em>);
793
+ remaining = remaining.slice(match[0].length);
794
+ previousChar = match[0][match[0].length - 1] || previousChar;
795
+ continue;
796
+ }
797
+
798
+ match = !/\w/.test(previousChar)
799
+ ? remaining.match(/^_([^_\s](?:[\s\S]*?[^_\s])?)_(?!\w)/)
800
+ : null;
801
+ if (match) {
802
+ parts.push(<em key={key++}><InlineMarkdown text={match[1]} /></em>);
803
+ remaining = remaining.slice(match[0].length);
804
+ previousChar = match[0][match[0].length - 1] || previousChar;
805
+ continue;
806
+ }
807
+
808
+ match = remaining.match(/^`([^`]+)`/);
809
+ if (match) {
810
+ parts.push(
811
+ <code
812
+ key={key++}
813
+ className="px-1.5 py-0.5 rounded bg-muted text-sm font-mono"
814
+ >
815
+ {match[1]}
816
+ </code>
817
+ );
818
+ remaining = remaining.slice(match[0].length);
819
+ previousChar = match[0][match[0].length - 1] || previousChar;
820
+ continue;
821
+ }
822
+
823
+ match = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
824
+ if (match) {
825
+ // Recursively parse the anchor text so <ins>/<del> diff tags (and
826
+ // other inline markdown) inside the link render correctly instead of
827
+ // showing up as literal HTML tag text. Sanitize the href: dangerous
828
+ // schemes (javascript:, data:, vbscript:, file:) are rendered as
829
+ // plain text instead of a live anchor to block XSS via plan content.
830
+ const safeHref = sanitizeLinkUrl(match[2]);
831
+ if (safeHref === null) {
832
+ parts.push(
833
+ <span key={key++}>
834
+ <InlineMarkdown text={match[1]} />
835
+ </span>
836
+ );
837
+ } else {
838
+ parts.push(
839
+ <a
840
+ key={key++}
841
+ href={safeHref}
842
+ target="_blank"
843
+ rel="noopener noreferrer"
844
+ className="text-primary underline underline-offset-2 hover:text-primary/80"
845
+ >
846
+ <InlineMarkdown text={match[1]} />
847
+ </a>
848
+ );
849
+ }
850
+ remaining = remaining.slice(match[0].length);
851
+ previousChar = match[0][match[0].length - 1] || previousChar;
852
+ continue;
853
+ }
854
+
855
+ // Hard line break: two+ trailing spaces + newline, or backslash + newline
856
+ match = remaining.match(/ {2,}\n|\\\n/);
857
+ if (match && match.index !== undefined) {
858
+ const before = remaining.slice(0, match.index);
859
+ if (before) {
860
+ parts.push(<InlineMarkdown key={key++} text={before} />);
861
+ }
862
+ parts.push(<br key={key++} />);
863
+ remaining = remaining.slice(match.index + match[0].length);
864
+ previousChar = "\n";
865
+ continue;
866
+ }
867
+
868
+ // Include '<' so the loop re-enters when an <ins>/<del> tag is next,
869
+ // rather than swallowing it as plain text.
870
+ const nextSpecial = remaining.slice(1).search(/[\*_`\[!<]/);
871
+ if (nextSpecial === -1) {
872
+ parts.push(remaining);
873
+ previousChar = remaining[remaining.length - 1] || previousChar;
874
+ break;
875
+ } else {
876
+ const plainText = remaining.slice(0, nextSpecial + 1);
877
+ parts.push(plainText);
878
+ remaining = remaining.slice(nextSpecial + 1);
879
+ previousChar = plainText[plainText.length - 1] || previousChar;
880
+ }
881
+ }
882
+
883
+ return <>{parts}</>;
884
+ };