@plannotator/ui 0.52.1 → 0.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/HANDOFF.md +284 -29
  2. package/README.md +45 -5
  3. package/components/AnnotationPanel.tsx +195 -118
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +54 -40
  6. package/components/BlockRenderer.tsx +20 -14
  7. package/components/CodeFilePopout.tsx +132 -69
  8. package/components/CommentPopover.tsx +8 -349
  9. package/components/ComposerTokenTextarea.tsx +379 -0
  10. package/components/ExportModal.tsx +6 -1
  11. package/components/HostTitle.tsx +108 -0
  12. package/components/HtmlSurfaceControls.tsx +2 -1
  13. package/components/ImageLightbox.tsx +288 -17
  14. package/components/ImagePeek.tsx +255 -0
  15. package/components/ImageResolverContext.tsx +31 -0
  16. package/components/InlineMarkdown.tsx +123 -15
  17. package/components/LocalLinkContext.tsx +49 -0
  18. package/components/MentionPicker.tsx +51 -0
  19. package/components/MenuVersionSection.tsx +51 -25
  20. package/components/OpenInAppButton.tsx +18 -49
  21. package/components/PlanHeaderMenu.tsx +2 -8
  22. package/components/ProviderIcons.tsx +10 -4
  23. package/components/RenderedMarkdown.tsx +40 -4
  24. package/components/Settings.tsx +33 -5
  25. package/components/SkillReferenceMenu.tsx +67 -18
  26. package/components/Tooltip.tsx +51 -4
  27. package/components/Viewer.tsx +220 -49
  28. package/components/ai/AIProviderBar.tsx +11 -0
  29. package/components/ai/AskSessionSetupHint.tsx +12 -0
  30. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  31. package/components/ai/SessionAskNotice.tsx +4 -3
  32. package/components/blocks/BlockquoteBlock.tsx +159 -0
  33. package/components/blocks/HtmlBlock.tsx +121 -9
  34. package/components/blocks/QuestionBlock.tsx +183 -96
  35. package/components/blocks/QuestionContext.tsx +3 -0
  36. package/components/diagram/DiagramViewer.tsx +41 -4
  37. package/components/diagram/index.ts +1 -1
  38. package/components/html-viewer/HtmlViewer.tsx +95 -3
  39. package/components/html-viewer/bridge-script.asset.js +413 -7
  40. package/components/html-viewer/bridge-script.lite.ts +1 -1
  41. package/components/html-viewer/bridge-script.ts +454 -7
  42. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  43. package/components/icons/AgentIcons.tsx +9 -7
  44. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  45. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  46. package/configure.ts +27 -1
  47. package/hooks/useAgentJobs.ts +8 -3
  48. package/hooks/useAnnotationHighlighter.ts +128 -23
  49. package/hooks/useExternalAnnotations.ts +6 -0
  50. package/hooks/useMentionAutocomplete.ts +1 -1
  51. package/hooks/useOpenInApps.ts +88 -0
  52. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  53. package/hooks/useUpdateCheck.ts +38 -98
  54. package/hooks/useUpdateCheckSetting.ts +43 -0
  55. package/hooks/useUpdateToast.ts +101 -0
  56. package/hooks/useVimSelection.ts +9 -0
  57. package/package.json +2 -2
  58. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  59. package/shortcuts/index.ts +1 -0
  60. package/shortcuts/runtime.ts +10 -3
  61. package/styles.css +1 -1
  62. package/types.ts +48 -0
  63. package/utils/agentClosed.ts +26 -0
  64. package/utils/agentToolSetting.ts +16 -9
  65. package/utils/aiProvider.ts +7 -0
  66. package/utils/askSessionSetupHint.ts +27 -0
  67. package/utils/blockTargeting.ts +14 -3
  68. package/utils/mentions.ts +34 -5
  69. package/utils/parser.ts +65 -51
  70. package/utils/pasteInsert.ts +234 -0
  71. package/utils/planDiffEngine.ts +272 -2
  72. package/utils/skillCatalog.ts +15 -2
  73. package/utils/skillReferences.ts +50 -13
  74. package/utils/storage.ts +5 -0
  75. package/utils/textCut.ts +41 -0
  76. package/utils/updateNotice.ts +120 -0
@@ -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
+ }