@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.
Files changed (67) hide show
  1. package/HANDOFF.md +317 -29
  2. package/README.md +35 -3
  3. package/components/AnnotationPanel.tsx +159 -107
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +9 -0
  6. package/components/BlockRenderer.tsx +26 -14
  7. package/components/CodeFilePopout.tsx +131 -68
  8. package/components/CommentPopover.tsx +6 -349
  9. package/components/ComposerTokenTextarea.tsx +379 -0
  10. package/components/ExportModal.tsx +6 -1
  11. package/components/HostTitle.tsx +108 -0
  12. package/components/ImageLightbox.tsx +288 -17
  13. package/components/ImagePeek.tsx +255 -0
  14. package/components/ImageResolverContext.tsx +31 -0
  15. package/components/InlineMarkdown.tsx +123 -15
  16. package/components/LocalLinkContext.tsx +49 -0
  17. package/components/MenuVersionSection.tsx +51 -25
  18. package/components/OpenInAppButton.tsx +18 -49
  19. package/components/PlanHeaderMenu.tsx +2 -8
  20. package/components/ProviderIcons.tsx +10 -4
  21. package/components/RenderedMarkdown.tsx +40 -4
  22. package/components/Settings.tsx +33 -5
  23. package/components/SkillReferenceMenu.tsx +28 -6
  24. package/components/Tooltip.tsx +51 -4
  25. package/components/Viewer.tsx +214 -55
  26. package/components/ai/AIProviderBar.tsx +11 -0
  27. package/components/ai/AskSessionSetupHint.tsx +12 -0
  28. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  29. package/components/ai/SessionAskNotice.tsx +4 -3
  30. package/components/blocks/BlockquoteBlock.tsx +159 -0
  31. package/components/blocks/HtmlBlock.tsx +121 -9
  32. package/components/blocks/QuestionBlock.tsx +253 -104
  33. package/components/diagram/DiagramViewer.tsx +41 -4
  34. package/components/diagram/index.ts +1 -1
  35. package/components/html-viewer/HtmlViewer.tsx +73 -3
  36. package/components/html-viewer/bridge-script.asset.js +86 -0
  37. package/components/html-viewer/bridge-script.ts +86 -0
  38. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  39. package/components/icons/AgentIcons.tsx +9 -7
  40. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  41. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  42. package/configure.ts +27 -1
  43. package/hooks/useAgentJobs.ts +8 -3
  44. package/hooks/useAnnotationHighlighter.ts +128 -23
  45. package/hooks/useExternalAnnotations.ts +6 -0
  46. package/hooks/useOpenInApps.ts +88 -0
  47. package/hooks/useUpdateCheck.ts +38 -98
  48. package/hooks/useUpdateCheckSetting.ts +43 -0
  49. package/hooks/useUpdateToast.ts +101 -0
  50. package/package.json +2 -2
  51. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  52. package/shortcuts/index.ts +1 -0
  53. package/styles.css +1 -1
  54. package/types.ts +48 -0
  55. package/utils/agentClosed.ts +26 -0
  56. package/utils/agentToolSetting.ts +16 -9
  57. package/utils/aiProvider.ts +7 -0
  58. package/utils/askSessionSetupHint.ts +27 -0
  59. package/utils/blockTargeting.ts +14 -3
  60. package/utils/parser.ts +65 -51
  61. package/utils/pasteInsert.ts +234 -0
  62. package/utils/planDiffEngine.ts +272 -2
  63. package/utils/skillCatalog.ts +15 -2
  64. package/utils/skillReferences.ts +50 -13
  65. package/utils/storage.ts +5 -0
  66. package/utils/textCut.ts +41 -0
  67. package/utils/updateNotice.ts +120 -0
@@ -7,11 +7,14 @@ import { useFenceTheme } from "../hooks/useFenceTheme";
7
7
  import { transformPlainText } from "../utils/inlineTransforms";
8
8
  import { hasLinkedDocExtension } from "../utils/markdownExtensions";
9
9
  import { getImageSrc } from "./ImageThumbnail";
10
+ import { UNAVAILABLE_IMAGE_CLASS, useImageResolver } from "./ImageResolverContext";
10
11
  import { useCodePathValidation, type CodePathValidationContextValue } from "./CodePathValidationContext";
