@plannotator/ui 0.53.0 → 0.54.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 (145) hide show
  1. package/HANDOFF.md +284 -27
  2. package/README.md +84 -14
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +177 -34
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/AnnotationToolstrip.tsx +45 -40
  9. package/components/ApproveDropdown.tsx +7 -9
  10. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  11. package/components/AttachmentsButton.tsx +1 -1
  12. package/components/BlockRenderer.tsx +19 -5
  13. package/components/CodeFilePopout.tsx +140 -5
  14. package/components/CommentPopover.tsx +46 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DiagramBlock.tsx +17 -12
  17. package/components/EditorAnnotationCard.tsx +1 -1
  18. package/components/ExportModal.tsx +77 -7
  19. package/components/HostTitle.tsx +0 -4
  20. package/components/HtmlSurfaceControls.tsx +2 -1
  21. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  22. package/components/ImageAnnotator/index.tsx +24 -4
  23. package/components/ImageLightbox.tsx +658 -120
  24. package/components/ImagePeek.tsx +38 -4
  25. package/components/ImageRegions.tsx +369 -0
  26. package/components/ImageThumbnail.tsx +1 -1
  27. package/components/ImageZoomStage.tsx +796 -0
  28. package/components/InboxAnnouncementDialog.tsx +202 -0
  29. package/components/InlineMarkdown.tsx +152 -24
  30. package/components/KeyboardShortcuts.tsx +23 -0
  31. package/components/LazyBlockBoundary.tsx +100 -0
  32. package/components/ListMarker.tsx +3 -3
  33. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  34. package/components/MentionPicker.tsx +51 -0
  35. package/components/MenuVersionSection.tsx +31 -9
  36. package/components/PlanDecisionMenuExtras.tsx +129 -0
  37. package/components/PlanHeaderMenu.tsx +15 -1
  38. package/components/ProviderIcons.tsx +8 -0
  39. package/components/RefHoverCard.tsx +913 -0
  40. package/components/SendFailureToast.tsx +126 -0
  41. package/components/Settings.tsx +617 -126
  42. package/components/SkillReferenceMenu.tsx +39 -12
  43. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  44. package/components/ThemeProvider.tsx +14 -1
  45. package/components/ThemeTab.tsx +16 -24
  46. package/components/Tooltip.tsx +49 -18
  47. package/components/Viewer.tsx +316 -47
  48. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  49. package/components/ai/NewChatButton.tsx +74 -0
  50. package/components/blocks/CodeBlock.tsx +1 -1
  51. package/components/blocks/HtmlBlock.tsx +7 -1
  52. package/components/blocks/QuestionBlock.tsx +522 -62
  53. package/components/blocks/QuestionContext.tsx +3 -0
  54. package/components/diagram/DiagramComposer.tsx +5 -1
  55. package/components/diagram/DiagramPending.tsx +12 -2
  56. package/components/diagram/DiagramViewer.tsx +19 -3
  57. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  58. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  59. package/components/html-embed/index.ts +3 -0
  60. package/components/html-embed/languages.ts +15 -0
  61. package/components/html-embed/loadGate.ts +107 -0
  62. package/components/html-viewer/HtmlViewer.tsx +69 -6
  63. package/components/html-viewer/bridge-script.asset.js +504 -12
  64. package/components/html-viewer/bridge-script.lite.ts +1 -1
  65. package/components/html-viewer/bridge-script.ts +545 -12
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +265 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  78. package/hooks/useAIChat.ts +72 -25
  79. package/hooks/useAnnotationDraft.ts +71 -6
  80. package/hooks/useAnnotationHighlighter.ts +57 -3
  81. package/hooks/useCodeAnnotationDraft.ts +113 -17
  82. package/hooks/useCookieHostSetting.ts +96 -0
  83. package/hooks/useDocumentFont.ts +17 -0
  84. package/hooks/useDocumentTextSize.ts +20 -0
  85. package/hooks/useFileBrowser.ts +10 -1
  86. package/hooks/useLinkedDoc.ts +226 -13
  87. package/hooks/useMentionAutocomplete.ts +1 -1
  88. package/hooks/usePackageChangesSetting.ts +90 -0
  89. package/hooks/usePlanLookDialog.ts +67 -0
  90. package/hooks/useSharing.ts +11 -7
  91. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  92. package/hooks/useThemePaletteHalves.ts +37 -0
  93. package/hooks/useUpdateCheck.ts +22 -4
  94. package/hooks/useUpdateToast.ts +1 -1
  95. package/hooks/useVimSelection.ts +9 -0
  96. package/package.json +23 -22
  97. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  98. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  99. package/shortcuts/core.ts +63 -4
  100. package/shortcuts/index.ts +1 -0
  101. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  102. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  103. package/shortcuts/runtime.ts +14 -6
  104. package/styles.css +1 -1
  105. package/theme.css +179 -1
  106. package/themes/plannotator-graphite.css +66 -0
  107. package/types.ts +26 -9
  108. package/utils/agentSwitch.ts +41 -20
  109. package/utils/agentToolSetting.ts +1 -1
  110. package/utils/aiChatFormat.ts +23 -7
  111. package/utils/annotationOwnsHighlight.ts +4 -2
  112. package/utils/asciidocParser.ts +570 -0
  113. package/utils/autoUpdateNotice.ts +24 -0
  114. package/utils/belowTitleSlot.ts +174 -0
  115. package/utils/codeLineScroll.ts +100 -0
  116. package/utils/commentSubmitKey.ts +48 -0
  117. package/utils/cssFontFamily.ts +65 -0
  118. package/utils/decisionSpec.ts +190 -2
  119. package/utils/defaultNotesApp.ts +1 -1
  120. package/utils/diffFonts.ts +4 -1
  121. package/utils/documentFont.ts +68 -0
  122. package/utils/documentTextSize.ts +54 -0
  123. package/utils/fontDetect.ts +63 -0
  124. package/utils/inboxAnnouncement.ts +78 -0
  125. package/utils/lookAndFeelAnnouncement.ts +82 -5
  126. package/utils/markdownExtensions.ts +2 -2
  127. package/utils/mentions.ts +34 -5
  128. package/utils/mermaid.ts +20 -3
  129. package/utils/mermaidTheme.ts +3 -51
  130. package/utils/notion.ts +58 -0
  131. package/utils/parser.ts +28 -1
  132. package/utils/platform.ts +46 -0
  133. package/utils/refCard.ts +82 -0
  134. package/utils/richClipboard.ts +380 -0
  135. package/utils/richClipboardLists.ts +57 -0
  136. package/utils/richCopyPolicy.ts +22 -0
  137. package/utils/scrollAnchor.ts +82 -0
  138. package/utils/sectionRefs.ts +123 -0
  139. package/utils/selectionBoundary.ts +40 -0
  140. package/utils/sendFailure.ts +85 -0
  141. package/utils/sharing.ts +5 -2
  142. package/utils/themeRegistry.ts +10 -0
  143. package/utils/themeTokens.ts +74 -0
  144. package/utils/updateNotice.ts +30 -8
  145. package/utils/vscodePanel.ts +9 -0
