@plannotator/ui 0.52.1 → 0.53.1

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 (76) hide show
  1. package/HANDOFF.md +284 -29
  2. package/README.md +45 -5
  3. package/components/AnnotationPanel.tsx +195 -118
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +54 -40
  6. package/components/BlockRenderer.tsx +20 -14
  7. package/components/CodeFilePopout.tsx +132 -69
  8. package/components/CommentPopover.tsx +8 -349
  9. package/components/ComposerTokenTextarea.tsx +379 -0
  10. package/components/ExportModal.tsx +6 -1
  11. package/components/HostTitle.tsx +108 -0
  12. package/components/HtmlSurfaceControls.tsx +2 -1
  13. package/components/ImageLightbox.tsx +288 -17
  14. package/components/ImagePeek.tsx +255 -0
  15. package/components/ImageResolverContext.tsx +31 -0
  16. package/components/InlineMarkdown.tsx +123 -15
  17. package/components/LocalLinkContext.tsx +49 -0
  18. package/components/MentionPicker.tsx +51 -0
  19. package/components/MenuVersionSection.tsx +51 -25
  20. package/components/OpenInAppButton.tsx +18 -49
  21. package/components/PlanHeaderMenu.tsx +2 -8
  22. package/components/ProviderIcons.tsx +10 -4
  23. package/components/RenderedMarkdown.tsx +40 -4
  24. package/components/Settings.tsx +33 -5
  25. package/components/SkillReferenceMenu.tsx +67 -18
  26. package/components/Tooltip.tsx +51 -4
  27. package/components/Viewer.tsx +220 -49
  28. package/components/ai/AIProviderBar.tsx +11 -0
  29. package/components/ai/AskSessionSetupHint.tsx +12 -0
  30. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  31. package/components/ai/SessionAskNotice.tsx +4 -3
  32. package/components/blocks/BlockquoteBlock.tsx +159 -0
  33. package/components/blocks/HtmlBlock.tsx +121 -9
  34. package/components/blocks/QuestionBlock.tsx +183 -96
  35. package/components/blocks/QuestionContext.tsx +3 -0
  36. package/components/diagram/DiagramViewer.tsx +41 -4
  37. package/components/diagram/index.ts +1 -1
  38. package/components/html-viewer/HtmlViewer.tsx +95 -3
  39. package/components/html-viewer/bridge-script.asset.js +413 -7
  40. package/components/html-viewer/bridge-script.lite.ts +1 -1
  41. package/components/html-viewer/bridge-script.ts +454 -7
  42. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  43. package/components/icons/AgentIcons.tsx +9 -7
  44. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  45. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  46. package/configure.ts +27 -1
  47. package/hooks/useAgentJobs.ts +8 -3
  48. package/hooks/useAnnotationHighlighter.ts +128 -23
  49. package/hooks/useExternalAnnotations.ts +6 -0
  50. package/hooks/useMentionAutocomplete.ts +1 -1
  51. package/hooks/useOpenInApps.ts +88 -0
  52. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  53. package/hooks/useUpdateCheck.ts +38 -98
  54. package/hooks/useUpdateCheckSetting.ts +43 -0
  55. package/hooks/useUpdateToast.ts +101 -0
  56. package/hooks/useVimSelection.ts +9 -0
  57. package/package.json +2 -2
  58. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  59. package/shortcuts/index.ts +1 -0
  60. package/shortcuts/runtime.ts +10 -3
  61. package/styles.css +1 -1
  62. package/types.ts +48 -0
  63. package/utils/agentClosed.ts +26 -0
  64. package/utils/agentToolSetting.ts +16 -9
  65. package/utils/aiProvider.ts +7 -0
  66. package/utils/askSessionSetupHint.ts +27 -0
  67. package/utils/blockTargeting.ts +14 -3
  68. package/utils/mentions.ts +34 -5
  69. package/utils/parser.ts +65 -51
  70. package/utils/pasteInsert.ts +234 -0
  71. package/utils/planDiffEngine.ts +272 -2
  72. package/utils/skillCatalog.ts +15 -2
  73. package/utils/skillReferences.ts +50 -13
  74. package/utils/storage.ts +5 -0
  75. package/utils/textCut.ts +41 -0
  76. package/utils/updateNotice.ts +120 -0
