@plannotator/ui 0.52.1 → 0.53.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 (67) hide show
  1. package/HANDOFF.md +231 -27
  2. package/README.md +29 -4
  3. package/components/AnnotationPanel.tsx +159 -107
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +9 -0
  6. package/components/BlockRenderer.tsx +20 -14
  7. package/components/CodeFilePopout.tsx +131 -68
  8. package/components/CommentPopover.tsx +6 -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/ImageLightbox.tsx +288 -17
  13. package/components/ImagePeek.tsx +255 -0
  14. package/components/ImageResolverContext.tsx +31 -0
  15. package/components/InlineMarkdown.tsx +123 -15
  16. package/components/LocalLinkContext.tsx +49 -0
  17. package/components/MenuVersionSection.tsx +51 -25
  18. package/components/OpenInAppButton.tsx +18 -49
  19. package/components/PlanHeaderMenu.tsx +2 -8
  20. package/components/ProviderIcons.tsx +10 -4
  21. package/components/RenderedMarkdown.tsx +40 -4
  22. package/components/Settings.tsx +33 -5
  23. package/components/SkillReferenceMenu.tsx +28 -6
  24. package/components/Tooltip.tsx +51 -4
  25. package/components/Viewer.tsx +189 -48
  26. package/components/ai/AIProviderBar.tsx +11 -0
  27. package/components/ai/AskSessionSetupHint.tsx +12 -0
  28. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  29. package/components/ai/SessionAskNotice.tsx +4 -3
  30. package/components/blocks/BlockquoteBlock.tsx +159 -0
  31. package/components/blocks/HtmlBlock.tsx +121 -9
  32. package/components/blocks/QuestionBlock.tsx +183 -96
  33. package/components/diagram/DiagramViewer.tsx +41 -4
  34. package/components/diagram/index.ts +1 -1
  35. package/components/html-viewer/HtmlViewer.tsx +73 -3
  36. package/components/html-viewer/bridge-script.asset.js +86 -0
  37. package/components/html-viewer/bridge-script.ts +86 -0
  38. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  39. package/components/icons/AgentIcons.tsx +9 -7
  40. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  41. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  42. package/configure.ts +27 -1
  43. package/hooks/useAgentJobs.ts +8 -3
  44. package/hooks/useAnnotationHighlighter.ts +128 -23
  45. package/hooks/useExternalAnnotations.ts +6 -0
  46. package/hooks/useOpenInApps.ts +88 -0
  47. package/hooks/useUpdateCheck.ts +38 -98
  48. package/hooks/useUpdateCheckSetting.ts +43 -0
  49. package/hooks/useUpdateToast.ts +101 -0
  50. package/package.json +2 -2
  51. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  52. package/shortcuts/index.ts +1 -0
  53. package/styles.css +1 -1
  54. package/types.ts +48 -0
  55. package/utils/agentClosed.ts +26 -0
  56. package/utils/agentToolSetting.ts +16 -9
  57. package/utils/aiProvider.ts +7 -0
  58. package/utils/askSessionSetupHint.ts +27 -0
  59. package/utils/blockTargeting.ts +14 -3
  60. package/utils/parser.ts +65 -51
  61. package/utils/pasteInsert.ts +234 -0
  62. package/utils/planDiffEngine.ts +272 -2
  63. package/utils/skillCatalog.ts +15 -2
  64. package/utils/skillReferences.ts +50 -13
  65. package/utils/storage.ts +5 -0
  66. package/utils/textCut.ts +41 -0
  67. package/utils/updateNotice.ts +120 -0
@@ -43,6 +43,17 @@ export interface PlanDiffBlock {
43
43
  inlineTokens?: InlineDiffToken[];
44
44
  /** Structural wrap metadata paired with inlineTokens. */
45
45
  inlineWrap?: InlineDiffWrap;
46
+ /**
47
+ * Source line range of this block in the OLD text: 1-based, inclusive,
48
+ * trimmed of leading and trailing blank lines. Absent when the block has
49
+ * no non-blank old line (added blocks, or an all-blank chunk). Filled by
50
+ * `computePlanDiff`; plan review ignores it.
51
+ */
52
+ oldStart?: number;
53
+ oldEnd?: number;
54
+ /** Same as `oldStart`/`oldEnd`, in the NEW text (absent on removed blocks). */
55
+ newStart?: number;
56
+ newEnd?: number;
46
57
  }
47
58
 