12
+ import { isLocalLinkTarget, isNewTabClick, useLocalLinkResolver } from "./LocalLinkContext";
11
13
  import type { ValidationEntry } from "../hooks/useValidatedCodePaths";
12
14
  import { CodeFilePicker } from "./CodeFilePicker";
13
15
  import { normalizeMathTex, renderMathToHtml } from "../utils/math";
14
16
  import { useMathRenderer } from "../hooks/useMathRenderer";
17
+ import { HostTitle } from "./HostTitle";
15
18
 
16
19
  export interface DocPreviewResult {
17
20
  contents?: string;
@@ -471,6 +474,35 @@ function emitPlainTextWithBareUrls(
471
474
  }
472
475
  }
473
476
 
477
+ /** An image a host resolver chose; a load failure shows the placeholder. */
478
+ const HostResolvedImage: React.FC<{
479
+ src: string;
480
+ alt: string;
481
+ onImageClick?: (src: string, alt: string) => void;
482
+ }> = ({ src, alt, onImageClick }) => {
483
+ const [failedSrc, setFailedSrc] = useState<string | null>(null);
484
+ if (failedSrc === src) {
485
+ return (
486
+ <span className={UNAVAILABLE_IMAGE_CLASS} title="Image could not be loaded" data-image-unavailable="">
487
+ {alt || 'Image'}
488
+ </span>
489
+ );
490
+ }
491
+ return (
492
+ <img
493
+ src={src}
494
+ alt={alt}
495
+ className="max-w-full rounded my-2 cursor-zoom-in"
496
+ loading="lazy"
497
+ onError={() => setFailedSrc(src)}
498
+ onClick={(e) => {
499
+ e.stopPropagation();
500
+ onImageClick?.(src, alt);
501
+ }}
502
+ />
503
+ );
504
+ };
505
+
474
506
  /**
475
507
  * Scanner that walks a text string and emits React nodes for inline markdown:
476
508
  * emphasis (**bold**, *italic*, _italic_, ***both***), `code`, ~~strikethrough~~,
@@ -508,6 +540,8 @@ export const InlineMarkdown: React.FC<{
508
540
  repoHost?: string;
509
541
  }> = ({ text, onOpenLinkedDoc, resolveLinkedDoc, onOpenCodeFile, onNavigateAnchor, imageBaseDir, onImageClick, githubRepo, repoHost }) => {
510
542
  const validation = useCodePathValidation();
543
+ const resolveLocalLink = useLocalLinkResolver();
544
+ const resolveImage = useImageResolver();
511
545
  const refLinks = forgeRefLinks({ display: githubRepo, host: repoHost });
512
546
  const parts: React.ReactNode[] = [];
513
547
  let remaining = text;
@@ -966,21 +1000,47 @@ export const InlineMarkdown: React.FC<{
966
1000
  if (match) {
967
1001
  const alt = match[1];
968
1002
  const src = match[2];
969
- const imgSrc = /^(https?:\/\/|data:|blob:)/i.test(src)
970
- ? src
971
- : getImageSrc(src, imageBaseDir);
1003
+ // A host's resolver (RenderedMarkdown `resolveImage`) decides first;
1004
+ // null from it, or no resolver, keeps the default resolution.
1005
+ const resolvedImage = resolveImage ? resolveImage(src) : null;
1006
+ if (resolvedImage?.kind === 'unavailable') {
1007
+ parts.push(
1008
+ <span
1009
+ key={key++}
1010
+ className={UNAVAILABLE_IMAGE_CLASS}
1011
+ title={resolvedImage.title}
1012
+ data-image-unavailable=""
1013
+ >
1014
+ {alt || 'Image'}
1015
+ </span>,
1016
+ );
1017
+ remaining = remaining.slice(match[0].length);
1018
+ previousChar = match[0][match[0].length - 1] || previousChar;
1019
+ continue;
1020
+ }
1021
+ const imgSrc = resolvedImage?.kind === 'src'
1022
+ ? resolvedImage.src
1023
+ : /^(https?:\/\/|data:|blob:)/i.test(src)
1024
+ ? src
1025
+ : getImageSrc(src, imageBaseDir);
972
1026
  parts.push(
973
- <img
974
- key={key++}
975
- src={imgSrc}
976
- alt={alt}
977
- className="max-w-full rounded my-2 cursor-zoom-in"
978
- loading="lazy"
979
- onClick={(e) => {
980
- e.stopPropagation();
981
- onImageClick?.(imgSrc, alt);
982
- }}
983
- />,
1027
+ resolveImage ? (
1028
+ // With a host resolver, an image that fails to load becomes the
1029
+ // same placeholder instead of a broken-image icon.
1030
+ <HostResolvedImage key={key++} src={imgSrc} alt={alt} onImageClick={onImageClick} />
1031
+ ) : (
1032
+ <img
1033
+ key={key++}
1034
+ src={imgSrc}
1035
+ alt={alt}
1036
+ className="max-w-full rounded my-2 cursor-zoom-in"
1037
+ loading="lazy"
1038
+ onClick={(e) => {
1039
+ e.stopPropagation();
1040
+ onImageClick?.(imgSrc, alt);
1041
+ }}
1042
+ />
1043
+ ),
984
1044
  );
985
1045
  remaining = remaining.slice(match[0].length);
986
1046
  previousChar = match[0][match[0].length - 1] || previousChar;
@@ -1043,8 +1103,56 @@ export const InlineMarkdown: React.FC<{
1043
1103
  const linkedDocPath = isLocalDoc ? linkUrl.replace(/#.*$/, '') : linkUrl;
1044
1104
  const codeFilePath = isCodeFile ? linkUrl.replace(/#.*$/, '') : linkUrl;
1045
1105
  const isInPageAnchor = safeLinkUrl.startsWith('#');
1106
+ // A host's resolver (Viewer `resolveLocalLink`) decides local paths
1107
+ // first; null from it, or no resolver, keeps the branches below.
1108
+ const localLink = resolveLocalLink && isLocalLinkTarget(linkUrl) ? resolveLocalLink(linkUrl) : null;
1046
1109
 
1047
- if (isInPageAnchor) {
1110
+ if (localLink?.kind === 'open') {
1111
+ const openLink = localLink.open;
1112
+ const hostHref = localLink.href;
1113
+ parts.push(
1114
+ <HostTitle key={key++} title={localLink.title}>
1115
+ <a
1116
+ href={hostHref ?? safeLinkUrl}
1117
+ onClick={(e) => {
1118
+ // With the host's own address, a new-tab click goes there.
1119
+ if (hostHref && isNewTabClick(e)) return;
1120
+ e.preventDefault();
1121
+ e.stopPropagation();
1122
+ openLink();
1123
+ }}
1124
+ // Without it, a middle click would open the path on the host's own origin.
1125
+ onAuxClick={hostHref ? undefined : (e) => e.preventDefault()}
1126
+ className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer"
1127
+ data-local-link="open"
1128
+ >
1129
+ {linkText}
1130
+ <svg
1131
+ className="w-3 h-3 opacity-50 flex-shrink-0"
1132
+ fill="none"
1133
+ viewBox="0 0 24 24"
1134
+ stroke="currentColor"
1135
+ strokeWidth={2}
1136
+ aria-hidden="true"
1137
+ >
1138
+ <path
1139
+ strokeLinecap="round"
1140
+ strokeLinejoin="round"
1141
+ d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
1142
+ />
1143
+ </svg>
1144
+ </a>
1145
+ </HostTitle>,
1146
+ );
1147
+ } else if (localLink?.kind === 'unavailable') {
1148
+ parts.push(
1149
+ <HostTitle key={key++} title={localLink.title}>
1150
+ <span className="text-muted-foreground underline decoration-dotted underline-offset-2" data-local-link="unavailable">
1151
+ {linkText}
1152
+ </span>
1153
+ </HostTitle>,
1154
+ );
1155
+ } else if (isInPageAnchor) {
1048
1156
  parts.push(
1049
1157
  <a
1050
1158
  key={key++}
@@ -0,0 +1,49 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ /**
4
+ * What a host makes of a link to a local path (`[the mock](mock.html)`):
5
+ *
6
+ * - `open`: draw a link (Enter and a click call `open`, the browser never
7
+ * follows the href). `title` is its tooltip. `href`, when given, replaces
8
+ * the link's own target as its href, so "Open in new tab", a modified
9
+ * click (Cmd/Ctrl/Shift, middle button), drag and copy use it: a host's
10
+ * own address for the same thing. Without it, a modified click also calls
11
+ * `open` and the middle button does nothing.
12
+ * - `unavailable`: draw the link text as inert text with `title` as its
13
+ * tooltip: no href, so nothing is fetched or navigated.
14
+ * - `null` from the resolver: the renderer's default for that link.
15
+ */
16
+ export type LocalLinkResolution =
17
+ | { kind: "open"; open: () => void; title?: string; href?: string }
18
+ | { kind: "unavailable"; title: string };
19
+
20
+ /** Called with the link target as written (fragment and query included). */
21
+ export type LocalLinkResolver = (href: string) => LocalLinkResolution | null;
22
+
23
+ /**
24
+ * Provided by `Viewer` from its optional `resolveLocalLink` prop. Absent
25
+ * (null), every link renders exactly as it always has.
26
+ */
27
+ export const LocalLinkContext = createContext<LocalLinkResolver | null>(null);
28
+
29
+ export function useLocalLinkResolver(): LocalLinkResolver | null {
30
+ return useContext(LocalLinkContext);
31
+ }
32
+
33
+ /** A click the browser would open in a new tab or window: Cmd/Ctrl/Shift, or not the main button. */
34
+ export function isNewTabClick(event: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; button: number }): boolean {
35
+ return event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0;
36
+ }
37
+
38
+ /**
39
+ * Whether a link target names a local path a host resolver is asked about:
40
+ * not an in-page `#anchor`, not protocol-relative (`//host`), and no URL
41
+ * scheme (`https:`, `mailto:`, `file:` …). A Windows drive path (`C:\x`,
42
+ * `C:/x`) is a path.
43
+ */
44
+ export function isLocalLinkTarget(href: string): boolean {
45
+ const target = href.trim();
46
+ if (target === "" || target.startsWith("#") || target.startsWith("//")) return false;
47
+ if (/^[a-z]:[\\/]/i.test(target)) return true;
48
+ return !/^[a-z][a-z0-9+.-]*:/i.test(target);
49
+ }
@@ -1,16 +1,15 @@
1
1
  import React, { useState } from 'react';
