@plannotator/ui 0.52.1 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HANDOFF.md +231 -27
- package/README.md +29 -4
- package/components/AnnotationPanel.tsx +159 -107
- package/components/AnnotationToolbar.tsx +17 -14
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/BlockRenderer.tsx +20 -14
- package/components/CodeFilePopout.tsx +131 -68
- package/components/CommentPopover.tsx +6 -349
- package/components/ComposerTokenTextarea.tsx +379 -0
- package/components/ExportModal.tsx +6 -1
- package/components/HostTitle.tsx +108 -0
- package/components/ImageLightbox.tsx +288 -17
- package/components/ImagePeek.tsx +255 -0
- package/components/ImageResolverContext.tsx +31 -0
- package/components/InlineMarkdown.tsx +123 -15
- package/components/LocalLinkContext.tsx +49 -0
- package/components/MenuVersionSection.tsx +51 -25
- package/components/OpenInAppButton.tsx +18 -49
- package/components/PlanHeaderMenu.tsx +2 -8
- package/components/ProviderIcons.tsx +10 -4
- package/components/RenderedMarkdown.tsx +40 -4
- package/components/Settings.tsx +33 -5
- package/components/SkillReferenceMenu.tsx +28 -6
- package/components/Tooltip.tsx +51 -4
- package/components/Viewer.tsx +189 -48
- 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/diagram/DiagramViewer.tsx +41 -4
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +73 -3
- package/components/html-viewer/bridge-script.asset.js +86 -0
- package/components/html-viewer/bridge-script.ts +86 -0
- package/components/html-viewer/useHtmlAnnotation.ts +51 -3
- package/components/icons/AgentIcons.tsx +9 -7
- package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
- package/components/plan-diff/PlanDiffViewer.tsx +6 -1
- package/configure.ts +27 -1
- package/hooks/useAgentJobs.ts +8 -3
- package/hooks/useAnnotationHighlighter.ts +128 -23
- package/hooks/useExternalAnnotations.ts +6 -0
- package/hooks/useOpenInApps.ts +88 -0
- package/hooks/useUpdateCheck.ts +38 -98
- package/hooks/useUpdateCheckSetting.ts +43 -0
- package/hooks/useUpdateToast.ts +101 -0
- package/package.json +2 -2
- package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
- package/shortcuts/index.ts +1 -0
- package/styles.css +1 -1
- package/types.ts +48 -0
- package/utils/agentClosed.ts +26 -0
- package/utils/agentToolSetting.ts +16 -9
- package/utils/aiProvider.ts +7 -0
- package/utils/askSessionSetupHint.ts +27 -0
- package/utils/blockTargeting.ts +14 -3
- package/utils/parser.ts +65 -51
- package/utils/pasteInsert.ts +234 -0
- package/utils/planDiffEngine.ts +272 -2
- package/utils/skillCatalog.ts +15 -2
- package/utils/skillReferences.ts +50 -13
- package/utils/storage.ts +5 -0
- package/utils/textCut.ts +41 -0
- package/utils/updateNotice.ts +120 -0
|
@@ -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
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
alt={alt}
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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 (
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
? '
|
|
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(
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 {
|
|
4
|
+
import { useOpenInApps, useRememberedOpenInApp } from '../hooks/useOpenInApps';
|
|
5
5
|
import { copyTextToClipboard } from '../utils/clipboard';
|
|
6
|
-
import
|
|
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
|
|
54
|
-
//
|
|
55
|
-
|
|
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
|
|
85
|
-
const
|
|
86
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
109
|
-
|
|
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
|
-
/**
|
|
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="
|
|
20
|
-
<path fill="
|
|
21
|
-
<path fill="
|
|
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
|
|
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
|
-
|
|
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
|
};
|