48
59
  export interface PlanDiffStats {
@@ -63,6 +74,35 @@ function countLines(text: string): number {
63
74
  return lines.length;
64
75
  }
65
76
 
77
+ /**
78
+ * The trimmed line range a chunk occupies when its first line is `start`
79
+ * (1-based): leading and trailing blank lines are not counted. Empty when the
80
+ * chunk has no non-blank line.
81
+ */
82
+ function trimmedLineRange(
83
+ value: string,
84
+ start: number
85
+ ): { start?: number; end?: number } {
86
+ const lines = value.split("\n");
87
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
88
+ let first = 0;
89
+ while (first < lines.length && lines[first].trim() === "") first++;
90
+ if (first >= lines.length) return {};
91
+ let last = lines.length - 1;
92
+ while (last > first && lines[last].trim() === "") last--;
93
+ return { start: start + first, end: start + last };
94
+ }
95
+
96
+ function oldRange(value: string, start: number): Pick<PlanDiffBlock, "oldStart" | "oldEnd"> {
97
+ const r = trimmedLineRange(value, start);
98
+ return r.start === undefined ? {} : { oldStart: r.start, oldEnd: r.end };
99
+ }
100
+
101
+ function newRange(value: string, start: number): Pick<PlanDiffBlock, "newStart" | "newEnd"> {
102
+ const r = trimmedLineRange(value, start);
103
+ return r.start === undefined ? {} : { newStart: r.start, newEnd: r.end };
104
+ }
105
+
66
106
  const INLINE_DIFFABLE_TYPES = new Set<Block["type"]>([
67
107
  "paragraph",
68
108
  "heading",
@@ -213,6 +253,34 @@ function substituteFencedBlocks(
213
253
  });
214
254
  }
215
255
 
256
+ // A quote (a run of `>` lines) that holds a code fence is one block in the
257
+ // rendered plan, and each diff chunk renders as its own quote. Diffed line
258
+ // by line, an edit inside the quoted fence split it into chunks holding half
259
+ // a fence each, so one chunk opened a fence it never closed and rendered the
260
+ // prose after it as code. The whole run is therefore one atomic unit, like
261
+ // an unquoted fence. Quotes without a fence keep line-level diffing. Runs
262
+ // after substituteFencedBlocks, so a `>` line inside an unquoted fence is
263
+ // already behind its sentinel and never starts a run.
264
+ const QUOTE_RUN_PATTERN = /^[ \t]*>[^\n]*(?:\n[ \t]*>[^\n]*)*/gm;
265
+ const QUOTED_FENCE_LINE = /^[ \t]*(?:>[ \t]?)+[ \t]*`{3,}/m;
266
+
267
+ function substituteQuotedFenceRuns(
268
+ text: string,
269
+ fenceMap: Map<string, string>,
270
+ fenceToId: Map<string, number>
271
+ ): string {
272
+ return text.replace(QUOTE_RUN_PATTERN, (match) => {
273
+ if (!QUOTED_FENCE_LINE.test(match)) return match;
274
+ let id = fenceToId.get(match);
275
+ if (id === undefined) {
276
+ id = fenceToId.size;
277
+ fenceMap.set(fenceSentinelFor(id), match);
278
+ fenceToId.set(match, id);
279
+ }
280
+ return fenceSentinelFor(id);
281
+ });
282
+ }
283
+
216
284
  function restoreFencedBlocks(
217
285
  value: string,
218
286
  fenceMap: Map<string, string>
@@ -530,8 +598,16 @@ export function computePlanDiff(
530
598
  // FENCE_BLOCK_PATTERN for the failure this prevents.
531
599
  const fenceMap = new Map<string, string>();
532
600
  const fenceToId = new Map<string, number>();
533
- const substOld = substituteFencedBlocks(oldText, fenceMap, fenceToId);
534
- const substNew = substituteFencedBlocks(newText, fenceMap, fenceToId);
601
+ const substOld = substituteQuotedFenceRuns(
602
+ substituteFencedBlocks(oldText, fenceMap, fenceToId),
603
+ fenceMap,
604
+ fenceToId,
605
+ );
606
+ const substNew = substituteQuotedFenceRuns(
607
+ substituteFencedBlocks(newText, fenceMap, fenceToId),
608
+ fenceMap,
609
+ fenceToId,
610
+ );
535
611
 
536
612
  const rawChanges: Change[] = diffLines(substOld, substNew);
537
613
  // Restore the fenced-block content on each change value before the
@@ -544,6 +620,10 @@ export function computePlanDiff(
544
620
 
545
621
  const blocks: PlanDiffBlock[] = [];
546
622
  const stats: PlanDiffStats = { additions: 0, deletions: 0, modifications: 0 };
623
+ // First line (1-based) of the next chunk on each side; the restored chunk
624
+ // values hold the real source lines, so counting them keeps both in step.
625
+ let oldLine = 1;
626
+ let newLine = 1;
547
627
 
548
628
  for (let i = 0; i < changes.length; i++) {
549
629
  const change = changes[i];
@@ -558,7 +638,11 @@ export function computePlanDiff(
558
638
  oldContent: change.value,
559
639
  lines: countLines(next.value),
560
640
  ...(inline ? { inlineTokens: inline.tokens, inlineWrap: inline.wrap } : {}),
641
+ ...oldRange(change.value, oldLine),
642
+ ...newRange(next.value, newLine),
561
643
  });
644
+ oldLine += countLines(change.value);
645
+ newLine += countLines(next.value);
562
646
  stats.modifications++;
563
647
  stats.additions += countLines(next.value);
564
648
  stats.deletions += countLines(change.value);
@@ -568,23 +652,209 @@ export function computePlanDiff(
568
652
  type: "added",
569
653
  content: change.value,
570
654
  lines: countLines(change.value),
655
+ ...newRange(change.value, newLine),
571
656
  });
657
+ newLine += countLines(change.value);
572
658
  stats.additions += countLines(change.value);
573
659
  } else if (change.removed) {
574
660
  blocks.push({
575
661
  type: "removed",
576
662
  content: change.value,
577
663
  lines: countLines(change.value),
664
+ ...oldRange(change.value, oldLine),
578
665
  });
666
+ oldLine += countLines(change.value);
579
667
  stats.deletions += countLines(change.value);
580
668
  } else {
581
669
  blocks.push({
582
670
  type: "unchanged",
583
671
  content: change.value,
584
672
  lines: countLines(change.value),
673
+ ...oldRange(change.value, oldLine),
674
+ ...newRange(change.value, newLine),
585
675
  });
676
+ oldLine += countLines(change.value);
677
+ newLine += countLines(change.value);
586
678
  }
587
679
  }
588
680
 
589
681
  return { blocks, stats };
590
682
  }
683
+
684
+ // --- Folding unchanged runs (code review's rendered Markdown diff) ---------
685
+
686
+ /** A run of unchanged sections hidden behind one row. */
687
+ export interface PlanDiffFold {
688
+ kind: "fold";
689
+ /** Stable for the same diff: `fold-<block index>-<first hidden piece>`. */
690
+ key: string;
691
+ /** The hidden sections, each an 'unchanged' block with its own ranges. */
692
+ pieces: PlanDiffBlock[];
693
+ /** How many sections are hidden (`pieces.length`). */
694
+ sectionCount: number;
695
+ /** Plain text of the hidden headings, at most three. */
696
+ headings: string[];
697
+ oldStart?: number;
698
+ oldEnd?: number;
699
+ newStart?: number;
700
+ newEnd?: number;
701
+ }
702
+
703
+ export type PlanDiffSegment =
704
+ | { kind: "block"; block: PlanDiffBlock; index: number }
705
+ | PlanDiffFold;
706
+
707
+ const MAX_FOLD_HEADINGS = 3;
708
+
709
+ /** Heading text without inline markdown markers. */
710
+ function plainHeadingText(text: string): string {
711
+ return text
712
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
713
+ .replace(/[*_`~]/g, "")
714
+ .replace(/\s+/g, " ")
715
+ .trim();
716
+ }
717
+
718
+ interface UnchangedPiece {
719
+ piece: PlanDiffBlock;
720
+ heading?: string;
721
+ }
722
+
723
+ /**
724
+ * Split an unchanged chunk into one piece per markdown block: each piece is
725
+ * the exact source text from its block's first line up to the next block's
726
+ * (blank lines stay with the piece before them; anything before the first
727
+ * block, such as frontmatter, stays with the first piece). Concatenating the
728
+ * pieces gives the chunk back byte for byte.
729
+ */
730
+ function splitUnchangedChunk(block: PlanDiffBlock): UnchangedPiece[] {
731
+ const content = block.content;
732
+ const parsed = parseMarkdownToBlocks(content);
733
+ if (parsed.length <= 1) {
734
+ const only = parsed[0];
735
+ return [only?.type === "heading" ? { piece: block, heading: plainHeadingText(only.content) } : { piece: block }];
736
+ }
737
+
738
+ // Character offset of the start of each 1-based line.
739
+ const lineOffsets: number[] = [0];
740
+ for (let i = 0; i < content.length; i++) {
741
+ if (content[i] === "\n") lineOffsets.push(i + 1);
742
+ }
743
+ const lineCount = countLines(content);
744
+ const starts = Array.from(
745
+ new Set(parsed.map((b) => b.startLine).filter((line) => line >= 1 && line <= lineCount))
746
+ ).sort((a, b) => a - b);
747
+ if (starts.length === 0) return [{ piece: block }];
748
+ starts[0] = 1;
749
+
750
+ // The chunk's untrimmed first line on each side (its ranges are trimmed).
751
+ const contentLines = content.split("\n");
752
+ let leadingBlank = 0;
753
+ while (leadingBlank < contentLines.length && contentLines[leadingBlank].trim() === "") leadingBlank++;
754
+ const oldBase = block.oldStart !== undefined ? block.oldStart - leadingBlank : undefined;
755
+ const newBase = block.newStart !== undefined ? block.newStart - leadingBlank : undefined;
756
+
757
+ const headingByStart = new Map<number, string>();
758
+ for (const b of parsed) {
759
+ if (b.type === "heading" && !headingByStart.has(b.startLine)) {
760
+ headingByStart.set(b.startLine, plainHeadingText(b.content));
761
+ }
762
+ }
763
+
764
+ return starts.map((startLine, i) => {
765
+ const nextStart = starts[i + 1];
766
+ const from = lineOffsets[startLine - 1] ?? content.length;
767
+ const to = nextStart !== undefined ? lineOffsets[nextStart - 1] ?? content.length : content.length;
768
+ const pieceContent = content.slice(from, to);
769
+ const piece: PlanDiffBlock = {
770
+ type: "unchanged",
771
+ content: pieceContent,
772
+ lines: countLines(pieceContent),
773
+ ...(oldBase !== undefined ? oldRange(pieceContent, oldBase + startLine - 1) : {}),
774
+ ...(newBase !== undefined ? newRange(pieceContent, newBase + startLine - 1) : {}),
775
+ };
776
+ // A heading piece begins at the heading itself (i > 0), or the first
777
+ // piece's parsed block is the heading.
778
+ const heading = headingByStart.get(i === 0 ? parsed[0].startLine : startLine);
779
+ return heading !== undefined ? { piece, heading } : { piece };
780
+ });
781
+ }
782
+
783
+ /** The span of several pieces' ranges on each side. */
784
+ function spanOf(pieces: PlanDiffBlock[]): Pick<PlanDiffBlock, "oldStart" | "oldEnd" | "newStart" | "newEnd"> {
785
+ const withOld = pieces.filter((p) => p.oldStart !== undefined);
786
+ const withNew = pieces.filter((p) => p.newStart !== undefined);
787
+ return {
788
+ ...(withOld.length > 0
789
+ ? { oldStart: withOld[0].oldStart, oldEnd: withOld[withOld.length - 1].oldEnd }
790
+ : {}),
791
+ ...(withNew.length > 0
792
+ ? { newStart: withNew[0].newStart, newEnd: withNew[withNew.length - 1].newEnd }
793
+ : {}),
794
+ };
795
+ }
796
+
797
+ /** Consecutive pieces as one unchanged block (kept together so list numbering stays continuous). */
798
+ function joinPieces(pieces: PlanDiffBlock[]): PlanDiffBlock {
799
+ if (pieces.length === 1) return pieces[0];
800
+ const content = pieces.map((p) => p.content).join("");
801
+ return { type: "unchanged", content, lines: countLines(content), ...spanOf(pieces) };
802
+ }
803
+
804
+ /**
805
+ * Fold long runs of unchanged sections behind one row, the way a code diff
806
+ * folds unmodified lines. Changed blocks pass through. Each unchanged block
807
+ * keeps `context` sections next to each neighbouring change visible (none
808
+ * toward the start or end of the document) and folds the rest when at least
809
+ * `minHidden` would be hidden; otherwise it is shown whole, untouched.
810
+ *
811
+ * Pure: the same blocks always give the same segments and fold keys.
812
+ */
813
+ export function foldUnchangedPlanDiff(
814
+ blocks: PlanDiffBlock[],
815
+ options: { context?: number; minHidden?: number } = {}
816
+ ): PlanDiffSegment[] {
817
+ const context = Math.max(0, options.context ?? 1);
818
+ const minHidden = Math.max(1, options.minHidden ?? 2);
819
+ const segments: PlanDiffSegment[] = [];
820
+
821
+ blocks.forEach((block, index) => {
822
+ if (block.type !== "unchanged") {
823
+ segments.push({ kind: "block", block, index });
824
+ return;
825
+ }
826
+ const pieces = splitUnchangedChunk(block);
827
+ const head = index > 0 ? Math.min(context, pieces.length) : 0;
828
+ const tail = index < blocks.length - 1 ? Math.min(context, pieces.length - head) : 0;
829
+ const hidden = pieces.length - head - tail;
830
+ if (hidden < minHidden) {
831
+ segments.push({ kind: "block", block, index });
832
+ return;
833
+ }
834
+ if (head > 0) {
835
+ segments.push({ kind: "block", block: joinPieces(pieces.slice(0, head).map((p) => p.piece)), index });
836
+ }
837
+ const folded = pieces.slice(head, head + hidden);
838
+ const foldPieces = folded.map((p) => p.piece);
839
+ segments.push({
840
+ kind: "fold",
841
+ key: `fold-${index}-${head}`,
842
+ pieces: foldPieces,
843
+ sectionCount: foldPieces.length,
844
+ headings: folded
845
+ .map((p) => p.heading)
846
+ .filter((h): h is string => !!h)
847
+ .slice(0, MAX_FOLD_HEADINGS),
848
+ ...spanOf(foldPieces),
849
+ });
850
+ if (tail > 0) {
851
+ segments.push({
852
+ kind: "block",
853
+ block: joinPieces(pieces.slice(head + hidden).map((p) => p.piece)),
854
+ index,
855
+ });
856
+ }
857
+ });
858
+
859
+ return segments;
860
+ }
@@ -81,6 +81,18 @@ async function requestCatalog(): Promise<SkillCatalogEntry[]> {
81
81
  }