2
- import { TextShimmer } from './TextShimmer';
3
2
  import type { UpdateInfo } from '../hooks/useUpdateCheck';
4
3
  import type { Origin } from '@plannotator/core/agents';
5
4
  import { isWindows } from '../utils/platform';
6
5
  import { copyTextToClipboard } from '../utils/clipboard';
6
+ import { requestUpdateNow } from '../utils/updateNotice';
7
7
 
8
- const PI_INSTALL_COMMAND = 'pi install npm:@plannotator/pi-extension';
9
-
10
- function getInstallCommand(origin?: Origin | null, isWSL = false): string {
11
- if (origin === 'pi') return PI_INSTALL_COMMAND;
8
+ /** The command when the server sent none (an advert always carries one). */
9
+ function fallbackCommand(origin?: Origin | null, isWSL = false): string {
10
+ if (origin === 'pi') return 'pi update --extensions';
12
11
  return isWindows && !isWSL
13
- ? 'powershell -c "irm https://plannotator.ai/install.ps1 | iex"'
12
+ ? 'irm https://plannotator.ai/install.ps1 | iex'
14
13
  : 'curl -fsSL https://plannotator.ai/install.sh | bash';
15
14
  }
16
15
 
@@ -22,6 +21,12 @@ interface MenuVersionSectionProps {
22
21
  closeMenu: () => void;
23
22
  }
24
23
 
24
+ /**
25
+ * The Options menu's version section: the running version and, when the
26
+ * machine knows of a newer release, "X is available · What's new" with the
27
+ * command for this host (remembered install flags included) and, for the
28
+ * binary the install script manages, Update now.
29
+ */
25
30
  export const MenuVersionSection: React.FC<MenuVersionSectionProps> = ({
26
31
  appVersion,
27
32
  updateInfo,
@@ -30,19 +35,25 @@ export const MenuVersionSection: React.FC<MenuVersionSectionProps> = ({
30
35
  closeMenu,
31
36
  }) => {
32
37
  const [copied, setCopied] = useState(false);
38
+ const [updating, setUpdating] = useState<'idle' | 'starting' | 'started' | string>('idle');
33
39
  const hasUpdate = !!updateInfo?.updateAvailable;
40
+ const command = updateInfo?.command ?? fallbackCommand(origin, isWSL);
34
41
 
35
42
  const handleCopy = async () => {
36
- if (await copyTextToClipboard(getInstallCommand(origin, isWSL))) {
43
+ if (await copyTextToClipboard(command)) {
37
44
  setCopied(true);
38
45
  setTimeout(() => setCopied(false), 2000);
39
- } else {
40
- console.error('Failed to copy');
41
46
  }
42
47
  };
43
48
 
49
+ const handleUpdate = async () => {
50
+ setUpdating('starting');
51
+ const result = await requestUpdateNow();
52
+ setUpdating('error' in result ? result.error : 'started');
53
+ };
54
+
44
55
  return (
45
- <div className="px-3 py-2 space-y-2">
56
+ <div className="px-3 py-2 space-y-2" data-menu-version="">
46
57
  <div className="flex items-center justify-between gap-3">
47
58
  <a
48
59
  href="https://github.com/backnotprop/plannotator"
@@ -59,6 +70,12 @@ export const MenuVersionSection: React.FC<MenuVersionSectionProps> = ({
59
70
  </div>
60
71
  <div className="flex flex-col items-start gap-1 text-[11px]">
61
72
  <span className="flex items-center gap-1.5">
73
+ {hasUpdate && (
74
+ <>
75
+ <span className="font-medium text-foreground">{updateInfo!.latestVersion} is available</span>
76
+ <span className="text-muted-foreground/40">·</span>
77
+ </>
78
+ )}
62
79
  <a
63
80
  href={hasUpdate ? updateInfo!.releaseUrl : 'https://github.com/backnotprop/plannotator/releases'}
64
81
  target="_blank"
@@ -66,24 +83,33 @@ export const MenuVersionSection: React.FC<MenuVersionSectionProps> = ({
66
83
  onClick={closeMenu}
67
84
  className="text-muted-foreground hover:text-foreground transition-colors"
68
85
  >
69
- Release notes
86
+ {hasUpdate ? "What's new" : 'Release notes'}
70
87
  </a>
71
- {hasUpdate && (
72
- <>
73
- <span className="text-muted-foreground/40">·</span>
74
- <TextShimmer className="text-[10px] font-medium" duration={2.5} spread={1.5}>
75
- New update available!
76
- </TextShimmer>
77
- </>
78
- )}
79
88
  </span>
80
89
  {hasUpdate && (
81
- <button
82
- onClick={handleCopy}
83
- className="w-full mt-1 px-2.5 py-1.5 rounded-md text-[11px] font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
84
- >
85
- {copied ? 'Copied!' : 'Copy update command'}
86
- </button>
90
+ <>
91
+ <div className="w-full mt-1 flex items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1">
92
+ <code className="flex-1 min-w-0 overflow-x-auto whitespace-nowrap font-mono text-[10px] text-foreground">{command}</code>
93
+ <button
94
+ onClick={handleCopy}
95
+ className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium border border-border hover:bg-muted transition-colors"
96
+ >
97
+ {copied ? 'Copied' : 'Copy'}
98
+ </button>
99
+ </div>
100
+ {updateInfo!.action === 'script' && (
101
+ <button
102
+ onClick={handleUpdate}
103
+ disabled={updating === 'starting' || updating === 'started'}
104
+ className="w-full mt-1 px-2.5 py-1.5 rounded-md text-[11px] font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-60"
105
+ >
106
+ {updating === 'started' ? `Updating to ${updateInfo!.latestVersion}…` : updating === 'starting' ? 'Starting…' : 'Update now'}
107
+ </button>
108
+ )}
109
+ {updating !== 'idle' && updating !== 'starting' && updating !== 'started' && (
110
+ <span className="text-[10px] text-destructive">{updating}</span>
111
+ )}
112
+ </>
87
113
  )}
88
114
  </div>
89
115
  </div>
@@ -1,9 +1,9 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
2
  import { ChevronDown, Check, Copy, MoreHorizontal } from 'lucide-react';
3
3
  import { AppIcon } from './icons/AppIcon';
4
- import { getLastOpenInApp, setLastOpenInApp } from '../utils/storage';
4
+ import { useOpenInApps, useRememberedOpenInApp } from '../hooks/useOpenInApps';
5
5
  import { copyTextToClipboard } from '../utils/clipboard';
6
- import type { OpenInKind } from '@plannotator/core/open-in-apps';
6
+ import { noteServerSessionMismatch, withServerSession } from '../utils/serverSession';
7
7
  import {
8
8
  DropdownMenu,
9
9
  DropdownMenuTrigger,
@@ -27,13 +27,6 @@ import {
27
27
  * nothing when there is neither an app to open nor a diff to copy.
28
28
  */
29
29
 
30
- interface DetectedApp {
31
- id: string;
32
- label: string;
33
- kind: OpenInKind;
34
- icon: string;
35
- }
36
-
37
30
  interface OpenInAppButtonProps {
38
31
  filePath: string | null | undefined;
39
32
  base?: string | null;
@@ -50,28 +43,9 @@ interface OpenInAppButtonProps {
50
43
  }
51
44
 
52
45
  // The host app catalog is static for the session, but the all-files view
53
- // renders one OpenInAppButton per file — so fetch /api/open-in/apps once and
54
- // share the promise across every instance instead of N identical requests.
55
- interface OpenInAppsResponse {
56
- available: boolean;
57
- apps: DetectedApp[];
58
- }
59
- let openInAppsPromise: Promise<OpenInAppsResponse> | null = null;
60
- function loadOpenInApps(): Promise<OpenInAppsResponse> {
61
- if (!openInAppsPromise) {
62
- openInAppsPromise = fetch('/api/open-in/apps')
63
- .then((r) => r.json())
64
- .then((data: OpenInAppsResponse) => ({
65
- available: !!data.available,
66
- apps: Array.isArray(data.apps) ? data.apps : [],
67
- }))
68
- .catch(() => {
69
- openInAppsPromise = null; // don't memoize failure — let the next mount retry
70
- return { available: false, apps: [] };
71
- });
72
- }
73
- return openInAppsPromise;
74
- }
46
+ // renders one OpenInAppButton per file — so /api/open-in/apps is fetched once
47
+ // and the promise shared across every instance (useOpenInApps).
48
+ const OPEN_IN_APPS_URL = '/api/open-in/apps';
75
49
 
76
50
  export const OpenInAppButton: React.FC<OpenInAppButtonProps> = ({
77
51
  filePath,
@@ -81,26 +55,17 @@ export const OpenInAppButton: React.FC<OpenInAppButtonProps> = ({
81
55
  showLabel = false,
82
56
  disabled = false,
83
57
  }) => {
84
- const [apps, setApps] = useState<DetectedApp[] | null>(null);
85
- const [available, setAvailable] = useState<boolean | null>(null);
86
- const [currentId, setCurrentId] = useState<string>(() => getLastOpenInApp());
58
+ const detected = useOpenInApps(OPEN_IN_APPS_URL);
59
+ const apps = detected?.apps ?? null;
60
+ const available = detected ? detected.available : null;
61
+ const [storedId, rememberApp] = useRememberedOpenInApp();
62
+ // Nothing picked yet: the file manager, as before.
63
+ const currentId = storedId ?? 'reveal';
87
64
  const [busy, setBusy] = useState(false);
88
65
  const [error, setError] = useState<string | null>(null);
89
66
  const [menuOpen, setMenuOpen] = useState(false);
90
67
  const errorTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
91
68
 
92
- useEffect(() => {
93
- let cancelled = false;
94
- loadOpenInApps().then((data) => {
95
- if (cancelled) return;
96
- setAvailable(data.available);
97
- setApps(data.apps);
98
- });
99
- return () => {
100
- cancelled = true;
101
- };
102
- }, []);
103
-
104
69
  useEffect(() => {
105
70
  return () => {
106
71
  if (errorTimer.current) clearTimeout(errorTimer.current);
@@ -141,8 +106,13 @@ export const OpenInAppButton: React.FC<OpenInAppButtonProps> = ({
141
106
  const res = await fetch('/api/open-in', {
142
107
  method: 'POST',
143
108
  headers: { 'Content-Type': 'application/json' },
144
- body: JSON.stringify({ filePath, base: base ?? null, appId }),
109
+ // The stale-tab guard: a server that took over this port refuses it.
110
+ body: JSON.stringify(withServerSession({ filePath, base: base ?? null, appId })),
145
111
  });
112
+ if (await noteServerSessionMismatch(res)) {
113
+ flashError('This review was replaced. Reload the page.');
114
+ return;
115
+ }
146
116
  const data = (await res.json().catch(() => null)) as
147
117
  | { ok: boolean; error?: string }
148
118
  | null;
@@ -157,8 +127,7 @@ export const OpenInAppButton: React.FC<OpenInAppButtonProps> = ({
157
127
  };
158
128
 
159
129
  const selectApp = (appId: string) => {
160
- setLastOpenInApp(appId);
161
- setCurrentId(appId);
130
+ rememberApp(appId);
162
131
  setMenuOpen(false);
163
132
  void open(appId);
164
133
  };
@@ -9,7 +9,6 @@ import { useTheme } from './ThemeProvider';
9
9
  import { THEME_MODES } from './themeModes';
10
10
  import { ReviewAgentsIcon } from './ReviewAgentsIcon';
11
11
  import { MenuVersionSection } from './MenuVersionSection';
12
- import { TextShimmer } from './TextShimmer';
13
12
  import type { UpdateInfo } from '../hooks/useUpdateCheck';
14
13
  import type { Origin } from '@plannotator/core/agents';
15
14
 
@@ -105,13 +104,8 @@ export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
105
104
  aria-expanded={isOpen}
106
105
  >
107
106
  {isOpen ? <CloseIcon /> : <MenuIcon />}
108
- {showUpdateDot ? (
109
- <TextShimmer className="hidden md:inline text-xs font-medium" duration={2.5} spread={1.5}>
110
- Options
111
- </TextShimmer>
112
- ) : (
113
- <span className="hidden md:inline">Options</span>
114
- )}
107
+ {/* The dot says an update is out; the label never moves (no shimmer). */}
108
+ <span className="hidden md:inline">Options</span>
115
109
  {showUpdateDot && (
116
110
  <span className="absolute top-0.5 right-0.5 md:-top-0.5 md:-right-0.5 w-2 h-2 rounded-full bg-primary ring-2 ring-background" />
117
111
  )}
@@ -14,11 +14,17 @@ export const CodexIcon: React.FC<{ className?: string }> = ({ className = 'w-4 h
14
14
  <img src={codexPng} alt="" className={`${className} rounded-sm`} />
15
15
  );
16
16
 
17
- /** Pi icon — extracted from apps/marketing/public/assets/icon-pi.svg */
17
+ /**
18
+ * Pi icon: the pi.dev logo (https://pi.dev/logo.svg), the same file as
19
+ * apps/marketing/public/assets/icon-pi.svg. It keeps its own three colours in
20
+ * every theme; the viewBox crops pi.dev's 800-unit canvas to the mark plus an
21
+ * even margin so it sits level with the other marks.
22
+ */
18
23
  export const PiIcon: React.FC<{ className?: string }> = ({ className = 'w-4 h-4' }) => (
19
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" className={className}>
20
- <path fill="currentColor" fillRule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"/>
21
- <path fill="currentColor" d="M517.36 400H634.72V634.72H517.36Z"/>
24
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="130 130 540 540" className={className}>
25
+ <path fill="#F09082" d="M165.29 165.29H517.36V400H400V282.65H165.29Z"/>
26
+ <path fill="#4D9ABF" d="M165.29 282.65H282.65V400H400V517.36H282.65V634.72H165.29Z"/>
27
+ <path fill="#F1BE58" d="M517.36 400H634.72V634.72H517.36Z"/>
22
28
  </svg>
23
29
  );
24
30
 
@@ -1,6 +1,9 @@
1
1
  import React, { useMemo } from 'react';
2
2
  import { parseMarkdownToBlocks, groupBlocks, computeListIndices } from '../utils/parser';
3
3
  import { BlockRenderer } from './BlockRenderer';
4
+ import { LocalLinkContext, type LocalLinkResolver } from './LocalLinkContext';
5
+ import { ImageResolverContext, type ImageResolver } from './ImageResolverContext';
6
+ import { buildHeadingSlugMap } from '../utils/slugify';
4
7
 
5
8
  /**
6
9
  * Full markdown renderer for surfaces outside the plan editor (e.g. the PR
@@ -19,6 +22,22 @@ export interface RenderedMarkdownProps {
19
22
  onOpenCodeFile?: (path: string) => void;
20
23
  githubRepo?: string;
21
24
  repoHost?: string;
25
+ /**
26
+ * Optional: decides what a link to a local path does (open it in the host,
27
+ * or render it inert). Same contract as Viewer's `resolveLocalLink`.
28
+ * Absent, links render exactly as before.
29
+ */
30
+ resolveLocalLink?: LocalLinkResolver;
31
+ /**
32
+ * Optional: decides where an image loads from, or that it is not shown.
33
+ * Absent, images resolve exactly as before.
34
+ */
35
+ resolveImage?: ImageResolver;
36
+ /**
37
+ * Optional: give headings slug ids (Viewer's slugger, GitHub dedup), so a
38
+ * host can scroll to `#section` links. Absent/false, headings carry no id.
39
+ */
40
+ headingAnchors?: boolean;
22
41
  }
23
42
 
24
43
  export const RenderedMarkdown: React.FC<RenderedMarkdownProps> = ({
@@ -29,11 +48,19 @@ export const RenderedMarkdown: React.FC<RenderedMarkdownProps> = ({
29
48
  onOpenCodeFile,
30
49
  githubRepo,
31
50
  repoHost,
51
+ resolveLocalLink,
52
+ resolveImage,
53
+ headingAnchors = false,
32
54
  }) => {
33
- const groups = useMemo(() => groupBlocks(parseMarkdownToBlocks(markdown)), [markdown]);
55
+ const blocks = useMemo(() => parseMarkdownToBlocks(markdown), [markdown]);
56
+ const groups = useMemo(() => groupBlocks(blocks), [blocks]);
57
+ const headingSlugMap = useMemo(
58
+ () => (headingAnchors ? buildHeadingSlugMap(blocks) : null),
59
+ [blocks, headingAnchors],
60
+ );
34
61
  const cb = { onImageClick, onOpenLinkedDoc, onOpenCodeFile, githubRepo, repoHost };
35
62
 
36
- return (
63
+ let body = (
37
64
  <div className={className}>
38
65
  {groups.map((group) =>
39
66
  group.type === 'list-group' ? (
@@ -42,15 +69,24 @@ export const RenderedMarkdown: React.FC<RenderedMarkdownProps> = ({
42
69
  return (
43
70
  <div key={group.key}>
44
71
  {group.blocks.map((block, i) => (
45
- <BlockRenderer key={block.id} block={block} orderedIndex={indices[i]} {...cb} />
72
+ <BlockRenderer key={block.id} block={block} orderedIndex={indices[i]} headingAnchorId={headingSlugMap?.get(block.id)} {...cb} />
46
73
  ))}
47
74
  </div>
48
75
  );
49
76
  })()
50
77
  ) : (
51
- <BlockRenderer key={group.block.id} block={group.block} {...cb} />
78
+ <BlockRenderer key={group.block.id} block={group.block} headingAnchorId={headingSlugMap?.get(group.block.id)} {...cb} />
52
79
  ),
53
80
  )}
54
81
  </div>
55
82
  );
83
+ // Provide only what the caller passed, so an enclosing provider still
84
+ // applies when a prop is absent.
85
+ if (resolveImage) {
86
+ body = <ImageResolverContext.Provider value={resolveImage}>{body}</ImageResolverContext.Provider>;
87
+ }
88
+ if (resolveLocalLink) {
89
+ body = <LocalLinkContext.Provider value={resolveLocalLink}>{body}</LocalLinkContext.Provider>;
90
+ }
91
+ return body;
56
92
  };