@plannotator/ui 0.53.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HANDOFF.md +284 -27
- package/README.md +84 -14
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +177 -34
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/AnnotationToolstrip.tsx +45 -40
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +19 -5
- package/components/CodeFilePopout.tsx +140 -5
- package/components/CommentPopover.tsx +46 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DiagramBlock.tsx +17 -12
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/HtmlSurfaceControls.tsx +2 -1
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +658 -120
- package/components/ImagePeek.tsx +38 -4
- package/components/ImageRegions.tsx +369 -0
- package/components/ImageThumbnail.tsx +1 -1
- package/components/ImageZoomStage.tsx +796 -0
- package/components/InboxAnnouncementDialog.tsx +202 -0
- package/components/InlineMarkdown.tsx +152 -24
- package/components/KeyboardShortcuts.tsx +23 -0
- package/components/LazyBlockBoundary.tsx +100 -0
- package/components/ListMarker.tsx +3 -3
- package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
- package/components/MentionPicker.tsx +51 -0
- package/components/MenuVersionSection.tsx +31 -9
- package/components/PlanDecisionMenuExtras.tsx +129 -0
- package/components/PlanHeaderMenu.tsx +15 -1
- package/components/ProviderIcons.tsx +8 -0
- package/components/RefHoverCard.tsx +913 -0
- package/components/SendFailureToast.tsx +126 -0
- package/components/Settings.tsx +617 -126
- package/components/SkillReferenceMenu.tsx +39 -12
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/ThemeProvider.tsx +14 -1
- package/components/ThemeTab.tsx +16 -24
- package/components/Tooltip.tsx +49 -18
- package/components/Viewer.tsx +316 -47
- package/components/ai/DocumentAIChatPanel.tsx +29 -3
- package/components/ai/NewChatButton.tsx +74 -0
- package/components/blocks/CodeBlock.tsx +1 -1
- package/components/blocks/HtmlBlock.tsx +7 -1
- package/components/blocks/QuestionBlock.tsx +522 -62
- package/components/blocks/QuestionContext.tsx +3 -0
- package/components/diagram/DiagramComposer.tsx +5 -1
- package/components/diagram/DiagramPending.tsx +12 -2
- package/components/diagram/DiagramViewer.tsx +19 -3
- package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
- package/components/html-embed/SvgFenceBlock.tsx +46 -0
- package/components/html-embed/index.ts +3 -0
- package/components/html-embed/languages.ts +15 -0
- package/components/html-embed/loadGate.ts +107 -0
- package/components/html-viewer/HtmlViewer.tsx +69 -6
- package/components/html-viewer/bridge-script.asset.js +504 -12
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +545 -12
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +265 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
- package/components/plan-diff/PlanDiffViewer.tsx +13 -2
- package/components/plan-diff/PlanRawDiffView.tsx +1 -1
- package/components/sidebar/FileBrowser.tsx +16 -2
- package/components/sidebar/SidebarTabs.tsx +3 -0
- package/config/index.ts +3 -0
- package/config/reviewView.ts +41 -1
- package/config/settings.ts +155 -4
- package/configure.ts +28 -0
- package/hooks/pfm/useCodeFilePopout.ts +7 -1
- package/hooks/useAIChat.ts +72 -25
- package/hooks/useAnnotationDraft.ts +71 -6
- package/hooks/useAnnotationHighlighter.ts +57 -3
- package/hooks/useCodeAnnotationDraft.ts +113 -17
- package/hooks/useCookieHostSetting.ts +96 -0
- package/hooks/useDocumentFont.ts +17 -0
- package/hooks/useDocumentTextSize.ts +20 -0
- package/hooks/useFileBrowser.ts +10 -1
- package/hooks/useLinkedDoc.ts +226 -13
- package/hooks/useMentionAutocomplete.ts +1 -1
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useSkillReferenceAutocomplete.ts +7 -3
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/hooks/useVimSelection.ts +9 -0
- package/package.json +23 -22
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
- package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
- package/shortcuts/core.ts +63 -4
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
- package/shortcuts/runtime.ts +14 -6
- package/styles.css +1 -1
- package/theme.css +179 -1
- package/themes/plannotator-graphite.css +66 -0
- package/types.ts +26 -9
- package/utils/agentSwitch.ts +41 -20
- package/utils/agentToolSetting.ts +1 -1
- package/utils/aiChatFormat.ts +23 -7
- package/utils/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -0
- package/utils/codeLineScroll.ts +100 -0
- package/utils/commentSubmitKey.ts +48 -0
- package/utils/cssFontFamily.ts +65 -0
- package/utils/decisionSpec.ts +190 -2
- package/utils/defaultNotesApp.ts +1 -1
- package/utils/diffFonts.ts +4 -1
- package/utils/documentFont.ts +68 -0
- package/utils/documentTextSize.ts +54 -0
- package/utils/fontDetect.ts +63 -0
- package/utils/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mentions.ts +34 -5
- package/utils/mermaid.ts +20 -3
- package/utils/mermaidTheme.ts +3 -51
- package/utils/notion.ts +58 -0
- package/utils/parser.ts +28 -1
- package/utils/platform.ts +46 -0
- package/utils/refCard.ts +82 -0
- package/utils/richClipboard.ts +380 -0
- package/utils/richClipboardLists.ts +57 -0
- package/utils/richCopyPolicy.ts +22 -0
- package/utils/scrollAnchor.ts +82 -0
- package/utils/sectionRefs.ts +123 -0
- package/utils/selectionBoundary.ts +40 -0
- package/utils/sendFailure.ts +85 -0
- package/utils/sharing.ts +5 -2
- package/utils/themeRegistry.ts +10 -0
- package/utils/themeTokens.ts +74 -0
- package/utils/updateNotice.ts +30 -8
- package/utils/vscodePanel.ts +9 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remember where a reader was in a rendered document, and put them back there.
|
|
3
|
+
*
|
|
4
|
+
* Raw pixels are a poor bookmark: images that finish loading, highlights that
|
|
5
|
+
* are re-applied, or a sidebar that changes the reading column's width all move
|
|
6
|
+
* the text under a fixed scrollTop. So the anchor is the first block
|
|
7
|
+
* (`data-block-id`) still visible at the top of the scroll viewport, plus how
|
|
8
|
+
* far its top sat from the viewport's top. Restoring scrolls that block back to
|
|
9
|
+
* the same offset; the raw scrollTop is only the fallback when the block is
|
|
10
|
+
* gone.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
getScrollViewportRect,
|
|
15
|
+
getScrollViewportTop,
|
|
16
|
+
offsetScrollViewport,
|
|
17
|
+
scrollViewportTo,
|
|
18
|
+
} from '../hooks/useScrollViewport';
|
|
19
|
+
|
|
20
|
+
export interface ScrollAnchor {
|
|
21
|
+
/** The first block visible at the top of the viewport, null when none is. */
|
|
22
|
+
blockId: string | null;
|
|
23
|
+
/** That block's top minus the viewport's top, in CSS pixels (≤ 0 when the
|
|
24
|
+
* block starts above the viewport). */
|
|
25
|
+
offset: number;
|
|
26
|
+
/** The viewport's scrollTop when captured — the fallback. */
|
|
27
|
+
scrollTop: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function blocksIn(viewport: HTMLElement): HTMLElement[] {
|
|
31
|
+
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-block-id]'));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Capture where the reader is in `viewport`. */
|
|
35
|
+
export function captureScrollAnchor(viewport: HTMLElement): ScrollAnchor {
|
|
36
|
+
const scrollTop = getScrollViewportTop(viewport);
|
|
37
|
+
const viewportTop = getScrollViewportRect(viewport).top;
|
|
38
|
+
for (const block of blocksIn(viewport)) {
|
|
39
|
+
const rect = block.getBoundingClientRect();
|
|
40
|
+
if (rect.height <= 0 && rect.width <= 0) continue;
|
|
41
|
+
if (rect.bottom > viewportTop + 1) {
|
|
42
|
+
return {
|
|
43
|
+
blockId: block.getAttribute('data-block-id'),
|
|
44
|
+
offset: rect.top - viewportTop,
|
|
45
|
+
scrollTop,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { blockId: null, offset: 0, scrollTop };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function findBlock(viewport: HTMLElement, blockId: string): HTMLElement | null {
|
|
53
|
+
for (const block of blocksIn(viewport)) {
|
|
54
|
+
if (block.getAttribute('data-block-id') === blockId) return block;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether the anchor's block is in the viewport's DOM yet. A document that is
|
|
61
|
+
* still mounting has none of its blocks, so a caller can wait for this.
|
|
62
|
+
*/
|
|
63
|
+
export function scrollAnchorReady(viewport: HTMLElement, anchor: ScrollAnchor): boolean {
|
|
64
|
+
if (anchor.blockId === null) return blocksIn(viewport).length > 0 || anchor.scrollTop === 0;
|
|
65
|
+
return findBlock(viewport, anchor.blockId) !== null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Put the reader back at `anchor`. Returns the viewport's scrollTop afterwards.
|
|
70
|
+
* When the anchor's block is gone, falls back to the captured scrollTop.
|
|
71
|
+
*/
|
|
72
|
+
export function restoreScrollAnchor(viewport: HTMLElement, anchor: ScrollAnchor): number {
|
|
73
|
+
const block = anchor.blockId === null ? null : findBlock(viewport, anchor.blockId);
|
|
74
|
+
if (block) {
|
|
75
|
+
const viewportTop = getScrollViewportRect(viewport).top;
|
|
76
|
+
const delta = block.getBoundingClientRect().top - viewportTop - anchor.offset;
|
|
77
|
+
if (Math.abs(delta) >= 1) offsetScrollViewport(viewport, delta);
|
|
78
|
+
} else if (Math.abs(getScrollViewportTop(viewport) - anchor.scrollTop) >= 1) {
|
|
79
|
+
scrollViewportTo(viewport, { top: anchor.scrollTop, behavior: 'auto' });
|
|
80
|
+
}
|
|
81
|
+
return getScrollViewportTop(viewport);
|
|
82
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `#Heading` references inside annotation text.
|
|
3
|
+
*
|
|
4
|
+
* Typing `#` followed by a heading's exact text links to that heading. Only
|
|
5
|
+
* headings in the current document resolve, so `#123`, `#fff` or a URL
|
|
6
|
+
* fragment stay plain text unless a heading is literally named that way.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { buildHeadingSlugMap } from './slugify';
|
|
10
|
+
import { transformPlainText } from './inlineTransforms';
|
|
11
|
+
|
|
12
|
+
export interface SectionRefSegment {
|
|
13
|
+
/** The heading text, shown as the link label. */
|
|
14
|
+
label: string;
|
|
15
|
+
/** The heading's anchor id in the rendered document. */
|
|
16
|
+
anchor: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type SectionRefPart = string | SectionRefSegment;
|
|
20
|
+
|
|
21
|
+
/** A heading's text without its inline markdown syntax. */
|
|
22
|
+
function stripHeadingMarkdown(content: string): string {
|
|
23
|
+
return content
|
|
24
|
+
.replace(/\[\[([^\]]+)\]\]/g, '$1')
|
|
25
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
26
|
+
.replace(/[*_`~]/g, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Heading text → anchor id. Each heading is indexed under its markdown source
|
|
31
|
+
* (`**Don't** ship`), the text the page shows (`Don’t ship`), and that text as
|
|
32
|
+
* typed with straight quotes (`Don't ship`). A title that two different
|
|
33
|
+
* headings share is left out: it could mean either.
|
|
34
|
+
*/
|
|
35
|
+
export function buildSectionRefIndex(
|
|
36
|
+
blocks: Array<{ id: string; type: string; content: string }>,
|
|
37
|
+
): Map<string, string> {
|
|
38
|
+
const slugs = buildHeadingSlugMap(blocks);
|
|
39
|
+
const index = new Map<string, string>();
|
|
40
|
+
const ambiguous = new Set<string>();
|
|
41
|
+
|
|
42
|
+
const register = (title: string, anchor: string) => {
|
|
43
|
+
const key = title.trim();
|
|
44
|
+
if (!key) return;
|
|
45
|
+
const existing = index.get(key);
|
|
46
|
+
if (existing === undefined) index.set(key, anchor);
|
|
47
|
+
else if (existing !== anchor) ambiguous.add(key);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
for (const block of blocks) {
|
|
51
|
+
if (block.type !== 'heading') continue;
|
|
52
|
+
const anchor = slugs.get(block.id);
|
|
53
|
+
if (!anchor) continue;
|
|
54
|
+
const stripped = stripHeadingMarkdown(block.content);
|
|
55
|
+
register(block.content, anchor);
|
|
56
|
+
register(stripped, anchor);
|
|
57
|
+
register(transformPlainText(stripped), anchor);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const title of ambiguous) index.delete(title);
|
|
61
|
+
return index;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A reference starts a word: at the start, after whitespace, or after an
|
|
65
|
+
// opening bracket or quote. This keeps `C#`, `issue#4` and `page/#intro` plain.
|
|
66
|
+
const REF_START = /[\s([{"'“‘「]/u;
|
|
67
|
+
const SPACED_WORD = /[\p{Script=Latin}\p{Script=Cyrillic}\p{Script=Greek}\p{N}_]/u;
|
|
68
|
+
|
|
69
|
+
function matchAt(text: string, hash: number, titles: string[]): string | null {
|
|
70
|
+
// `# Title` is a markdown heading, not a reference.
|
|
71
|
+
if (hash + 1 >= text.length || /\s/.test(text[hash + 1]!)) return null;
|
|
72
|
+
// Longest first, so `#3.1 Scope` beats a sibling heading named `3.1`.
|
|
73
|
+
for (const title of titles) {
|
|
74
|
+
if (!text.startsWith(title, hash + 1)) continue;
|
|
75
|
+
// `#Intro` must not link inside `#Introduction`. Only scripts that space
|
|
76
|
+
// their words need the boundary: elsewhere a suffix follows the title directly.
|
|
77
|
+
const end = hash + 1 + title.length;
|
|
78
|
+
const next = text.slice(end, end + 2).match(/^./su)?.[0];
|
|
79
|
+
const last = title.match(/.$/su)?.[0];
|
|
80
|
+
if (next && last && SPACED_WORD.test(next) && SPACED_WORD.test(last)) continue;
|
|
81
|
+
return title;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Split annotation text into plain runs and `#Heading` references. Returns
|
|
88
|
+
* `[text]` when nothing resolves. Text inside backticks is never linked.
|
|
89
|
+
*/
|
|
90
|
+
export function parseSectionRefs(text: string, index: Map<string, string>): SectionRefPart[] {
|
|
91
|
+
if (!text || index.size === 0 || !text.includes('#')) return [text];
|
|
92
|
+
|
|
93
|
+
const titles = [...index.keys()].sort((a, b) => b.length - a.length);
|
|
94
|
+
const parts: SectionRefPart[] = [];
|
|
95
|
+
let plain = '';
|
|
96
|
+
let i = 0;
|
|
97
|
+
|
|
98
|
+
while (i < text.length) {
|
|
99
|
+
const ch = text[i]!;
|
|
100
|
+
const close = ch === '`' ? text.indexOf('`', i + 1) : -1;
|
|
101
|
+
if (close !== -1) {
|
|
102
|
+
plain += text.slice(i, close + 1);
|
|
103
|
+
i = close + 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const title =
|
|
107
|
+
ch === '#' && (i === 0 || REF_START.test(text[i - 1]!))
|
|
108
|
+
? matchAt(text, i, titles)
|
|
109
|
+
: null;
|
|
110
|
+
if (!title) {
|
|
111
|
+
plain += ch;
|
|
112
|
+
i += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (plain) parts.push(plain);
|
|
116
|
+
plain = '';
|
|
117
|
+
parts.push({ label: title, anchor: index.get(title)! });
|
|
118
|
+
i += 1 + title.length;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (plain) parts.push(plain);
|
|
122
|
+
return parts.some((p) => typeof p !== 'string') ? parts : [text];
|
|
123
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boundary trimming for web-highlighter's `Render.SelectedNodes` hook.
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own pure module so it can be unit-tested without a DOM:
|
|
5
|
+
* useAnnotationHighlighter imports @plannotator/web-highlighter, whose UMD
|
|
6
|
+
* bundle reads `window` at module-eval time.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Minimal structural view of web-highlighter's `SelectedNode`: only the field
|
|
11
|
+
* the trim actually reads. Declared locally rather than imported from the
|
|
12
|
+
* package's `dist/`, which resolves today only because the fork ships no
|
|
13
|
+
* `exports` map; a republish that adds one would break consumers compiling
|
|
14
|
+
* @plannotator/ui from source.
|
|
15
|
+
*/
|
|
16
|
+
export interface SelectedNodeLike {
|
|
17
|
+
$node: { textContent: string | null };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Drop whitespace-only nodes from the leading and trailing ends of the node
|
|
22
|
+
* list web-highlighter is about to wrap. Whitespace-only nodes in the MIDDLE of
|
|
23
|
+
* a genuine multi-node selection are kept, so inter-node spacing stays
|
|
24
|
+
* highlighted.
|
|
25
|
+
*/
|
|
26
|
+
export const trimWhitespaceOnlyBoundaryNodes = (
|
|
27
|
+
selectedNodes: SelectedNodeLike[],
|
|
28
|
+
): SelectedNodeLike[] => {
|
|
29
|
+
let start = 0;
|
|
30
|
+
while (start < selectedNodes.length && !/\S/.test(selectedNodes[start].$node.textContent ?? '')) {
|
|
31
|
+
start += 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let end = selectedNodes.length;
|
|
35
|
+
while (end > start && !/\S/.test(selectedNodes[end - 1].$node.textContent ?? '')) {
|
|
36
|
+
end -= 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return selectedNodes.slice(start, end);
|
|
40
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a decision POST (send feedback, approve, deny) did not land (#1392).
|
|
3
|
+
*
|
|
4
|
+
* The review pages used to swallow these failures: a POST to a server that
|
|
5
|
+
* had stopped rejected with a network error, the catch only re-enabled the
|
|
6
|
+
* button, and the reviewer could not tell that nothing reached the agent.
|
|
7
|
+
* The handlers now tag the failure so the page can say which one happened
|
|
8
|
+
* (`SendFailureToast`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The `code` every plan, annotate and review server (Bun and Pi) puts on its
|
|
12
|
+
* 409 for a decision that arrives after the review was decided or closed
|
|
13
|
+
* (also `packages/shared/host-control.ts`). */
|
|
14
|
+
export const ALREADY_DECIDED_CODE = 'already_decided';
|
|
15
|
+
|
|
16
|
+
/** The `code` of plan review's 409 for a decision on an older revision. */
|
|
17
|
+
export const PLAN_REVISED_CODE = 'plan_revised';
|
|
18
|
+
|
|
19
|
+
export type SendFailure =
|
|
20
|
+
/** fetch rejected: the server is gone (stopped, crashed) or unreachable. */
|
|
21
|
+
| { kind: 'unreachable' }
|
|
22
|
+
/** The server answered with a non-2xx status it has no dedicated UI for. */
|
|
23
|
+
| { kind: 'rejected'; status: number }
|
|
24
|
+
/** The review was decided already (another window sent it, or the session
|
|
25
|
+
* that opened it ended). Sending again cannot work. */
|
|
26
|
+
| { kind: 'decided' }
|
|
27
|
+
/** Anything else thrown before the response settled. */
|
|
28
|
+
| { kind: 'unknown' };
|
|
29
|
+
|
|
30
|
+
export class DecisionSendError extends Error {
|
|
31
|
+
readonly failure: SendFailure;
|
|
32
|
+
constructor(failure: SendFailure, options?: { cause?: unknown }) {
|
|
33
|
+
super(`Decision not sent (${failure.kind})`, options);
|
|
34
|
+
this.name = 'DecisionSendError';
|
|
35
|
+
this.failure = failure;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `.catch()` for a decision fetch: a rejected fetch means the server is unreachable. */
|
|
40
|
+
export function throwUnreachable(cause: unknown): never {
|
|
41
|
+
throw new DecisionSendError({ kind: 'unreachable' }, { cause });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The `code` of a refusal body, when it has one. Never consumes `res`. */
|
|
45
|
+
export async function refusalCode(res: Response): Promise<string | undefined> {
|
|
46
|
+
try {
|
|
47
|
+
const code = ((await res.clone().json()) as { code?: unknown } | null)?.code;
|
|
48
|
+
return typeof code === 'string' ? code : undefined;
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Throws the failure for a response that is not ok: `decided` for the
|
|
55
|
+
* servers' already-decided refusal, `rejected` with the status otherwise. */
|
|
56
|
+
export async function assertSendOk(res: Response): Promise<void> {
|
|
57
|
+
if (res.ok) return;
|
|
58
|
+
if (res.status === 409 && (await refusalCode(res)) === ALREADY_DECIDED_CODE) {
|
|
59
|
+
throw new DecisionSendError({ kind: 'decided' });
|
|
60
|
+
}
|
|
61
|
+
throw new DecisionSendError({ kind: 'rejected', status: res.status });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function sendFailureOf(error: unknown): SendFailure {
|
|
65
|
+
return error instanceof DecisionSendError ? error.failure : { kind: 'unknown' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether sending the same decision again could work. */
|
|
69
|
+
export function canRetrySend(failure: SendFailure): boolean {
|
|
70
|
+
return failure.kind !== 'decided';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The one sentence under the toast's title: why nothing reached the agent. */
|
|
74
|
+
export function describeSendFailure(failure: SendFailure): string {
|
|
75
|
+
switch (failure.kind) {
|
|
76
|
+
case 'unreachable':
|
|
77
|
+
return "Plannotator isn't running or can't be reached.";
|
|
78
|
+
case 'rejected':
|
|
79
|
+
return `Plannotator answered with an error (${failure.status}).`;
|
|
80
|
+
case 'decided':
|
|
81
|
+
return 'This review was already decided, in another window or because its session ended.';
|
|
82
|
+
default:
|
|
83
|
+
return 'Something went wrong before it was sent.';
|
|
84
|
+
}
|
|
85
|
+
}
|
package/utils/sharing.ts
CHANGED
|
@@ -29,7 +29,7 @@ export interface SharePayload {
|
|
|
29
29
|
d?: (string | null)[]; // diffContext per annotation, parallel to `a`
|
|
30
30
|
s?: (string | undefined)[]; // source per annotation (external tool identifier), parallel to `a`
|
|
31
31
|
h?: string; // raw HTML content (direct HTML rendering mode)
|
|
32
|
-
r?: 'html'; // render mode flag (omitted = markdown)
|
|
32
|
+
r?: 'html' | 'asciidoc'; // render mode flag (omitted = markdown)
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
@@ -192,6 +192,7 @@ export async function generateShareUrl(
|
|
|
192
192
|
globalAttachments?: ImageAttachment[],
|
|
193
193
|
baseUrl: string = DEFAULT_SHARE_BASE,
|
|
194
194
|
rawHtml?: string,
|
|
195
|
+
renderAs?: DocumentRenderAs,
|
|
195
196
|
): Promise<string | null> {
|
|
196
197
|
// HTML content is too large for URL hashes — force paste service path
|
|
197
198
|
if (rawHtml) return null;
|
|
@@ -203,6 +204,7 @@ export async function generateShareUrl(
|
|
|
203
204
|
g: globalAttachments?.length ? toShareableImages(globalAttachments) : undefined,
|
|
204
205
|
...(diffContexts ? { d: diffContexts } : {}),
|
|
205
206
|
...(sources ? { s: sources } : {}),
|
|
207
|
+
...(renderAs === 'asciidoc' ? { r: 'asciidoc' as const } : {}),
|
|
206
208
|
};
|
|
207
209
|
|
|
208
210
|
const hash = await compress(payload);
|
|
@@ -274,6 +276,7 @@ export async function createShortShareUrl(
|
|
|
274
276
|
shareBaseUrl?: string;
|
|
275
277
|
},
|
|
276
278
|
rawHtml?: string,
|
|
279
|
+
renderAs?: DocumentRenderAs,
|
|
277
280
|
): Promise<{ shortUrl: string; id: string } | null> {
|
|
278
281
|
const pasteApi = options?.pasteApiUrl ?? DEFAULT_PASTE_API;
|
|
279
282
|
const shareBase = options?.shareBaseUrl ?? DEFAULT_SHARE_BASE;
|
|
@@ -287,7 +290,7 @@ export async function createShortShareUrl(
|
|
|
287
290
|
g: globalAttachments?.length ? toShareableImages(globalAttachments) : undefined,
|
|
288
291
|
...(diffContexts ? { d: diffContexts } : {}),
|
|
289
292
|
...(sources ? { s: sources } : {}),
|
|
290
|
-
...(rawHtml ? { h: rawHtml, r: 'html' as const } : {}),
|
|
293
|
+
...(rawHtml ? { h: rawHtml, r: 'html' as const } : renderAs === 'asciidoc' ? { r: 'asciidoc' as const } : {}),
|
|
291
294
|
};
|
|
292
295
|
|
|
293
296
|
const compressed = await compress(payload);
|
package/utils/themeRegistry.ts
CHANGED
|
@@ -31,6 +31,16 @@ export const BUILT_IN_THEMES: ThemeInfo[] = [
|
|
|
31
31
|
light: { primary: 'oklch(0.50 0.25 280)', secondary: 'oklch(0.50 0.18 180)', accent: 'oklch(0.60 0.22 50)', background: 'oklch(0.97 0.005 260)', foreground: 'oklch(0.18 0.02 260)' },
|
|
32
32
|
},
|
|
33
33
|
},
|
|
34
|
+
{
|
|
35
|
+
id: 'plannotator-graphite',
|
|
36
|
+
name: 'Plannotator Graphite',
|
|
37
|
+
builtIn: true,
|
|
38
|
+
modeSupport: 'both',
|
|
39
|
+
colors: {
|
|
40
|
+
dark: { primary: 'oklch(0.765 0.124 283)', secondary: 'oklch(0.740 0.123 180)', accent: 'oklch(0.765 0.156 60)', background: 'oklch(0.225 0.006 286)', foreground: 'oklch(0.945 0.003 286)' },
|
|
41
|
+
light: { primary: 'oklch(0.50 0.25 280)', secondary: 'oklch(0.50 0.18 180)', accent: 'oklch(0.60 0.22 50)', background: 'oklch(0.97 0.005 260)', foreground: 'oklch(0.18 0.02 260)' },
|
|
42
|
+
},
|
|
43
|
+
},
|
|
34
44
|
{
|
|
35
45
|
id: 'simple',
|
|
36
46
|
name: 'Simple',
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the live theme tokens off the document, for code that derives
|
|
3
|
+
* colours from the palette on screen (Mermaid's theme, the review sidebar's
|
|
4
|
+
* +/- counts). Browser-only, and free of any diagram code, so a bundle that
|
|
5
|
+
* only needs the tokens never pulls a diagram runtime in.
|
|
6
|
+
*/
|
|
7
|
+
import { parseCssColor } from './cssColor';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read the custom properties `names` off `el` (default: the document element,
|
|
11
|
+
* where `ThemeProvider` puts the `theme-*` / `light` classes). Colours the
|
|
12
|
+
* parser does not understand as written (`color-mix()`, a `var()` chain) are
|
|
13
|
+
* resolved through a throwaway probe element so the engine does the
|
|
14
|
+
* substitution; names in `raw` are returned as written (a font stack).
|
|
15
|
+
* Returns `undefined` outside a browser; a token that does not resolve is
|
|
16
|
+
* left out.
|
|
17
|
+
*/
|
|
18
|
+
export function readThemeTokenValues<N extends string>(
|
|
19
|
+
names: readonly N[],
|
|
20
|
+
el?: Element | null,
|
|
21
|
+
raw: readonly N[] = [],
|
|
22
|
+
): Partial<Record<N, string>> | undefined {
|
|
23
|
+
if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') return undefined;
|
|
24
|
+
const target = el ?? document.documentElement;
|
|
25
|
+
if (!target) return undefined;
|
|
26
|
+
let computed: CSSStyleDeclaration;
|
|
27
|
+
try {
|
|
28
|
+
computed = getComputedStyle(target);
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const tokens: Partial<Record<N, string>> = {};
|
|
33
|
+
let probe: HTMLElement | null = null;
|
|
34
|
+
try {
|
|
35
|
+
for (const name of names) {
|
|
36
|
+
let value = '';
|
|
37
|
+
try {
|
|
38
|
+
value = computed.getPropertyValue(`--${name}`).trim();
|
|
39
|
+
} catch {
|
|
40
|
+
value = '';
|
|
41
|
+
}
|
|
42
|
+
if (!value) continue;
|
|
43
|
+
if (raw.includes(name)) {
|
|
44
|
+
tokens[name] = value;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (parseCssColor(value)) {
|
|
48
|
+
tokens[name] = value;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
// Unparsable as written: let the engine resolve it to a colour.
|
|
52
|
+
try {
|
|
53
|
+
if (!probe) {
|
|
54
|
+
probe = document.createElement('span');
|
|
55
|
+
probe.setAttribute('aria-hidden', 'true');
|
|
56
|
+
probe.style.position = 'absolute';
|
|
57
|
+
probe.style.width = '0';
|
|
58
|
+
probe.style.height = '0';
|
|
59
|
+
probe.style.overflow = 'hidden';
|
|
60
|
+
probe.style.pointerEvents = 'none';
|
|
61
|
+
target.appendChild(probe);
|
|
62
|
+
}
|
|
63
|
+
probe.style.color = `var(--${name})`;
|
|
64
|
+
const resolved = getComputedStyle(probe).color.trim();
|
|
65
|
+
if (resolved && parseCssColor(resolved)) tokens[name] = resolved;
|
|
66
|
+
} catch {
|
|
67
|
+
// leave the token absent
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
probe?.remove();
|
|
72
|
+
}
|
|
73
|
+
return tokens;
|
|
74
|
+
}
|
package/utils/updateNotice.ts
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* .product/approved/plannotator-updates/ §4.5, owner's answers 1 and 3).
|
|
4
4
|
*
|
|
5
5
|
* The server says whether a newer release exists (`updateAvailable` on
|
|
6
|
-
* /api/plan and /api/diff, from the machine's
|
|
6
|
+
* /api/plan and /api/diff, from the machine's own check): the browser
|
|
7
7
|
* never asks GitHub. This module parses that advert, decides whether the toast
|
|
8
|
-
* may show now (once per version, and at most once
|
|
9
|
-
*
|
|
8
|
+
* may show now (once per version, and at most once a day across versions,
|
|
9
|
+
* remembered in a cookie that every localhost port shares), and
|
|
10
10
|
* words the toast for the host that launched the session.
|
|
11
11
|
*/
|
|
12
12
|
|
|
@@ -25,8 +25,30 @@ export interface UpdateAdvert {
|
|
|
25
25
|
publishedAt?: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Checking for updates has failed for hours on this machine
|
|
30
|
+
* (`updateCheckFailing` on /api/plan and /api/diff, never beside
|
|
31
|
+
* `updateAvailable`): the Options menu says so quietly, with the command that
|
|
32
|
+
* updates without the check. No toast, no dot.
|
|
33
|
+
*/
|
|
34
|
+
export interface UpdateCheckFailingAdvert {
|
|
35
|
+
since: string;
|
|
36
|
+
error: string;
|
|
37
|
+
command: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The server's failing record, validated (anything malformed is none). */
|
|
41
|
+
export function parseUpdateCheckFailing(value: unknown): UpdateCheckFailingAdvert | null {
|
|
42
|
+
if (!value || typeof value !== 'object') return null;
|
|
43
|
+
const v = value as Record<string, unknown>;
|
|
44
|
+
if (typeof v.since !== 'string' || Number.isNaN(Date.parse(v.since))) return null;
|
|
45
|
+
if (typeof v.error !== 'string' || v.error.length > 200) return null;
|
|
46
|
+
if (typeof v.command !== 'string' || !v.command || v.command.length > 500) return null;
|
|
47
|
+
return { since: v.since, error: v.error, command: v.command };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The toast shows at most this often, across versions (owner's choice: once a day). */
|
|
51
|
+
export const UPDATE_TOAST_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
30
52
|
|
|
31
53
|
const TOAST_KEY = 'plannotator-update-toast';
|
|
32
54
|
const VERSION = /^\d{1,9}\.\d{1,9}\.\d{1,9}$/;
|
|
@@ -82,7 +104,7 @@ export function describeUpdateToast(advert: UpdateAdvert): { title: string; desc
|
|
|
82
104
|
case 'script':
|
|
83
105
|
return {
|
|
84
106
|
title: `Plannotator ${advert.version} is available`,
|
|
85
|
-
description: `You have ${advert.current}. Update
|
|
107
|
+
description: `You have ${advert.current}. Update installs it in the background; this review keeps working.`,
|
|
86
108
|
primary: 'update',
|
|
87
109
|
};
|
|
88
110
|
case 'copy':
|
|
@@ -94,13 +116,13 @@ export function describeUpdateToast(advert: UpdateAdvert): { title: string; desc
|
|
|
94
116
|
case 'pi':
|
|
95
117
|
return {
|
|
96
118
|
title: `Plannotator for Pi ${advert.version} is available`,
|
|
97
|
-
description: `Run ${advert.command}, then
|
|
119
|
+
description: `Run ${advert.command}, then type /reload in Pi.`,
|
|
98
120
|
primary: 'copy',
|
|
99
121
|
};
|
|
100
122
|
case 'opencode':
|
|
101
123
|
return {
|
|
102
124
|
title: `Plannotator ${advert.version} is available`,
|
|
103
|
-
description: "Run the install script again, then restart
|
|
125
|
+
description: "Run the install script again, then run opencode2 reload (OpenCode 1: restart it). It also clears OpenCode's cached plugin.",
|
|
104
126
|
primary: 'copy',
|
|
105
127
|
};
|
|
106
128
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True inside the VS Code extension's panel (its theme bridge sets
|
|
3
|
+
* `window.__PLANNOTATOR_VSCODE`). There the panel forwards keys to VS Code, so
|
|
4
|
+
* a page key that shadows a VS Code window key (⌘P is Quick Open there, not
|
|
5
|
+
* Print) should step aside.
|
|
6
|
+
*/
|
|
7
|
+
export function isVSCodePanel(): boolean {
|
|
8
|
+
return typeof window !== 'undefined' && (window as { __PLANNOTATOR_VSCODE?: unknown }).__PLANNOTATOR_VSCODE === true;
|
|
9
|
+
}
|