@plannotator/ui 0.52.1 → 0.53.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HANDOFF.md +284 -29
- package/README.md +45 -5
- package/components/AnnotationPanel.tsx +195 -118
- package/components/AnnotationToolbar.tsx +17 -14
- package/components/AnnotationToolstrip.tsx +54 -40
- package/components/BlockRenderer.tsx +20 -14
- package/components/CodeFilePopout.tsx +132 -69
- package/components/CommentPopover.tsx +8 -349
- package/components/ComposerTokenTextarea.tsx +379 -0
- package/components/ExportModal.tsx +6 -1
- package/components/HostTitle.tsx +108 -0
- package/components/HtmlSurfaceControls.tsx +2 -1
- 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/MentionPicker.tsx +51 -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 +67 -18
- package/components/Tooltip.tsx +51 -4
- package/components/Viewer.tsx +220 -49
- 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 +183 -96
- package/components/blocks/QuestionContext.tsx +3 -0
- package/components/diagram/DiagramViewer.tsx +41 -4
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +95 -3
- package/components/html-viewer/bridge-script.asset.js +413 -7
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +454 -7
- 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/useMentionAutocomplete.ts +1 -1
- package/hooks/useOpenInApps.ts +88 -0
- package/hooks/useSkillReferenceAutocomplete.ts +7 -3
- package/hooks/useUpdateCheck.ts +38 -98
- package/hooks/useUpdateCheckSetting.ts +43 -0
- package/hooks/useUpdateToast.ts +101 -0
- package/hooks/useVimSelection.ts +9 -0
- package/package.json +2 -2
- package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
- package/shortcuts/index.ts +1 -0
- package/shortcuts/runtime.ts +10 -3
- 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/mentions.ts +34 -5
- 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/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
|
+
}
|