82
82
  }
83
83
 
84
+ // Whether a fetched catalog is registered for the per-comment feedback export
85
+ // (skillReferenceExportBlock). Default on: Plannotator's exporters list each
86
+ // comment's references. A host that composes ONE block per message itself
87
+ // (the Plannotator Inbox) turns it off, so a comment's `$name` stays plain
88
+ // text in its export instead of carrying a second "Skills referenced" list.
89
+ let exportRegistration = true;
90
+
91
+ export function setSkillCatalogExportRegistration(on: boolean): void {
92
+ exportRegistration = on;
93
+ if (!on) setSkillCatalogForExport([]);
94
+ }
95
+
84
96
  let cached: { at: number; skills: SkillCatalogEntry[] } | null = null;
85
97
  let inflight: Promise<SkillCatalogEntry[]> | null = null;
86
98
  // Bumped by resetSkillCatalogCache. A request that resolves after a reset (or
@@ -111,7 +123,7 @@ export function fetchSkillCatalog(): Promise<SkillCatalogEntry[]> {
111
123
  } else {
112
124
  cached = { at: Date.now(), skills: cached.skills };
113
125
  }
114
- setSkillCatalogForExport(cached.skills);
126
+ if (exportRegistration) setSkillCatalogForExport(cached.skills);
115
127
  return cached.skills;