@@ -0,0 +1,202 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { ArrowUpRight, Play } from 'lucide-react';
3
+ import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
4
+
5
+ /**
6
+ * One-time announcement for the Plannotator Inbox, on plan review, annotate
7
+ * and code review. Nearly full screen: the Inbox film from plannotator.ai/inbox
8
+ * fills the top, under it one title, one sentence, "Close" and one link to the
9
+ * Inbox page.
10
+ *
11
+ * Built on the shared dialog (`./ui/dialog`, Base UI): modal, focus trapped,
12
+ * focus restored on close, the backdrop and Escape close it. Like the other
13
+ * announcements it swallows Mod+Enter on the capture phase, so a keystroke
14
+ * aimed at it cannot approve a plan or post a review behind it.
15
+ *
16
+ * The film is the marketing page's own file (`apps/marketing/public/assets/
17
+ * inbox-film.mp4`, 1920x1080, ~14 MB, voice-led), served from
18
+ * plannotator.ai/assets like the other announcements' footage. It never
19
+ * autoplays and is `preload="none"`: opening the dialog fetches only the poster
20
+ * (~120 KB), and the film downloads when the reader presses play. Until then
21
+ * the frame is the poster with one play button, as on the Inbox page; after
22
+ * that the native controls take over (sound, seeking, full screen). If the
23
+ * film cannot load, the frame says so and the rest of the dialog is unchanged.
24
+ */
25
+
26
+ /** The Inbox page on plannotator.ai. */
27
+ export const INBOX_PAGE_URL = 'https://plannotator.ai/inbox';
28
+
29
+ /** Where the marketing deploy publishes `apps/marketing/public/assets/`. */
30
+ const MEDIA_BASE_URL = 'https://plannotator.ai/assets';
31
+
32
+ export const INBOX_FILM = {
33
+ mp4: `${MEDIA_BASE_URL}/inbox-film.mp4`,
34
+ poster: `${MEDIA_BASE_URL}/inbox-film-poster.jpg`,
35
+ } as const;
36
+
37
+ /** The film is 1920x1080. */
38
+ const FILM_ASPECT = 16 / 9;
39
+
40
+ /**
41
+ * The panel's width also follows the viewport height, so on a short window the
42
+ * film shrinks until film and footer fit instead of scrolling the film away.
43
+ * 2rem is the overlay's padding, 10rem the footer on a wide panel.
44
+ */
45
+ const PANEL_WIDTH =
46
+ `min(1200px, 100%, calc((var(--pn-viewport-height, 100dvh) - 2rem - 10rem) * ${FILM_ASPECT}))`;
47
+
48
+ interface InboxAnnouncementDialogProps {
49
+ readonly isOpen: boolean;
50
+ /**
51
+ * Marks the announcement seen and closes it. Wired to Close, Escape, the
52
+ * backdrop and the link to the Inbox page.
53
+ */
54
+ readonly onDismiss: () => void;
55
+ }
56
+
57
+ function Film() {
58
+ const videoRef = useRef<HTMLVideoElement>(null);
59
+ const [started, setStarted] = useState(false);
60
+ const [failed, setFailed] = useState(false);
61
+
62
+ const play = () => {
63
+ setStarted(true);
64
+ // A rejected play() (autoplay policy, no media) leaves the native
65
+ // controls up, which offer play again.
66
+ void videoRef.current?.play()?.catch(() => {});
67
+ };
68
+
69
+ return (
70
+ <div className="relative w-full shrink-0 overflow-hidden border-b border-border bg-muted" style={{ aspectRatio: String(FILM_ASPECT) }}>
71
+ <video
72
+ ref={videoRef}
73
+ data-inbox-film
74
+ poster={INBOX_FILM.poster}
75
+ controls={started}
76
+ playsInline
77
+ preload="none"
78
+ aria-label="The Plannotator Inbox film"
79
+ // A source that cannot be fetched errors on <source>; a file that was
80
+ // fetched but cannot be decoded errors on the element itself.
81
+ onError={() => setFailed(true)}
82
+ className="absolute inset-0 h-full w-full bg-muted object-contain"
83
+ >
84
+ <source src={INBOX_FILM.mp4} type="video/mp4" onError={() => setFailed(true)} />
85
+ </video>
86
+
87
+ {failed ? (
88
+ <p
89
+ data-inbox-film-unavailable
90
+ className="absolute inset-0 grid place-items-center bg-background/75 px-6 text-center text-sm text-muted-foreground backdrop-blur-sm"
91
+ >
92
+ The film could not load. It is also on the Inbox page.
93
+ </p>
94
+ ) : !started ? (
95
+ <button
96
+ type="button"
97
+ onClick={play}
98
+ aria-label="Play the Inbox film"
99
+ data-inbox-film-play
100
+ className="group absolute inset-0 grid place-items-center outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
101
+ >
102
+ <span className="grid size-16 place-items-center rounded-full border border-border/60 bg-background/80 text-foreground shadow-lg backdrop-blur-sm transition-transform motion-reduce:transition-none group-hover:scale-105 sm:size-20">
103
+ <Play className="ml-1 size-6 fill-current sm:size-7" aria-hidden="true" />
104
+ </span>
105
+ </button>
106
+ ) : null}
107
+ </div>
108
+ );
109
+ }
110
+
111
+ export function InboxAnnouncementDialog({ isOpen, onDismiss }: InboxAnnouncementDialogProps) {
112
+ const closeRef = useRef<HTMLButtonElement>(null);
113
+ const onDismissRef = useRef(onDismiss);
114
+
115
+ useEffect(() => {
116
+ onDismissRef.current = onDismiss;
117
+ }, [onDismiss]);
118
+
119
+ useEffect(() => {
120
+ if (!isOpen) return;
121
+ const handleKeyDown = (event: KeyboardEvent) => {
122
+ // Escape is handled here rather than by the dialog so that no window
123
+ // handler of the App behind it (close a panel, leave a mode) sees it.
124
+ if (event.key === 'Escape') {
125
+ event.preventDefault();
126
+ event.stopPropagation();
127
+ onDismissRef.current();
128
+ return;
129
+ }
130
+ // Both apps submit their decision on Mod+Enter from a window-level
131
+ // handler; swallow it on the capture phase.
132
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
133
+ event.preventDefault();
134
+ event.stopPropagation();
135
+ }
136
+ };
137
+ document.addEventListener('keydown', handleKeyDown, true);
138
+ return () => document.removeEventListener('keydown', handleKeyDown, true);
139
+ }, [isOpen]);
140
+
141
+ if (!isOpen) return null;
142
+
143
+ return (
144
+ <Dialog
145
+ open
146
+ onOpenChange={(open) => {
147
+ if (!open) onDismissRef.current();
148
+ }}
149
+ >
150
+ <DialogContent
151
+ hideClose
152
+ initialFocus={closeRef}
153
+ data-inbox-announcement-dialog
154
+ backdropClassName="bg-background/90 backdrop-blur-sm"
155
+ style={{ width: PANEL_WIDTH }}
156
+ className="max-h-full max-w-none rounded-xl bg-card text-card-foreground shadow-2xl motion-reduce:transition-none"
157
+ >
158
+ <Film />
159
+
160
+ <div className="flex min-h-0 flex-col gap-5 overflow-y-auto px-5 py-5 sm:flex-row sm:items-end sm:justify-between sm:gap-8 sm:px-7 sm:py-6">
161
+ <div className="min-w-0">
162
+ <DialogTitle className="text-balance text-xl font-semibold tracking-tight sm:text-2xl">
163
+ The Plannotator Inbox
164
+ </DialogTitle>
165
+ <DialogDescription className="mt-1.5 max-w-xl text-pretty leading-relaxed">
166
+ Questions, blockers and reviews from your agents arrive in one inbox while they work,
167
+ and your answers go back to them.
168
+ </DialogDescription>
169
+ </div>
170
+
171
+ <div className="flex shrink-0 flex-col-reverse gap-2 sm:flex-row sm:items-center">
172
+ <button
173
+ ref={closeRef}
174
+ type="button"
175
+ data-inbox-announcement-close
176
+ onClick={() => onDismissRef.current()}
177
+ className="min-h-10 rounded-lg border border-border px-4 text-sm font-medium text-foreground outline-none transition-colors motion-reduce:transition-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-card"
178
+ >
179
+ Close
180
+ </button>
181
+ <a
182
+ href={INBOX_PAGE_URL}
183
+ target="_blank"
184
+ rel="noopener noreferrer"
185
+ data-inbox-announcement-learn-more
186
+ // Following the link counts as seeing the announcement. Closed on
187
+ // the next task: a link removed from the page during its own click
188
+ // is not followed.
189
+ onClick={() => {
190
+ setTimeout(() => onDismissRef.current(), 0);
191
+ }}
192
+ className="inline-flex min-h-10 items-center justify-center gap-1.5 rounded-lg bg-primary px-5 text-sm font-medium text-primary-foreground outline-none transition-opacity motion-reduce:transition-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-card"
193
+ >
194
+ Learn about the Inbox
195
+ <ArrowUpRight className="size-4" aria-hidden="true" />
196
+ </a>
197
+ </div>
198
+ </div>
199
+ </DialogContent>
200
+ </Dialog>
201
+ );
202
+ }
@@ -1,7 +1,7 @@
1
1
  import React, { useState, useRef, useCallback, useEffect, useMemo } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/core/code-file";
