@plannotator/ui 0.52.0 → 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.
- package/HANDOFF.md +317 -29
- package/README.md +35 -3
- package/components/AnnotationPanel.tsx +159 -107
- package/components/AnnotationToolbar.tsx +17 -14
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/BlockRenderer.tsx +26 -14
- package/components/CodeFilePopout.tsx +131 -68
- package/components/CommentPopover.tsx +6 -349
- package/components/ComposerTokenTextarea.tsx +379 -0
- package/components/ExportModal.tsx +6 -1
- package/components/HostTitle.tsx +108 -0
- package/components/ImageLightbox.tsx +288 -17
- package/components/ImagePeek.tsx +255 -0
- package/components/ImageResolverContext.tsx +31 -0
- package/components/InlineMarkdown.tsx +123 -15
- package/components/LocalLinkContext.tsx +49 -0
- package/components/MenuVersionSection.tsx +51 -25
- package/components/OpenInAppButton.tsx +18 -49
- package/components/PlanHeaderMenu.tsx +2 -8
- package/components/ProviderIcons.tsx +10 -4
- package/components/RenderedMarkdown.tsx +40 -4
- package/components/Settings.tsx +33 -5
- package/components/SkillReferenceMenu.tsx +28 -6
- package/components/Tooltip.tsx +51 -4
- package/components/Viewer.tsx +214 -55
- package/components/ai/AIProviderBar.tsx +11 -0
- package/components/ai/AskSessionSetupHint.tsx +12 -0
- package/components/ai/DocumentAIChatPanel.tsx +17 -5
- package/components/ai/SessionAskNotice.tsx +4 -3
- package/components/blocks/BlockquoteBlock.tsx +159 -0
- package/components/blocks/HtmlBlock.tsx +121 -9
- package/components/blocks/QuestionBlock.tsx +253 -104
- package/components/diagram/DiagramViewer.tsx +41 -4
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +73 -3
- package/components/html-viewer/bridge-script.asset.js +86 -0
- package/components/html-viewer/bridge-script.ts +86 -0
- package/components/html-viewer/useHtmlAnnotation.ts +51 -3
- package/components/icons/AgentIcons.tsx +9 -7
- package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
- package/components/plan-diff/PlanDiffViewer.tsx +6 -1
- package/configure.ts +27 -1
- package/hooks/useAgentJobs.ts +8 -3
- package/hooks/useAnnotationHighlighter.ts +128 -23
- package/hooks/useExternalAnnotations.ts +6 -0
- package/hooks/useOpenInApps.ts +88 -0
- package/hooks/useUpdateCheck.ts +38 -98
- package/hooks/useUpdateCheckSetting.ts +43 -0
- package/hooks/useUpdateToast.ts +101 -0
- package/package.json +2 -2
- package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
- package/shortcuts/index.ts +1 -0
- package/styles.css +1 -1
- package/types.ts +48 -0
- package/utils/agentClosed.ts +26 -0
- package/utils/agentToolSetting.ts +16 -9
- package/utils/aiProvider.ts +7 -0
- package/utils/askSessionSetupHint.ts +27 -0
- package/utils/blockTargeting.ts +14 -3
- package/utils/parser.ts +65 -51
- package/utils/pasteInsert.ts +234 -0
- package/utils/planDiffEngine.ts +272 -2
- package/utils/skillCatalog.ts +15 -2
- package/utils/skillReferences.ts +50 -13
- package/utils/storage.ts +5 -0
- package/utils/textCut.ts +41 -0
- package/utils/updateNotice.ts +120 -0
package/utils/planDiffEngine.ts
CHANGED
|
@@ -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 =
|
|
534
|
-
|
|
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
|
+
}
|
package/utils/skillCatalog.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/utils/skillReferences.ts
CHANGED
|
@@ -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
|
|
335
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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}\` (${
|
|
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;
|
|
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}\` (${
|
|
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
|
}
|
package/utils/textCut.ts
ADDED
|
@@ -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
|
+
}
|