proto-plugin 0.1.9 → 0.1.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proto-plugin",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org"
@@ -3,16 +3,25 @@
3
3
  import { Button } from "@prototype/components/ui/button";
4
4
  import { buildUpgradeProtoPluginCopyText } from "@prototype/lib/prototypes/upgrade-proto-plugin-prompt";
5
5
  import { useProtoPluginVersionCheck } from "@prototype/lib/prototypes/use-proto-plugin-version-check";
6
+ import { useActivePrototypeToolTheme } from "@prototype/lib/prototypes/use-prototype-tool-theme";
6
7
  import useCopyToClipboard from "@prototype/lib/use-copy-to-clipboard";
7
8
  import { cn } from "@prototype/lib/utils";
8
9
  import { X } from "lucide-react";
10
+ import { useEffect, useState } from "react";
11
+ import { createPortal } from "react-dom";
9
12
  import { toast } from "sonner";
10
13
 
11
14
  export function PrototypePluginUpdatePopup() {
12
15
  const { status, shouldShowPopup, dismiss } = useProtoPluginVersionCheck();
13
16
  const { copy, icon, isCopied } = useCopyToClipboard();
17
+ const theme = useActivePrototypeToolTheme();
18
+ const [mounted, setMounted] = useState(false);
14
19
 
15
- if (!shouldShowPopup || !status?.installed || !status.latest) {
20
+ useEffect(() => {
21
+ setMounted(true);
22
+ }, []);
23
+
24
+ if (!shouldShowPopup || !status?.installed || !status.latest || !mounted) {
16
25
  return null;
17
26
  }
18
27
 
@@ -31,40 +40,47 @@ export function PrototypePluginUpdatePopup() {
31
40
  }
32
41
  };
33
42
 
34
- return (
43
+ return createPortal(
35
44
  <div
36
- role="status"
37
- aria-live="polite"
38
- className={cn(
39
- "pointer-events-auto fixed bottom-4 left-4 z-[1050] relative isolate flex w-fit max-w-[min(18rem,calc(100vw-2rem))] flex-col gap-2 rounded-lg bg-[var(--tool-chrome-surface)] p-2.5 pr-7 shadow-[var(--tool-review-card-shadow)] ring-[0.5px] ring-[var(--tool-chrome-border)]",
40
- )}
45
+ data-prototype-root
46
+ data-prototype-comment-theme={theme}
47
+ className="pointer-events-none fixed bottom-4 right-4 z-[1050] !bg-transparent"
41
48
  >
42
- <button
43
- type="button"
44
- aria-label="Dismiss update notification"
45
- className="absolute right-1.5 top-1.5 rounded p-0.5 text-[var(--tool-chrome-text-muted)] transition-colors duration-200 ease hover:text-[var(--tool-chrome-text-heading)]"
46
- onClick={dismiss}
49
+ <div
50
+ role="status"
51
+ aria-live="polite"
52
+ className={cn(
53
+ "pointer-events-auto relative flex w-[15rem] flex-col gap-3 rounded-lg bg-[var(--tool-chrome-surface)] p-4 shadow-md ring-[0.5px] ring-[var(--tool-chrome-border)]",
54
+ )}
47
55
  >
48
- <X size={12} />
49
- </button>
56
+ <button
57
+ type="button"
58
+ aria-label="Dismiss update notification"
59
+ className="absolute right-2 top-2 rounded p-1 text-[var(--tool-chrome-text-muted)] transition-colors duration-200 ease hover:text-[var(--tool-chrome-text-heading)]"
60
+ onClick={dismiss}
61
+ >
62
+ <X size={12} />
63
+ </button>
50
64
 
51
- <p className="text-xs leading-relaxed text-[var(--tool-chrome-text-muted)]">
52
- A new version of Proto is available, copy the prompt below into your agent to update
53
- </p>
65
+ <p className="pr-4 text-xs leading-relaxed text-[var(--tool-chrome-text-muted)]">
66
+ A new version of Proto is available, copy the prompt below into your agent to update
67
+ </p>
54
68
 
55
- <Button
56
- type="button"
57
- variant="chrome"
58
- size="sm"
59
- className="h-7 w-fit cursor-pointer gap-1 px-2 text-[11px]"
60
- onClick={() => {
61
- void handleCopyPrompt();
62
- }}
63
- aria-label="Copy update prompt"
64
- >
65
- {isCopied ? "Copied update prompt" : "Copy update prompt"}
66
- {icon}
67
- </Button>
68
- </div>
69
+ <Button
70
+ type="button"
71
+ variant="chrome"
72
+ size="sm"
73
+ className="h-7 w-fit cursor-pointer gap-1 px-2 text-[11px]"
74
+ onClick={() => {
75
+ void handleCopyPrompt();
76
+ }}
77
+ aria-label="Copy update prompt"
78
+ >
79
+ {isCopied ? "Copied update prompt" : "Copy update prompt"}
80
+ {icon}
81
+ </Button>
82
+ </div>
83
+ </div>,
84
+ document.body,
69
85
  );
70
86
  }
@@ -0,0 +1,18 @@
1
+ export function isNewerProtoPluginVersion(latest: string, installed: string): boolean {
2
+ const latestParts = parseSemver(latest);
3
+ const installedParts = parseSemver(installed);
4
+ if (!latestParts || !installedParts) return false;
5
+
6
+ for (let index = 0; index < 3; index += 1) {
7
+ if (latestParts[index]! > installedParts[index]!) return true;
8
+ if (latestParts[index]! < installedParts[index]!) return false;
9
+ }
10
+
11
+ return false;
12
+ }
13
+
14
+ function parseSemver(version: string): [number, number, number] | null {
15
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
16
+ if (!match) return null;
17
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
18
+ }
@@ -1,3 +1,5 @@
1
+ import { isNewerProtoPluginVersion } from "@prototype/lib/prototypes/proto-plugin-semver";
2
+
1
3
  export type ProtoPluginVersionStatus = {
2
4
  installed: string | null;
3
5
  latest: string | null;
@@ -8,12 +10,73 @@ export type ProtoPluginVersionStatus = {
8
10
 
9
11
  export const PROTOTYPE_PLUGIN_VERSION_PATH = "/api/proto-plugin/version";
10
12
 
13
+ const NPM_LATEST_URL = "https://registry.npmjs.org/proto-plugin/latest";
14
+
15
+ type NpmLatestResponse = {
16
+ version?: string;
17
+ repository?: { url?: string };
18
+ };
19
+
20
+ function normalizeRepositoryUrl(url: string | undefined): string | undefined {
21
+ return url?.replace(/^git\+/, "").replace(/\.git$/, "");
22
+ }
23
+
24
+ async function fetchLatestFromNpmRegistry(): Promise<{
25
+ latest: string | null;
26
+ repositoryUrl?: string;
27
+ }> {
28
+ try {
29
+ const response = await fetch(NPM_LATEST_URL, { cache: "no-store" });
30
+ if (!response.ok) {
31
+ return { latest: null };
32
+ }
33
+
34
+ const data = (await response.json()) as NpmLatestResponse;
35
+ return {
36
+ latest: data.version ?? null,
37
+ repositoryUrl: normalizeRepositoryUrl(data.repository?.url),
38
+ };
39
+ } catch {
40
+ return { latest: null };
41
+ }
42
+ }
43
+
44
+ /** Client-side npm check when the server route is stale (older plugin releases cached npm for 1h). */
45
+ async function reconcileWithNpmRegistry(
46
+ status: ProtoPluginVersionStatus,
47
+ ): Promise<ProtoPluginVersionStatus> {
48
+ if (status.isWorkspaceLink || !status.installed) {
49
+ return status;
50
+ }
51
+
52
+ if (
53
+ status.updateAvailable &&
54
+ status.latest &&
55
+ isNewerProtoPluginVersion(status.latest, status.installed)
56
+ ) {
57
+ return status;
58
+ }
59
+
60
+ const { latest, repositoryUrl } = await fetchLatestFromNpmRegistry();
61
+ if (!latest || !isNewerProtoPluginVersion(latest, status.installed)) {
62
+ return status;
63
+ }
64
+
65
+ return {
66
+ ...status,
67
+ latest,
68
+ updateAvailable: true,
69
+ repositoryUrl: repositoryUrl ?? status.repositoryUrl,
70
+ };
71
+ }
72
+
11
73
  export async function fetchProtoPluginVersionStatus(): Promise<ProtoPluginVersionStatus> {
12
- const response = await fetch(PROTOTYPE_PLUGIN_VERSION_PATH);
74
+ const response = await fetch(PROTOTYPE_PLUGIN_VERSION_PATH, { cache: "no-store" });
13
75
 
14
76
  if (!response.ok) {
15
77
  throw new Error("Failed to check proto-plugin version.");
16
78
  }
17
79
 
18
- return (await response.json()) as ProtoPluginVersionStatus;
80
+ const status = (await response.json()) as ProtoPluginVersionStatus;
81
+ return reconcileWithNpmRegistry(status);
19
82
  }
@@ -7,7 +7,9 @@ import {
7
7
  createContext,
8
8
  useCallback,
9
9
  useContext,
10
+ useEffect,
10
11
  useMemo,
12
+ useState,
11
13
  type ReactNode,
12
14
  } from "react";
13
15
 
@@ -113,3 +115,37 @@ export function usePrototypeToolTheme(): PrototypeToolThemeContextValue {
113
115
 
114
116
  return context;
115
117
  }
118
+
119
+ function readActivePrototypeToolTheme(): PrototypeToolTheme {
120
+ if (typeof document === "undefined") return DEFAULT_THEME;
121
+
122
+ const roots = document.querySelectorAll<HTMLElement>("[data-prototype-root]");
123
+ const activeRoot = roots[roots.length - 1] ?? roots[0];
124
+ return normalizeTheme(activeRoot?.getAttribute("data-prototype-comment-theme") ?? DEFAULT_THEME);
125
+ }
126
+
127
+ /** Syncs with the innermost tool theme root (e.g. portaled UI outside nested providers). */
128
+ export function useActivePrototypeToolTheme(): PrototypeToolTheme {
129
+ const { theme: contextTheme } = usePrototypeToolTheme();
130
+ const [theme, setTheme] = useState<PrototypeToolTheme>(contextTheme);
131
+
132
+ useEffect(() => {
133
+ const syncTheme = () => {
134
+ setTheme(readActivePrototypeToolTheme());
135
+ };
136
+
137
+ syncTheme();
138
+
139
+ const observer = new MutationObserver(syncTheme);
140
+ for (const root of document.querySelectorAll("[data-prototype-root]")) {
141
+ observer.observe(root, {
142
+ attributes: true,
143
+ attributeFilter: ["data-prototype-comment-theme"],
144
+ });
145
+ }
146
+
147
+ return () => observer.disconnect();
148
+ }, []);
149
+
150
+ return theme;
151
+ }
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "fs";
2
2
  import { join } from "path";
3
3
 
4
+ import { isNewerProtoPluginVersion } from "@prototype/lib/prototypes/proto-plugin-semver";
4
5
  import { NextResponse } from "next/server";
5
6
 
6
7
  const PACKAGE_NAME = "proto-plugin";
@@ -19,25 +20,6 @@ type NpmLatestResponse = {
19
20
  repository?: { url?: string };
20
21
  };
21
22
 
22
- function parseSemver(version: string): [number, number, number] | null {
23
- const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
24
- if (!match) return null;
25
- return [Number(match[1]), Number(match[2]), Number(match[3])];
26
- }
27
-
28
- export function isNewerVersion(latest: string, installed: string): boolean {
29
- const latestParts = parseSemver(latest);
30
- const installedParts = parseSemver(installed);
31
- if (!latestParts || !installedParts) return false;
32
-
33
- for (let index = 0; index < 3; index += 1) {
34
- if (latestParts[index]! > installedParts[index]!) return true;
35
- if (latestParts[index]! < installedParts[index]!) return false;
36
- }
37
-
38
- return false;
39
- }
40
-
41
23
  function readIsWorkspaceDependency(cwd: string): boolean {
42
24
  try {
43
25
  const hostPkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) as {
@@ -110,7 +92,9 @@ export async function GET() {
110
92
  const { version: latest, repositoryUrl } = await fetchLatestFromNpm();
111
93
 
112
94
  const updateAvailable =
113
- installed != null && latest != null && isNewerVersion(latest, installed);
95
+ installed != null &&
96
+ latest != null &&
97
+ isNewerProtoPluginVersion(latest, installed);
114
98
 
115
99
  const status: ProtoPluginVersionStatus = {
116
100
  installed,