4
- import { forgeRefLinks } from "@plannotator/core/forge-refs";
4
+ import { findCrossRepoRef, forgeRefLinks, matchCrossRepoRef, type CrossRepoRef } from "@plannotator/core/forge-refs";
5
5
  import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight";
6
6
  import { useFenceTheme } from "../hooks/useFenceTheme";
7
7
  import { transformPlainText } from "../utils/inlineTransforms";
@@ -380,6 +380,34 @@ export function trimUrlTail(url: string): string {
380
380
  return url;
381
381
  }
382
382
 
383
+ // Index of the first bare `http(s)://` URL that starts after index 0 and
384
+ // before `end` at a word boundary AND runs past `end`, or -1. Only such a
385
+ // URL needs the chunk split: one that ends inside the chunk is linked by
386
+ // emitPlainTextWithBareUrls in the same pass, exactly as before, so a
387
+ // paragraph of many URLs stays one pass instead of a loop turn (and a
388
+ // rescan of the rest of the text) per URL. Index 0 is left to the caller's
389
+ // own bare-URL check, which already ran for it.
390
+ const BARE_URL_SCHEME = /https?:\/\//g;
391
+ const BARE_URL_AT = /https?:\/\/[^\s<>"']+/y;
392
+ function bareUrlRunningPast(text: string, end: number): number {
393
+ // Only a scheme that starts before `end` counts, so read no further than
394
+ // `end` plus the longest scheme ("https://" less its first character).
395
+ const window = text.slice(0, end + 7);
396
+ BARE_URL_SCHEME.lastIndex = 1;
397
+ let m: RegExpExecArray | null;
398
+ while ((m = BARE_URL_SCHEME.exec(window)) !== null && m.index < end) {
399
+ if (/\w/.test(window[m.index - 1])) continue;
400
+ BARE_URL_AT.lastIndex = m.index;
401
+ const url = BARE_URL_AT.exec(text);
402
+ if (!url) continue;
403
+ const urlEnd = m.index + trimUrlTail(url[0]).length;
404
+ if (urlEnd > end) return m.index;
405
+ // The URL ends inside the chunk; look for the next one after it.
406
+ BARE_URL_SCHEME.lastIndex = Math.max(urlEnd, m.index + 1);
407
+ }
408
+ return -1;
409
+ }
410
+
383
411
  // Scan a plain-text chunk for bare https?:// URLs and bare code file paths
384
412
  // at word boundaries, emitting them as interactive nodes. Surrounding text
385
413
  // passes through transformPlainText for emoji shortcodes + smart punctuation.
@@ -474,35 +502,73 @@ function emitPlainTextWithBareUrls(
474
502
  }
475
503
  }