116
128
  });
117
129
  inflight = promise;
@@ -176,7 +188,8 @@ export function resetSkillContentTransport(): void {
176
188
  contentTransport = defaultContentTransport;
177
189
  }
178
190
 
179
- function normalizeSkillContent(raw: unknown): SkillExportContent | null {
191
+ /** A raw `{ content, truncated, dir, path }` record as an export content, or null when any part is missing. */
192
+ export function normalizeSkillContent(raw: unknown): SkillExportContent | null {
180
193
  if (!raw || typeof raw !== 'object') return null;
181
194
  const { content, truncated, dir, path } = raw as Record<string, unknown>;
182
195
  if (typeof content !== 'string' || !content) return null;
@@ -14,6 +14,13 @@
14
14
  * pre-feature behavior byte-for-byte.
15
15
  */
16
16
 
17
+ import {
18
+ REVIEWER_SKILL_AUDIENCE,
19
+ skillInstructionsIncludedNote,
20
+ skillsReferencedHeading,
21
+ type SkillBlockAudience,
22
+ } from '@plannotator/core/skill-reference-block';
23
+
17
24
  export type SkillRootId = 'claude' | 'codex' | 'universal';
18
25
 
19
26
  export interface SkillCatalogEntry {
@@ -331,8 +338,8 @@ export function resetSkillContentsForExport(): void {
331
338
  exportContents.clear();
332
339
  }
333
340
 
334
- const HUMAN_ONLY_EXPORT_NOTE =
335
- 'human-invocation-only: you cannot invoke this skill; the reviewer included it as context';
341
+ const humanOnlyExportNote = (audience: SkillBlockAudience): string =>
342
+ `human-invocation-only: you cannot invoke this skill; the ${audience.requester} included it as context`;
336
343
 
337
344
  /**
338
345
  * The structural forms the injection block itself uses: the BEGIN/END markers
@@ -392,9 +399,9 @@ export function neutralizeSkillMarkerLines(body: string): string {
392
399
  * and labeled so it can never read as the reviewer's own words. Body lines
393
400
  * that imitate the markers are neutralized (see neutralizeSkillMarkerLines)
394
401
  * so the body cannot close the block early or forge a sibling block. */
395
- function injectedSkillSection(name: string, body: SkillExportContent): string {
402
+ function injectedSkillSection(name: string, body: SkillExportContent, audience: SkillBlockAudience): string {
396
403
  let out = `--- BEGIN SKILL INSTRUCTIONS: ${name} ---\n`;
397
- out += `This skill cannot be invoked by a model, so its instructions are included here at the reviewer's request. Follow them when acting on this feedback.\n`;
404
+ out += `This skill cannot be invoked by a model, so its instructions are included here at the ${audience.requester}'s request. Follow them when acting on this ${audience.subject}.\n`;
398
405
  out += `Skill directory: ${body.dir}\n`;
399
406
  out += `Resolve any relative paths in the instructions below (e.g. references/, scripts/, assets/) against that absolute directory; your working directory is not the skill directory.\n\n`;
400
407
  out += `${neutralizeSkillMarkerLines(body.content)}\n`;
@@ -438,38 +445,68 @@ export function skillReferenceExportBlock(
438
445
  ): string {
439
446
  if (!text) return '';
440
447
  const refs = extractSkillReferences(text, exportCatalog);
441
- if (refs.length === 0) return '';
448
+ return renderSkillReferencesBlock(refs, {
449
+ contentOf: (name) => exportContents.get(name),
450
+ injectedNames,
451
+ external: options?.external,
452
+ });
453
+ }
454
+
455
+ /** What `renderSkillReferencesBlock` needs besides the references themselves. */
456
+ export interface SkillReferencesBlockOptions {
457
+ /** The SKILL.md body of a human-only skill, when it is to be injected; undefined falls back to the name + directory line. */
458
+ contentOf?: (name: string) => SkillExportContent | undefined;
459
+ /** Per-export dedupe (see skillReferenceExportBlock). */
460
+ injectedNames?: Set<string>;
461
+ /** A comment that did not come from the person in this UI: never injects (see skillReferenceExportBlock). */
462
+ external?: boolean;
463
+ /** Who is asking, about what. Default: the reviewer, about this feedback (Plannotator's export). */
464
+ audience?: SkillBlockAudience;
465
+ }
442
466
 
443
- const external = options?.external === true;
467
+ /**
468
+ * The block itself, for references a caller already resolved: the export
469
+ * above per comment, and a host that composes ONE block per message (the
470
+ * Plannotator Inbox, with `INBOX_REPLY_SKILL_AUDIENCE`). '' when `refs` is
471
+ * empty. Same rules and markers as skillReferenceExportBlock.
472
+ */
473
+ export function renderSkillReferencesBlock(
474
+ refs: readonly SkillCatalogEntry[],
475
+ options: SkillReferencesBlockOptions = {},
476
+ ): string {
477
+ if (refs.length === 0) return '';
478
+ const { injectedNames } = options;
479
+ const audience = options.audience ?? REVIEWER_SKILL_AUDIENCE;
480
+ const external = options.external === true;
444
481
  const toInject: Array<{ name: string; body: SkillExportContent }> = [];
445
- let out = `**Skills referenced** (the reviewer is asking you to invoke these skills when acting on this feedback):\n`;
482
+ let out = `${skillsReferencedHeading(audience)}\n`;
446
483
  for (const ref of refs) {
447
484
  if (!ref.humanOnly) {
448
485
  out += `- \`${ref.name}\`\n`;
449
486
  continue;
450
487
  }
451
- const body = exportContents.get(ref.name);
488
+ const body = options.contentOf?.(ref.name);
452
489
  if (body && injectedNames?.has(ref.name)) {
453
490
  // Already injected earlier in this export (necessarily by a reviewer-
454
491
  // written comment) — true regardless of who references it now.
455
- out += `- \`${ref.name}\` (cannot be invoked by a model; its instructions are included earlier in this feedback)\n`;
492
+ out += `- \`${ref.name}\` (cannot be invoked by a model; its instructions are included earlier in this ${audience.subject})\n`;
456
493
  } else if (external) {
457
494
  // Tool-submitted comment: name + directory, never verbatim injection.
458
495
  out += ref.dir
459
496
  ? `- \`${ref.name}\` (cannot be invoked by a model; this comment came from an external tool, so its instructions are not included — SKILL.md is in ${ref.dir})\n`
460
- : `- \`${ref.name}\` (${HUMAN_ONLY_EXPORT_NOTE})\n`;
497
+ : `- \`${ref.name}\` (${humanOnlyExportNote(audience)})\n`;
461
498
  } else if (body) {
462
499
  injectedNames?.add(ref.name);
463
500
  toInject.push({ name: ref.name, body });
464
- out += `- \`${ref.name}\` (cannot be invoked by a model; its instructions are included below at the reviewer's request)\n`;
501
+ out += `- \`${ref.name}\` (cannot be invoked by a model; ${skillInstructionsIncludedNote(audience)})\n`;
465
502
  } else if (ref.dir) {
466
503
  out += `- \`${ref.name}\` (cannot be invoked by a model; its instructions could not be included, read SKILL.md in ${ref.dir} and follow it)\n`;
467
504
  } else {
468
- out += `- \`${ref.name}\` (${HUMAN_ONLY_EXPORT_NOTE})\n`;
505
+ out += `- \`${ref.name}\` (${humanOnlyExportNote(audience)})\n`;
469
506
  }
470
507
  }
471
508
  for (const { name, body } of toInject) {
472
- out += `\n${injectedSkillSection(name, body)}`;
509
+ out += `\n${injectedSkillSection(name, body, audience)}`;
473
510
  }
474
511
  return out;
475
512
  }
package/utils/storage.ts CHANGED
@@ -127,6 +127,11 @@ export function getLastOpenInApp(): string {
127
127
  return getItem(OPEN_IN_APP_KEY) ?? 'reveal';
128
128
  }
129
129
 
130
+ /** The stored "Open in app" id, or null when nothing was ever picked (no 'reveal' default). */
131
+ export function getStoredOpenInApp(): string | null {
132
+ return getItem(OPEN_IN_APP_KEY);
133
+ }
134
+
130
135
  export function setLastOpenInApp(id: string): void {
131
136
  setItem(OPEN_IN_APP_KEY, id);
132
137
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Is this element's text cut (its own overflow with "…", or a line clamp)?
3
+ * With `partSelector`, it also counts as cut when its first descendant
4
+ * matching it is (a name drawn in two parts, whose first part shrinks).
5
+ * Rounding can hide a cut of under a pixel (a chip
6
+ * measured a scrollWidth of 104 in a clientWidth of 103 with its ellipsis
7
+ * showing, and another 98 in 98 with the text 0.06 px wider than its box),
8
+ * so any whole pixel counts, and where layout can be read the text's own
9
+ * width (a Range over its contents) is compared with the box's content width.
10
+ */
11
+ export function isTextCut(el: Element | null | undefined, partSelector?: string): boolean {
12
+ if (typeof HTMLElement === 'undefined' || !(el instanceof HTMLElement)) return false;
13
+ const part = partSelector ? el.querySelector<HTMLElement>(partSelector) : null;
14
+ if (part && overflows(part)) return true;
15
+ return overflows(el);
16
+ }
17
+
18
+ function overflows(el: HTMLElement): boolean {
19
+ if (el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight + 1) return true;
20
+ if (el.clientWidth === 0 || typeof document.createRange !== 'function') return false;
21
+ const range = document.createRange();
22
+ range.selectNodeContents(el);
23
+ const text = range.getBoundingClientRect?.().width ?? 0;
24
+ if (!(text > 0)) return false;
25
+ const style = getComputedStyle(el);
26
+ if (style.overflowX === 'visible') return false;
27
+ const box = el.getBoundingClientRect().width;
28
+ const inner = box - num(style.paddingLeft) - num(style.paddingRight) - num(style.borderLeftWidth) - num(style.borderRightWidth);
29
+ // Chrome draws the ellipsis for any overflow (a chip lost 0.06 px): a hundredth of a pixel counts.
30
+ return text > inner + 0.01;
31
+ }
32
+
33
+ function num(value: string): number {
34
+ const n = Number.parseFloat(value);
35
+ return Number.isFinite(n) ? n : 0;
36
+ }
37
+
38
+ /** No hover on this device (a touch screen): a hover there is a first tap, so a tooltip should not open. */
39
+ export function noHover(): boolean {
40
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(hover: none)').matches;
41
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Update notices in plan review, annotate and code review (design:
3
+ * .product/approved/plannotator-updates/ §4.5, owner's answers 1 and 3).
4
+ *
5
+ * The server says whether a newer release exists (`updateAvailable` on
6
+ * /api/plan and /api/diff, from the machine's once-a-day check): the browser
7
+ * never asks GitHub. This module parses that advert, decides whether the toast
8
+ * may show now (once per version, and at most once every three days across
9
+ * versions, remembered in a cookie that every localhost port shares), and
10
+ * words the toast for the host that launched the session.
11
+ */
12
+
13
+ import { getItem, setItem } from './storage';
14
+ import { noteServerSessionMismatch, withServerSession } from './serverSession';
15
+
16
+ export type UpdateAction = 'script' | 'copy' | 'pi' | 'opencode';
17
+
18
+ export interface UpdateAdvert {
19
+ version: string;
20
+ current: string;
21
+ releaseUrl: string;
22
+ action: UpdateAction;
23
+ /** What a person runs by hand, with the remembered install flags (`pi update --extensions` for Pi). */
24
+ command: string;
25
+ publishedAt?: string;
26
+ }
27
+
28
+ /** The toast shows at most this often, across versions. */
29
+ export const UPDATE_TOAST_INTERVAL_MS = 3 * 24 * 60 * 60 * 1000;
30
+
31
+ const TOAST_KEY = 'plannotator-update-toast';
32
+ const VERSION = /^\d{1,9}\.\d{1,9}\.\d{1,9}$/;
33
+ const ACTIONS: readonly UpdateAction[] = ['script', 'copy', 'pi', 'opencode'];
34
+
35
+ /** The server's advert, validated (anything malformed is no advert). */
36
+ export function parseUpdateAdvert(value: unknown): UpdateAdvert | null {
37
+ if (!value || typeof value !== 'object') return null;
38
+ const v = value as Record<string, unknown>;
39
+ if (typeof v.version !== 'string' || !VERSION.test(v.version)) return null;
40
+ if (typeof v.current !== 'string' || !VERSION.test(v.current)) return null;
41
+ if (typeof v.releaseUrl !== 'string' || !/^https:\/\/github\.com\//.test(v.releaseUrl)) return null;
42
+ if (typeof v.action !== 'string' || !ACTIONS.includes(v.action as UpdateAction)) return null;
43
+ if (typeof v.command !== 'string' || !v.command || v.command.length > 500) return null;
44
+ return {
45
+ version: v.version,
46
+ current: v.current,
47
+ releaseUrl: v.releaseUrl,
48
+ action: v.action as UpdateAction,
49
+ command: v.command,
50
+ ...(typeof v.publishedAt === 'string' && { publishedAt: v.publishedAt }),
51
+ };
52
+ }
53
+
54
+ function readToastRecord(): { version: string; at: number } | null {
55
+ const raw = getItem(TOAST_KEY);
56
+ if (!raw) return null;
57
+ const at = raw.lastIndexOf('@');
58
+ if (at <= 0) return null;
59
+ const time = Number(raw.slice(at + 1));
60
+ return Number.isFinite(time) ? { version: raw.slice(0, at), at: time } : null;
61
+ }
62
+
63
+ /** Whether the toast for `version` may show now (pure, for tests; `claimUpdateToast` records it). */
64
+ export function updateToastDue(version: string, now: number, record = readToastRecord()): boolean {
65
+ if (!record) return true;
66
+ if (record.version === version) return false;
67
+ // A clock that moved backwards does not hold the toast back for good.
68
+ if (record.at > now) return true;
69
+ return now - record.at >= UPDATE_TOAST_INTERVAL_MS;
70
+ }
71
+
72
+ /** True the first time the toast may show for `version`; marks it shown. */
73
+ export function claimUpdateToast(version: string, now: number): boolean {
74
+ if (!updateToastDue(version, now)) return false;
75
+ setItem(TOAST_KEY, `${version}@${now}`);
76
+ return true;
77
+ }
78
+
79
+ /** The toast's words and its primary action, per host. */
80
+ export function describeUpdateToast(advert: UpdateAdvert): { title: string; description: string; primary: 'update' | 'copy' } {
81
+ switch (advert.action) {
82
+ case 'script':
83
+ return {
84
+ title: `Plannotator ${advert.version} is available`,
85
+ description: `You have ${advert.current}. Update from the Inbox, or turn on automatic updates in Settings.`,
86
+ primary: 'update',
87
+ };
88
+ case 'copy':
89
+ return {
90
+ title: `Plannotator ${advert.version} is available`,
91
+ description: `You have ${advert.current}. This copy was not installed by the install script: run the install command to update.`,
92
+ primary: 'copy',
93
+ };
94
+ case 'pi':
95
+ return {
96
+ title: `Plannotator for Pi ${advert.version} is available`,
97
+ description: `Run ${advert.command}, then restart Pi.`,
98
+ primary: 'copy',
99
+ };
100
+ case 'opencode':
101
+ return {
102
+ title: `Plannotator ${advert.version} is available`,
103
+ description: "Run the install script again, then restart OpenCode. It also clears OpenCode's cached plugin.",
104
+ primary: 'copy',
105
+ };
106
+ }
107
+ }
108
+
109
+ /** Ask the server to run the install script now (`script` only). */
110
+ export async function requestUpdateNow(): Promise<{ ok: true; version: string } | { ok: false; error: string }> {
111
+ try {
112
+ const res = await fetch('/api/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(withServerSession({})) });
113
+ if (await noteServerSessionMismatch(res)) return { ok: false, error: 'This review was replaced by a newer Plannotator session. Reload the page.' };
114
+ const body = (await res.json().catch(() => ({}))) as { version?: string; error?: string };
115
+ if (res.ok && typeof body.version === 'string') return { ok: true, version: body.version };
116
+ return { ok: false, error: body.error ?? `The update could not start (HTTP ${res.status}).` };
117
+ } catch {
118
+ return { ok: false, error: 'The update could not start: the session is not reachable.' };
119
+ }
120
+ }