package/types.ts CHANGED
@@ -386,6 +386,28 @@ export interface CodeAnnotation {
386
386
  * inline on the current diff and never posted as an inline PR comment.
387
387
  */
388
388
  outdated?: boolean;
389
+ /**
390
+ * Code review only: a comment made by selecting text in a Markdown file's
391
+ * rendered preview. Always file-scoped (`scope: 'file'`); `lineStart` /
392
+ * `lineEnd` are the file-comment sentinels, not source lines. Exported with
393
+ * the quoted text; in PR mode it goes into the review body, never inline.
394
+ * Additive: annotations without it behave exactly as before.
395
+ */
396
+ markdownPreview?: MarkdownPreviewAnchor;
397
+ }
398
+
399
+ /** Where a comment made in a rendered Markdown preview points (see `CodeAnnotation.markdownPreview`). */
400
+ export interface MarkdownPreviewAnchor {
401
+ /** The rendered text the reviewer selected. */
402
+ originalText: string;
403
+ /** The preview view it was made in: the rendered diff or the whole new version. */
404
+ view: 'diff' | 'whole';
405
+ /** In the rendered diff: the kind of section the selection started in. */
406
+ diffContext?: 'added' | 'removed' | 'modified' | 'unchanged';
407
+ /** web-highlighter positions, valid only in the view it was made in;
408
+ * other views re-anchor by text. */
409
+ startMeta?: AnnotationTextMeta;
410
+ endMeta?: AnnotationTextMeta;
389
411
  }
390
412
 
391
413
  /** Token-level metadata passed from selection to annotation creation. */
@@ -520,3 +542,29 @@ export type {
520
542
  MentionSource,
521
543
  MentionTrigger,
522
544
  } from './utils/mentions';
545
+
546
+ /**
547
+ * The composer handed to the host (opt-in `onHostDraft` on `Viewer` and
548
+ * `HtmlViewer`; Plannotator supplies neither). With it a viewer draws no
549
+ * toolbar or composer of its own: it reports what a comment would be on, and
550
+ * the host writes the comment, saves it, and draws it as saved through
551
+ * `annotations` or `applySharedAnnotations`.
552
+ */
553
+ export interface HostDraft {
554
+ /** The annotation a comment here would carry, with an empty `text`. */
555
+ annotation: Annotation;
556
+ /**
557
+ * `'selection'`: a text selection settled; the host offers its own Comment
558
+ * (a phone's edit menu). `'compose'`: open the composer now (a pinpoint
559
+ * tap, Comment mode, an HTML pin).
560
+ */
561
+ intent: 'selection' | 'compose';
562
+ /** What it is on, as a composer names it (Plannotator's pinpoint label: "Button", a heading's words). Absent for text. */
563
+ label?: string;
564
+ /**
565
+ * Drop the draft: its pending highlight or pin goes. The host calls it when
566
+ * the person cancels, and after the save, before it draws the saved
567
+ * annotation. A draft already replaced by a newer one ignores it.
568
+ */
569
+ cancel: () => void;
570
+ }
@@ -31,3 +31,29 @@ export function agentClosedSubtitle(unsentAnnotations: number, surface: AgentClo
31
31
  const comments = unsentAnnotations === 1 ? 'Your comment is' : `Your ${unsentAnnotations} comments are`;
32
32
  return `The agent closed this review. ${comments} saved as a draft. ${RESTORE[surface]}`;
33
33
  }
34
+
35
+ /**
36
+ * Copy for a plan review closed because plan mode or the agent session ended
37
+ * (`session-closed` with `by: "session"`, Pi's plan review), not because the
38
+ * agent asked to close it. Neutral on purpose: it also runs on /reload and
39
+ * session switches, after which plan mode may come back.
40
+ */
41
+ export const SESSION_CLOSED_TITLE = 'Review Closed';
42
+
43
+ export function sessionClosedSubtitle(unsentAnnotations: number): string {
44
+ const ended = 'The agent session that opened this plan review ended.';
45
+ if (unsentAnnotations <= 0) return ended;
46
+ const comments = unsentAnnotations === 1 ? 'Your comment is' : `Your ${unsentAnnotations} comments are`;
47
+ return `${ended} ${comments} saved.`;
48
+ }
49
+
50
+ /** Title and subtitle for a closed review, by who closed it. */
51
+ export function closedReviewCopy(
52
+ closed: { unsentAnnotations: number; by?: 'agent' | 'session' },
53
+ surface: AgentClosedSurface = 'document',
54
+ ): { title: string; subtitle: string } {
55
+ if (closed.by === 'session') {
56
+ return { title: SESSION_CLOSED_TITLE, subtitle: sessionClosedSubtitle(closed.unsentAnnotations) };
57
+ }
58
+ return { title: AGENT_CLOSED_TITLE, subtitle: agentClosedSubtitle(closed.unsentAnnotations, surface) };
59
+ }
@@ -4,13 +4,16 @@
4
4
  * the POST /api/config write that changes it.