476
504
 
477
- /** An image a host resolver chose; a load failure shows the placeholder. */
505
+ /**
506
+ * An image a host resolver chose; a load failure shows the placeholder.
507
+ *
508
+ * The `<img>` element itself never leaves the document: on failure the
509
+ * placeholder is inserted BEFORE it (React's reference node is then the
510
+ * `<img>`, which it owns) and the `<img>` is hidden. Replacing the `<img>`
511
+ * would make React insert before the node that follows it, and when that is
512
+ * a text node the annotation highlighter already moved into a `<mark>` (a
513
+ * restored comment starting right after the image), `insertBefore` throws
514
+ * and the whole document unmounts. A loaded image's DOM is unchanged.
515
+ */
478
516
  const HostResolvedImage: React.FC<{
479
517
  src: string;
480
518
  alt: string;
519
+ /** The src as written in the markdown (`data-image-src`, read by the image viewer's region comments). */
520
+ writtenSrc?: string;
481
521
  onImageClick?: (src: string, alt: string) => void;
482
- }> = ({ src, alt, onImageClick }) => {
522
+ }> = ({ src, alt, writtenSrc, onImageClick }) => {
483
523
  const [failedSrc, setFailedSrc] = useState<string | null>(null);
484
- if (failedSrc === src) {
485
- return (
486
- <span className={UNAVAILABLE_IMAGE_CLASS} title="Image could not be loaded" data-image-unavailable="">
487
- {alt || 'Image'}
488
- </span>
489
- );
490
- }
524
+ const failed = failedSrc === src;
491
525
  return (
492
- <img
493
- src={src}
494
- alt={alt}
495
- className="max-w-full rounded my-2 cursor-zoom-in"
496
- loading="lazy"
497
- onError={() => setFailedSrc(src)}
498
- onClick={(e) => {
499
- e.stopPropagation();
500
- onImageClick?.(src, alt);
501
- }}
502
- />
526
+ <>
527
+ {failed && (
528
+ <span key="unavailable" className={`${UNAVAILABLE_IMAGE_CLASS} annotation-exclude`} title="Image could not be loaded" data-image-unavailable="">
529
+ {alt || 'Image'}
530
+ </span>
531
+ )}
532
+ <img
533
+ key="image"
534
+ src={src}
535
+ alt={failed ? '' : alt}
536
+ data-image-src={writtenSrc}
537
+ data-image-failed={failed ? '' : undefined}
538
+ hidden={failed || undefined}
539
+ aria-hidden={failed || undefined}
540
+ className={failed ? undefined : 'max-w-full rounded my-2 cursor-zoom-in'}
541
+ loading="lazy"
542
+ onError={() => setFailedSrc(src)}
543
+ onClick={(e) => {
544
+ e.stopPropagation();
545
+ if (!failed) onImageClick?.(src, alt);
546
+ }}
547
+ />
548
+ </>
503
549
  );
504
550
  };
505
551
 
552
+ /**
553
+ * Whether a digit-only 6/8-digit `#123456` is an issue ref (when a ref link can
554
+ * be built) rather than a hex swatch. Off by default: every host renders such
555
+ * values as colours. The Plannotator Inbox turns it on through
556
+ * `configurePlannotatorUI({ digitHexIssueRefs: true })` (#1932).
557
+ */
558
+ let digitHexIssueRefs = false;
559
+
560
+ export function setDigitHexIssueRefs(on: boolean): void {
561
+ digitHexIssueRefs = on;
562
+ }
563
+
564
+ export function resetDigitHexIssueRefs(): void {
565
+ digitHexIssueRefs = false;
566
+ }
567
+
568
+ export function getDigitHexIssueRefs(): boolean {
569
+ return digitHexIssueRefs;
570
+ }
571
+
506
572
  /**
507
573
  * Scanner that walks a text string and emits React nodes for inline markdown:
508
574
  * emphasis (**bold**, *italic*, _italic_, ***both***), `code`, ~~strikethrough~~,
@@ -543,6 +609,13 @@ export const InlineMarkdown: React.FC<{
543
609
  const resolveLocalLink = useLocalLinkResolver();
544
610
  const resolveImage = useImageResolver();
545
611
  const refLinks = forgeRefLinks({ display: githubRepo, host: repoHost });
612
+ // Cross-repo `owner/repo#123` refs: only for a caller that names its
613
+ // project's repo (it knows the forge); otherwise they stay plain text, never
614
+ // a guessed github.com link. With a code-file handler, an `owner/repo` that
615
+ // is a code path not known to be missing stays the file (`src/app.js#12`).
616
+ const crossRepoProject = githubRepo ? { host: repoHost } : null;
617
+ const crossRepoAccept = (ref: CrossRepoRef): boolean =>
618
+ !(onOpenCodeFile && isCodeFilePathStrict(ref.path) && gateCodePath(ref.path, validation ?? null).render !== 'plain');
546
619
  const parts: React.ReactNode[] = [];
547
620
  let remaining = text;
548
621
  let key = 0;
@@ -650,6 +723,32 @@ export const InlineMarkdown: React.FC<{
650
723
  }
651
724
  }
652
725
 
726
+ // Cross-repo ref: owner/repo#123, shown as written, linked on the
727
+ // project's forge host. Read before file-path detection, so
728
+ // `vercel/next.js#1` is a ref, not a path (unless the viewer knows that
729
+ // file, see crossRepoAccept); `docs/guide.md#2` stays a path.
730
+ const crossRepoRef = crossRepoProject ? matchCrossRepoRef(remaining, previousChar, crossRepoProject) : null;
731
+ if (crossRepoRef && crossRepoAccept(crossRepoRef)) {
732
+ parts.push(
733
+ crossRepoRef.href ? (
734
+ <a
735
+ key={key++}
736
+ href={crossRepoRef.href}
737
+ target="_blank"
738
+ rel="noopener noreferrer"
739
+ className="text-primary font-medium hover:underline"
740
+ >
741
+ {crossRepoRef.label}
742
+ </a>
743
+ ) : (
744
+ crossRepoRef.label
745
+ ),
746
+ );
747
+ remaining = remaining.slice(crossRepoRef.label.length);
748
+ previousChar = crossRepoRef.label[crossRepoRef.label.length - 1];
749
+ continue;
750
+ }
751
+
653
752
  // Autolinks: <https://url> or <email@domain.com>
654
753
  match = remaining.match(/^<(https?:\/\/[^>]+)>/);
655
754
  if (match) {
@@ -846,9 +945,19 @@ export const InlineMarkdown: React.FC<{
846
945
  }
847
946
 
848
947
  // Hex color swatch — 3/4-digit forms need an a-f letter to avoid matching issue refs like #123.
948
+ // 6/8-digit forms with no letter (#186379) are ambiguous. By default they
949
+ // stay swatches (greys like #333333 are common in docs; six-digit issues are
950
+ // rare). A host that opts in with `configurePlannotatorUI({ digitHexIssueRefs:
951
+ // true })` (the Plannotator Inbox, #1932) gets an issue ref instead, but only
952
+ // when a ref link can actually be built (`refLinks`: a repo path on a known
953
+ // forge) and the ref branch below would take it: a word boundary and no
954
+ // leading zero (issue numbers never start with 0, so #000000 stays a colour).
849
955
  match = remaining.match(
850
956
  /^(#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|(?=[0-9a-fA-F]*[a-fA-F])[0-9a-fA-F]{4}|(?=[0-9a-fA-F]*[a-fA-F])[0-9a-fA-F]{3}))(?![0-9a-fA-F\w])/,
851
957
  );
958
+ if (match && digitHexIssueRefs && refLinks && /^#[1-9]\d*$/.test(match[1]) && !/\w/.test(previousChar)) {
959
+ match = null;
960
+ }
852
961
  if (match) {
853
962
  const hex = match[1];
854
963
  parts.push(
@@ -872,8 +981,8 @@ export const InlineMarkdown: React.FC<{
872
981
  }
873
982
 
874
983
  // Issue / PR reference: #123 — only at word boundary, digits only.
875
- // Hex-swatch branch above already consumed #fff / #123abc etc., so a bare
876
- // #\d+ here is safe to treat as an issue ref.
984
+ // Hex-swatch branch above already consumed #fff / #123abc etc. (and digit-only
985
+ // #123456 / #12345678 unless the host opted in), so a bare #\d+ here is an issue ref.
877
986
  if (!/\w/.test(previousChar)) {
878
987
  match = remaining.match(/^#(\d+)(?!\w)/);
879
988
  if (match) {
@@ -1027,12 +1136,13 @@ export const InlineMarkdown: React.FC<{
1027
1136
  resolveImage ? (
1028
1137
  // With a host resolver, an image that fails to load becomes the
1029
1138
  // same placeholder instead of a broken-image icon.
1030
- <HostResolvedImage key={key++} src={imgSrc} alt={alt} onImageClick={onImageClick} />
1139
+ <HostResolvedImage key={key++} src={imgSrc} alt={alt} writtenSrc={src} onImageClick={onImageClick} />
1031
1140
  ) : (
1032
1141
  <img
1033
1142
  key={key++}
1034
1143
  src={imgSrc}
1035
1144
  alt={alt}
1145
+ data-image-src={src}
1036
1146
  className="max-w-full rounded my-2 cursor-zoom-in"
1037
1147
  loading="lazy"
1038
1148
  onClick={(e) => {
@@ -1272,6 +1382,24 @@ export const InlineMarkdown: React.FC<{
1272
1382
  // multi-char patterns like emoji shortcodes. Bare URLs are instead
1273
1383
  // detected inline via emitPlainTextWithBareUrls() below.
1274
1384
  const nextSpecial = remaining.slice(1).search(/[\*_`\[!~\\<#@$]/);
1385
+ // A bare URL that starts inside this plain-text chunk and runs past its
1386
+ // end would be cut there, and `_`, `~`, `#`, `@`, `$` and `!` all end a
1387
+ // chunk (`…/w/ws_01…` rendered a link to `…/w/ws`). Stop the chunk where
1388
+ // that URL starts instead, so the bare-URL branch above takes it whole.
1389
+ const chunkEnd = nextSpecial === -1 ? remaining.length : nextSpecial + 1;
1390
+ // Same for an owner/repo#123 ref: `#` (and `_` in a name) ends a chunk, so
1391
+ // stop before the ref and let the cross-repo branch above take it whole.
1392
+ // A ref inside a URL never splits it: the URL starts first and wins.
1393
+ const urlAt = bareUrlRunningPast(remaining, chunkEnd);
1394
+ const refAt = crossRepoProject ? findCrossRepoRef(remaining, urlAt === -1 ? chunkEnd : urlAt, crossRepoProject, crossRepoAccept) : -1;
1395
+ const splitAt = refAt !== -1 ? refAt : urlAt;
1396
+ if (splitAt !== -1) {
1397
+ const before = remaining.slice(0, splitAt);
1398
+ emitPlainTextWithBareUrls(before, previousChar, parts, () => key++, onOpenCodeFile, validation, imageBaseDir);
1399
+ previousChar = before[before.length - 1] || previousChar;
1400
+ remaining = remaining.slice(splitAt);
1401
+ continue;
1402
+ }
1275
1403
  const plainText = nextSpecial === -1 ? remaining : remaining.slice(0, nextSpecial + 1);
1276
1404
  emitPlainTextWithBareUrls(plainText, previousChar, parts, () => key++, onOpenCodeFile, validation, imageBaseDir);
1277
1405
  previousChar = plainText[plainText.length - 1] || previousChar;
@@ -2,7 +2,11 @@ import React from 'react';
2
2
  import {
3
3
  formatShortcutBindingTokens,
4
4
  listScopeShortcuts,
5
+ resolveShortcutBindings,
6
+ reviewExplorerShortcuts,
7
+ reviewMarkdownPreviewShortcuts,
5
8
  vimSelectionShortcuts,
9
+ type ShortcutScopeDefinition,
6
10
  } from '../shortcuts';
7
11
  import { isMac, modKey, altKey } from '../utils/platform';
8
12
 
@@ -141,6 +145,23 @@ const vimShortcuts: ShortcutSection[] = (() => {
141
145
  return Array.from(sections, ([title, shortcuts]) => ({ title, shortcuts }));
142
146
  })();
143
147
 
148
+ /** Sections read from registry scopes (this system's keys), so they cannot drift. */
149
+ function scopeSections(scope: ShortcutScopeDefinition<any>): ShortcutSection[] {
150
+ const sections = new Map<string, Shortcut[]>();
151
+ const entries = listScopeShortcuts(scope)
152
+ .sort((left, right) => (left.displayOrder ?? 0) - (right.displayOrder ?? 0));
153
+ for (const entry of entries) {
154
+ const shortcuts = sections.get(entry.section) ?? [];
155
+ shortcuts.push({
156
+ keys: formatShortcutBindingTokens(resolveShortcutBindings(entry)[0] ?? ''),
157
+ desc: entry.description,
158
+ hint: entry.hint,
159
+ });
160
+ sections.set(entry.section, shortcuts);
161
+ }
162
+ return Array.from(sections, ([title, shortcuts]) => ({ title, shortcuts }));
163
+ }
164
+
144
165
  const planActionShortcuts: ShortcutSection = {
145
166
  title: 'Actions',
146
167
  shortcuts: [
@@ -213,6 +234,8 @@ const reviewShortcuts: ShortcutSection[] = [
213
234
  { keys: ['Z'], desc: 'Undo collapse', hint: 'Reopens the last collapsed file and scrolls to it' },
214
235
  ],
215
236
  },
237
+ ...scopeSections(reviewExplorerShortcuts),
238
+ ...scopeSections(reviewMarkdownPreviewShortcuts),
216
239
  {
217
240
  title: 'Annotations',
218
241
  shortcuts: [
@@ -0,0 +1,100 @@
1
+ import React, { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * A part of the page that a code-split build loads as its own chunk (a
5
+ * diagram, an HTML or SVG embed, the diagram popout, the Markdown preview)
6
+ * can fail to arrive: a flaky link, a server that has stopped, or a tab older
7
+ * than the server's build, whose chunk names no longer exist. React.lazy then
8
+ * throws, and without a boundary React unmounts the whole app, taking the
9
+ * user's unsent comments and edits off the screen with it.
10
+ *
11
+ * This boundary keeps the failure in place of that one part. The caller's
12
+ * `fallback` gets a small notice with a Reload action (`LazyLoadNotice`) and
13
+ * draws it beside whatever it can still show, usually the block's source.
14
+ * The rest of the page stays mounted. Nothing reloads by itself.
15
+ *
16
+ * With no error it renders its children and nothing else.
17
+ */
18
+ export class LazyBlockBoundary extends React.Component<
19
+ { fallback: (notice: React.ReactNode) => React.ReactNode; children: React.ReactNode },
20
+ { failed: boolean; error: unknown }
21
+ > {
22
+ state = { failed: false, error: null as unknown };
23
+
24
+ static getDerivedStateFromError(error: unknown) {
25
+ return { failed: true, error };
26
+ }
27
+
28
+ componentDidCatch(error: unknown) {
29
+ console.error('[plannotator] A part of the page could not load:', error);
30
+ }
31
+
32
+ render() {
33
+ if (this.state.failed) return this.props.fallback(<LazyLoadNotice error={this.state.error} />);
34
+ return this.props.children;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * The URL of the chunk a failed dynamic import asked for, when the error names
40
+ * one on this page's origin. Chromium and Firefox put it in the message
41
+ * ("Failed to fetch dynamically imported module: http://…/assets/x.js");
42
+ * Safari does not, and then the notice says the general thing.
43
+ */
44
+ export function failedChunkUrl(error: unknown): string | null {
45
+ if (typeof window === 'undefined') return null;
46
+ const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '';
47
+ const match = /(https?:\/\/[^\s'"]+|\/assets\/[^\s'"]+)/.exec(message);
48
+ if (!match) return null;
49
+ try {
50
+ const url = new URL(match[1], window.location.href);
51
+ return url.origin === window.location.origin ? url.href : null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * One quiet line: why the part is missing, and Reload. A chunk the server
59
+ * answers with 404 is a tab older than the server's build (the server was
60
+ * updated on the same port), so the line says that instead. Finding out costs
61
+ * one HEAD request, made only after a failure.
62
+ */
63
+ export const LazyLoadNotice: React.FC<{ error: unknown }> = ({ error }) => {
64
+ const [updated, setUpdated] = useState(false);
65
+ useEffect(() => {
66
+ const url = failedChunkUrl(error);
67
+ if (url === null || typeof fetch !== 'function') return;
68
+ let live = true;
69
+ fetch(url, { method: 'HEAD', cache: 'no-store' }).then(
70
+ (response) => {
71
+ if (live && response.status === 404) setUpdated(true);
72
+ },
73
+ () => {},
74
+ );
75
+ return () => {
76
+ live = false;
77
+ };
78
+ }, [error]);
79
+
80
+ return (
81
+ <div
82
+ role="status"
83
+ data-lazy-load-failed={updated ? 'updated' : ''}
84
+ className="mb-1.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground"
85
+ >
86
+ <span>{updated ? 'This page was updated.' : "Couldn't load this view."}</span>
87
+ {/* The hit area is padding cancelled by a negative margin, so the line
88
+ looks the same: 24px tall everywhere, 44px on a touch screen. On
89
+ touch it grows upward into the block's top margin, never down over
90
+ the source under it. */}
91
+ <button
92
+ type="button"
93
+ onClick={() => window.location.reload()}
94
+ className="relative -mx-1 -my-1 px-1 py-1 font-medium text-foreground underline-offset-2 hover:underline [@media(pointer:coarse)]:-mb-1.5 [@media(pointer:coarse)]:-mt-[22px] [@media(pointer:coarse)]:pb-1.5 [@media(pointer:coarse)]:pt-[22px]"
95
+ >
96
+ {updated ? 'Reload to see this' : 'Reload'}
97
+ </button>
98
+ </div>
99
+ );
100
+ };
@@ -60,17 +60,17 @@ export const ListMarker: React.FC<ListMarkerProps> = ({
60
60
  aria-checked={interactive ? checked : undefined}
61
61
  >
62
62
  {showNumeral && (
63
- <span className="text-primary/60 tabular-nums text-right" style={{ minWidth: '1.5rem' }}>
63
+ <span className="text-primary/60 tabular-nums text-right" style={{ minWidth: '1.5rem' }} data-list-numeral="">
64
64
  {orderedIndex}.
65
65
  </span>
66
66
  )}
67
67
  {isCheckbox ? (
68
68
  checked ? (
69
- <svg className="w-4 h-4 text-success mt-[3px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
69
+ <svg data-list-check="" className="w-4 h-4 text-success mt-[3px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
70
70
  <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" />
71
71
  </svg>
72
72
  ) : (
73
- <svg className={`w-4 h-4 text-muted-foreground/50 mt-[3px]${interactive ? ' hover:text-muted-foreground transition-colors' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
73
+ <svg data-list-check="" className={`w-4 h-4 text-muted-foreground/50 mt-[3px]${interactive ? ' hover:text-muted-foreground transition-colors' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
74
74
  <circle cx="12" cy="12" r="9" />
75
75
  </svg>
76
76
  )
@@ -12,6 +12,12 @@ interface LookAndFeelAnnouncementDialogProps {
12
12
  onToggleGrid: (value: boolean) => void;
13
13
  /** Persists the current choice and closes the dialog. */
14
14
  onDismiss: () => void;
15
+ /**
16
+ * `first-run` (default): the first-use choice. `moved`: the one-time notice
17
+ * for someone moved from Grid to Clean when Clean became the default; the
18
+ * same chooser, with a line saying what changed.
19
+ */
20
+ variant?: 'first-run' | 'moved';
15
21
  }
16
22
 
17
23
  const LOOK_OPTIONS: {
@@ -48,6 +54,7 @@ export const LookAndFeelAnnouncementDialog: React.FC<LookAndFeelAnnouncementDial
48
54
  gridEnabled,
49
55
  onToggleGrid,
50
56
  onDismiss,
57
+ variant = 'first-run',
51
58
  }) => {
52
59
  const dialogRef = useRef<HTMLDivElement>(null);
53
60
  const continueRef = useRef<HTMLButtonElement>(null);
@@ -115,6 +122,7 @@ export const LookAndFeelAnnouncementDialog: React.FC<LookAndFeelAnnouncementDial
115
122
  <div
116
123
  ref={dialogRef}
117
124
  role="dialog"
125
+ data-plan-look-prompt={variant}
118
126
  aria-modal="true"
119
127
  aria-labelledby="plan-look-choice-title"
120
128
  aria-describedby="plan-look-choice-description"
@@ -129,13 +137,15 @@ export const LookAndFeelAnnouncementDialog: React.FC<LookAndFeelAnnouncementDial
129
137
  id="plan-look-choice-title"
130
138
  className="text-balance text-xl font-semibold tracking-tight sm:text-2xl"
131
139
  >
132
- Choose how plans look
140
+ {variant === 'moved' ? 'Plans now open in Clean' : 'Choose how plans look'}
133
141
  </h2>
134
142
  <p
135
143
  id="plan-look-choice-description"
136
144
  className="mt-1 text-sm leading-relaxed text-muted-foreground"
137
145
  >
138
- Pick a starting view. You can change it anytime in Settings.
146
+ {variant === 'moved'
147
+ ? "Grid is one switch away in Settings, or pick it here."
148
+ : "Pick a starting view. You can change it anytime in Settings."}
139
149
  </p>
140
150
  </header>
141
151