5
5
  *
6
6
  * Only a Plannotator server that knows the session's tool host sends
7
- * `agentToolHost` (the Claude Code mod, Pi, OpenCode 2). Everything else, an
7
+ * `agentToolHost` (the Claude Code mod, Pi, Oh My Pi, OpenCode 2). Everything else, an
8
8
  * older server, OpenCode 1, Codex, a `@plannotator/ui` host such as
9
9
  * Workspaces, parses to undefined, and no toggle or offer is shown.
10
10
  */
11
11
 
12
- /** The hosts that register the `plannotator` agent tool. */
13
- export type AgentToolHost = 'claude-code' | 'pi' | 'opencode';
12
+ /**
13
+ * The hosts that register the `plannotator` agent tool. `oh-my-pi` is the Pi
14
+ * extension running inside Oh My Pi, where the tool is on by default.
15
+ */
16
+ export type AgentToolHost = 'claude-code' | 'pi' | 'oh-my-pi' | 'opencode';
14
17
 
15
18
  export interface AgentToolSetting {
16
19
  readonly host: AgentToolHost;
@@ -23,7 +26,7 @@ export interface AgentToolSetting {
23
26
  }
24
27
 
25
28
  function isAgentToolHost(value: unknown): value is AgentToolHost {
26
- return value === 'claude-code' || value === 'pi' || value === 'opencode';
29
+ return value === 'claude-code' || value === 'pi' || value === 'oh-my-pi' || value === 'opencode';
27
30
  }
28
31
 
29
32
  /** Reads the agent tool fields out of a `serverConfig` payload, or undefined when the server reports no tool host. */
@@ -79,6 +82,7 @@ export async function saveAgentToolSetting(
79
82
  const HOST_NAMES: Record<AgentToolHost, string> = {
80
83
  'claude-code': 'Claude Code',
81
84
  pi: 'Pi',
85
+ 'oh-my-pi': 'Oh My Pi',
82
86
  opencode: 'OpenCode',
83
87
  };
84
88
 
@@ -90,7 +94,8 @@ export function agentToolHostName(host: AgentToolHost): string {
90
94
  * When a change takes effect on this host: the tool list is part of the
91
95
  * model's prompt, so each host reads the setting once. Pi reads it per
92
96
  * session; the Claude Code mod and the OpenCode plugin decide once per
93
- * process (when the module or plugin loads), so those take a restart.
97
+ * process (when the module or plugin loads), so those take a restart, and so
98
+ * does Oh My Pi, which may keep the extension loaded across its sessions.
94
99
  */
95
100
  export function agentToolAppliesWhen(host: AgentToolHost): string {
96
101
  return host === 'pi' ? 'the next Pi session' : `the next time ${HOST_NAMES[host]} starts`;
@@ -99,10 +104,12 @@ export function agentToolAppliesWhen(host: AgentToolHost): string {
99
104
  /**
100
105
  * What the tool costs in context. Pi and OpenCode send its full definition
101
106
  * with every request; Claude Code defers it behind tool search, so it costs
102
- * about its name until it is used.
107
+ * about its name until it is used; Oh My Pi lists it as one line of its
108
+ * system prompt (about 210 characters, measured on OMP 18.10) and loads the
109
+ * schema only when the model reads it.
103
110
  */
104
111
  export function agentToolCostNote(host: AgentToolHost): string {
105
- return host === 'claude-code'
106
- ? 'Claude Code loads it only when needed.'
107
- : 'Adds about 780 tokens to every request.';
112
+ if (host === 'claude-code') return 'Claude Code loads it only when needed.';
113
+ if (host === 'oh-my-pi') return 'Oh My Pi lists it in one short line (about 50 tokens) and loads the rest only when needed.';
114
+ return 'Adds about 780 tokens to every request.';
108
115
  }
@@ -38,6 +38,13 @@ export interface SessionBridgeInfo {
38
38
  host: string;
39
39
  status: 'ready' | 'busy' | 'blocked' | 'gone';
40
40
  modes: { turn: boolean; transient: boolean };
41
+ /** `false` when the host never interrupts the session (Stop only stops listening). Absent = it can. */
42
+ interruptible?: boolean;
43
+ }
44
+
45
+ /** Whether any listed "Ask this session" provider says it cannot interrupt its session. */
46
+ export function hasNonInterruptibleSession(providers: readonly { sessionBridge?: SessionBridgeInfo }[]): boolean {
47
+ return providers.some((provider) => provider.sessionBridge?.interruptible === false);
41
48
  }
42
49
 
43
50
  /** Error codes "Ask this session" answers with that the panel turns into actions. */
@@ -0,0 +1,27 @@
1
+ import type { Origin } from '@plannotator/core/agents';
2
+ import { isSessionBridgeProvider, type AIProviderOption } from './aiProvider';
3
+
4
+ /**
5
+ * The command that installs the Plannotator extension into Oh My Pi. OMP loads
6
+ * Pi extensions as OMP plugins; this is the one place the UI names it.
7
+ */
8
+ export const OMP_EXTENSION_INSTALL_COMMAND = 'omp plugin install @plannotator/pi-extension';
9
+
10
+ /**
11
+ * A one-line note for Ask AI when the agent that opened Plannotator COULD
12
+ * answer "Ask this session" but this session has no bridge to it (#1814).
13
+ *
14
+ * Only Oh My Pi qualifies today: origin `oh-my-pi` means the CLI ran from
15
+ * OMP's shell (OMP exports `OMPCODE` to its bash children), which never has a
16
+ * bridge, while the Plannotator extension loaded into OMP does. Every other
17
+ * origin without a bridge either has no way to get one or is already covered,
18
+ * so it gets no note. A session that has a bridge never shows it.
19
+ */
20
+ export function askSessionSetupHint(
21
+ origin: Origin | null | undefined,
22
+ providers: ReadonlyArray<Pick<AIProviderOption, 'name' | 'sessionBridge'>>,
23
+ ): string | null {
24
+ if (origin !== 'oh-my-pi') return null;
25
+ if (providers.some(isSessionBridgeProvider)) return null;
26
+ return `To ask your Oh My Pi session instead, install the Plannotator extension (${OMP_EXTENSION_INSTALL_COMMAND}) and open reviews with its /plannotator-* commands.`;
27
+ }
@@ -247,11 +247,22 @@ export function buildSemanticTargetGraph(container: HTMLElement): SemanticTarget
247
247
  continue;
248
248
  }
249
249
 
250
- const codeElement = block.querySelector<HTMLElement>('pre > code.pn-code');
250
+ // A quote is one text block even when it holds a code fence or a table:
251
+ // its fence is not a code-block target (whose click would annotate only
252
+ // the fence), and its table rows are not cells of the quote.
253
+ const isQuote = block.tagName === 'BLOCKQUOTE';
254
+ const codeElement = isQuote ? null : block.querySelector<HTMLElement>('pre > code.pn-code');
255
+ // Inline math in a quote keeps the paragraph rule (the quote targets the
256
+ // formula, as a paragraph with inline math does); a display-math block
257
+ // inside a quote is not the quote.
251
258
  const mathElement = block.matches('.math-annotatable,[data-math-tex]')
252
259
  ? block
253
- : block.querySelector<HTMLElement>('.math-annotatable,[data-math-tex]');
254
- const table = block.querySelector<HTMLTableElement>('table');
260
+ : block.querySelector<HTMLElement>(
261
+ isQuote
262
+ ? '.math-annotatable:not([data-math-display="true"]),[data-math-tex]:not([data-math-display="true"])'
263
+ : '.math-annotatable,[data-math-tex]',
264
+ );
265
+ const table = isQuote ? null : block.querySelector<HTMLTableElement>('table');
255
266
 
256
267
  if (codeElement) {
257
268
  const target: SemanticTarget = {
package/utils/mentions.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  * Nothing here touches the DOM, fetches, or persists. A composer with no
12
12
  * `mentionSource` never calls any of it.
13
13
  */
14
+ import type { ReactNode } from 'react';
14
15
 
15
16
  /** One pickable identity, supplied by the host. */
16
17
  export interface MentionPerson {
@@ -32,6 +33,13 @@ export interface MentionPerson {
32
33
  * supplied `onPickBlocked` (see `MentionSource`).
33
34
  */
34
35
  readonly canOpen: boolean;
36
+ /**
37
+ * Optional group heading: consecutive people with the same
38
+ * `group` are drawn under one heading, as a `role="group"` labelled by it
39
+ * (the Inbox groups agents by kind). Absent on every person → the flat
40
+ * list, exactly as before.
41
+ */
42
+ readonly group?: string;
35
43
  }
36
44
 
37
45
  /** The opt-in `@` mention source for `CommentPopover`'s composer. */
@@ -61,6 +69,24 @@ export interface MentionSource {
61
69
  readonly tokenClassName?: string;
62
70
  /** Fires on every text change with the ids whose token still survives in the body. */
63
71
  readonly onMentionsChange?: (ids: readonly string[]) => void;
72
+ /**
73
+ * Which kinds of people this composer offers. Absent → users only,
74
+ * as before (an agent is not taggable in a host's comments by default). A
75
+ * host whose agents can be tagged (the Inbox) passes `['agent']` or both.
76
+ */
77
+ readonly kinds?: readonly MentionPerson['kind'][];
78
+ /**
79
+ * Whether a query also matches the `detail` line. Absent → true, as
80
+ * before. A host whose detail is incidental (the Inbox's "billing-api ·
81
+ * idle") passes false, so `@p` finds Pi, not every row with a "p" in it.
82
+ */
83
+ readonly matchDetail?: boolean;
84
+ /**
85
+ * Optional line drawn under the composer field, from the people
86
+ * the draft still tags: a host's "Goes to …" and heads-up. Absent → nothing
87
+ * is drawn, as before.
88
+ */
89
+ readonly renderNotice?: (tagged: readonly MentionPerson[]) => ReactNode;
64
90
  /**
65
91
  * A `canOpen: false` person was picked. When supplied, NOTHING is inserted
66
92
  * and the host shows its own no-access affordance. When absent, such a
@@ -115,24 +141,27 @@ export function mentionTrigger(text: string, caret: number): MentionTrigger | nu
115
141
  }
116
142
 
117
143
  /**
118
- * The people this trigger offers: users only (an agent is not taggable in a
119
- * comment), never one already tagged in this draft, de-duplicated by id, and
120
- * filtered on label or detail once a query character exists.
144
+ * The people this trigger offers: users only by default (an agent is not
145
+ * taggable in a comment unless the host says so with `kinds`), never one
146
+ * already tagged in this draft, de-duplicated by id, and filtered on label or
147
+ * detail once a query character exists.
121
148
  */
122
149
  export function mentionMatches(
123
150
  people: readonly MentionPerson[],
124
151
  trigger: MentionTrigger,
125
152
  alreadyTagged: ReadonlySet<string>,
153
+ kinds: readonly MentionPerson['kind'][] = ['user'],
154
+ matchDetail = true,
126
155
  ): readonly MentionPerson[] {
127
156
  const seen = new Set<string>();
128
157
  return people.filter((person) => {
129
- if (person.kind !== 'user') return false;
158
+ if (!kinds.includes(person.kind)) return false;
130
159
  if (alreadyTagged.has(person.id) || seen.has(person.id)) return false;
131
160
  seen.add(person.id);
132
161
  if (trigger.query === '') return true;
133
162
  return (
134
163
  person.label.toLowerCase().includes(trigger.query) ||
135
- (person.detail ?? '').toLowerCase().includes(trigger.query)
164
+ (matchDetail && (person.detail ?? '').toLowerCase().includes(trigger.query))
136
165
  );
137
166
  });
138
167
  }
package/utils/parser.ts CHANGED
@@ -382,10 +382,13 @@ export const parseMarkdownToBlocks = (markdown: string, options?: ParseMarkdownO
382
382
  const prevLineWasBlank = lastLineWasBlank;
383
383
  lastLineWasBlank = false;
384
384
 
385
- // Headings
386
- if (trimmed.startsWith('#')) {
385
+ // Headings: one to six `#` followed by a space or the end of the line
386
+ // (CommonMark ATX). `#123 fixed`, `#hashtag` and `#!/bin/sh` are text,
387
+ // so an issue ref that starts a line keeps its link.
388
+ const headingMatch = trimmed.match(/^(#{1,6})(?:\s|$)/);
389
+ if (headingMatch) {
387
390
  flush();
388
- const level = trimmed.match(/^#+/)?.[0].length || 1;
391
+ const level = headingMatch[1].length;
389
392
  blocks.push({
390
393
  id: `block-${currentId++}`,
391
394
  type: 'heading',
@@ -449,62 +452,63 @@ export const parseMarkdownToBlocks = (markdown: string, options?: ParseMarkdownO
449
452
  continue;
450
453
  }
451
454
 
452
- // Blockquotes — consecutive `>` lines merge into one block so wrapped
453
- // paragraph quotes render as a single continuous quote box. A blank line
454
- // breaks the blockquote so the next `>` starts a fresh one.
455
+ // Blockquotes: a run of consecutive `>` lines (blank `>` lines included)
456
+ // is ONE quote block, the way GitHub renders it: one quote, one bar. A
457
+ // truly blank line ends the run, so the next `>` starts a fresh quote; a
458
+ // line without `>` is not a lazy continuation (it starts a paragraph).
455
459
  //
456
- // Exception: if the stripped content starts with a block-level marker
457
- // (list item, heading, code fence, nested blockquote) we do NOT merge.
458
- // Our flat block model can't render a list-inside-a-quote as an actual
459
- // nested list, so merging would flatten the markers into run-on inline
460
- // text. Leaving them as separate blockquote blocks preserves each line's
461
- // visual identity (a stacked-box layout) — imperfect but legible. A
462
- // proper recursive blockquote parser is tracked as a follow-up.
460
+ // `content` is the quote's own markdown: each line with its `>` marker and
461
+ // one following space or tab removed (deeper indentation is kept, so a
462
+ // fenced code block or a nested list inside the quote keeps its shape) and
463
+ // trailing whitespace trimmed, one content line per source line. Blank
464
+ // lines at the quote's edges are dropped (`startLine` moves past leading
465
+ // ones), so the block's line span is the quote's text. The renderer
466
+ // (`BlockquoteBlock`) parses `content` again with this function and draws
467
+ // the inner blocks (paragraphs, code fences, lists, nested quotes) inside
468
+ // the one quote, while the quote stays a single annotatable block with a
469
+ // single `data-block-id`.
470
+ //
471
+ // GitHub alerts (`> [!KIND]` as the run's first line) are unchanged: the
472
+ // marker line tags the block, and the body is every later line fully
473
+ // `>`-stripped and trimmed, empty leading lines skipped, exactly as the
474
+ // line-by-line merge produced it. AlertBlock renders it.
463
475
  if (trimmed.startsWith('>')) {
464
476
  flush();
465
- const stripped = trimmed.replace(/^>\s*/, '');
466
- // List markers require trailing whitespace to avoid matching inline
467
- // text like "-hyphen" or "1.5 seconds"; headings, code fences, and
468
- // nested blockquote markers don't require it (``` can be followed
469
- // directly by a language tag, # can start a dense heading).
470
- const blockMarkerRe = /^(?:(?:\*|-|\d+\.)\s|#|```|>)/;
471
- const hasBlockMarker = blockMarkerRe.test(stripped);
472
- const prevBlock = blocks.length > 0 ? blocks[blocks.length - 1] : null;
473
- // Don't merge into a previous blockquote whose content itself starts
474
- // with a block marker — otherwise a `> some text` line following a
475
- // `> 1. item` line would get glued onto the list-item block.
476
- const prevIsMarkerQuote =
477
- prevBlock?.type === 'blockquote' && blockMarkerRe.test(prevBlock.content);
478
- // Alerts own their body: once a blockquote is tagged as an alert,
479
- // subsequent `>` lines always merge into it (until a blank line).
480
- // Without this, `> [!NOTE]\n> - item` splits the list item off into
481
- // a separate plain quote, losing the callout.
482
- const prevIsAlert = prevBlock?.type === 'blockquote' && !!prevBlock.alertKind;
483
- const shouldMergeIntoAlert = prevIsAlert && !prevLineWasBlank;
484
- const shouldMergeNormal =
485
- !hasBlockMarker &&
486
- !prevIsMarkerQuote &&
487
- !prevLineWasBlank &&
488
- prevBlock?.type === 'blockquote';
489
- if (shouldMergeIntoAlert || shouldMergeNormal) {
490
- prevBlock!.content = prevBlock!.content
491
- ? prevBlock!.content + '\n' + stripped
492
- : stripped;
493
- } else {
494
- // GitHub alert marker: a blockquote whose first line is [!KIND].
495
- // We strip the marker from content and tag the block; rendering decides the style.
496
- const alertMatch = stripped.match(/^\[!(NOTE|TIP|WARNING|CAUTION|IMPORTANT)\]\s*$/i);
477
+ let runEnd = i;
478
+ while (runEnd + 1 < lines.length && lines[runEnd + 1].trim().startsWith('>')) runEnd++;
479
+ const run = lines.slice(i, runEnd + 1);
480
+ const quoteStartLine = currentLineNum;
481
+ i = runEnd;
482
+ const alertMatch = trimmed
483
+ .replace(/^>\s*/, '')
484
+ .match(/^\[!(NOTE|TIP|WARNING|CAUTION|IMPORTANT)\]\s*$/i);
485
+ if (alertMatch) {
486
+ const body = run.slice(1).reduce((acc, raw) => {
487
+ const stripped = raw.trim().replace(/^>\s*/, '');
488
+ return acc ? acc + '\n' + stripped : stripped;
489
+ }, '');
497
490
  blocks.push({
498
491
  id: `block-${currentId++}`,
499
492
  type: 'blockquote',
500
- content: alertMatch ? '' : stripped,
501
- alertKind: alertMatch
502
- ? (alertMatch[1].toLowerCase() as 'note' | 'tip' | 'warning' | 'caution' | 'important')
503
- : undefined,
493
+ content: body,
494
+ alertKind: alertMatch[1].toLowerCase() as 'note' | 'tip' | 'warning' | 'caution' | 'important',
504
495
  order: currentId,
505
- startLine: currentLineNum
496
+ startLine: quoteStartLine,
506
497
  });
498
+ continue;
507
499
  }
500
+ const inner = run.map((raw) => raw.replace(/^\s*>[ \t]?/, '').trimEnd());
501
+ let first = 0;
502
+ while (first < inner.length - 1 && inner[first] === '') first++;
503
+ let last = inner.length - 1;
504
+ while (last > first && inner[last] === '') last--;
505
+ blocks.push({
506
+ id: `block-${currentId++}`,
507
+ type: 'blockquote',
508
+ content: inner.slice(first, last + 1).join('\n'),
509
+ order: currentId,
510
+ startLine: quoteStartLine + first,
511
+ });
508
512
  continue;
509
513
  }
510
514
 
@@ -1024,7 +1028,17 @@ export const exportAnnotationEntry = (ann: any, opts: ElementContextExportOption
1024
1028
  * a malformed anchor (an older or foreign writer) is skipped, never thrown. */
1025
1029
  const diagramLocationExportLine = (ann: any): string => {
1026
1030
  const anchor = ann?.diagramAnchor === undefined ? null : parseDiagramAnchor(ann.diagramAnchor);
1027
- return anchor === null ? '' : `${safeInline(diagramAnchorLocationLine(anchor), 600)}\n`;
1031
+ if (anchor !== null) return `${safeInline(diagramAnchorLocationLine(anchor), 600)}\n`;
1032
+ return hostLocationExportLine(ann);
1033
+ };
1034
+
1035
+ /** A host's own location line for an entry (the Inbox's "At 1:42 (102.4 s of
1036
+ * 7:41)" on a video comment): `exportLocation`, a string the HOST sets on the
1037
+ * copy it hands the exporter, never stored and never set by Plannotator, so
1038
+ * every other annotation's output is byte-identical. One line, capped. */
1039
+ const hostLocationExportLine = (ann: any): string => {
1040
+ const line = ann?.exportLocation;
1041
+ return typeof line === 'string' && line.trim() !== '' ? `${safeInline(line, 300)}\n` : '';
1028
1042
  };
1029
1043
 
1030
1044
  const lineLabelForAnnotation = (blocks: Block[], ann: any): string | null => {