diff --git a/bun.lock b/bun.lock index 0ec6689ea..61d9b8d4a 100644 --- a/bun.lock +++ b/bun.lock @@ -64,7 +64,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.19.21", + "version": "0.19.22", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -86,7 +86,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.19.21", + "version": "0.19.22", "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", "@pierre/diffs": "^1.1.12", @@ -201,7 +201,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.19.21", + "version": "0.19.22", "dependencies": { "@pierre/diffs": "^1.1.12", "@plannotator/ai": "workspace:*", @@ -237,6 +237,7 @@ "lucide-react": "^1.14.0", "marked": "^17.0.6", "mermaid": "^11.12.2", + "motion": "^12.38.0", "overlayscrollbars": "^2.11.0", "overlayscrollbars-react": "^0.5.6", "perfect-freehand": "^1.2.2", diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index a1ce4137e..8aefd8374 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -24,7 +24,7 @@ import { useActiveSection } from '@plannotator/ui/hooks/useActiveSection'; import { storage } from '@plannotator/ui/utils/storage'; import { configStore } from '@plannotator/ui/config'; import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; -import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner'; +import { useUpdateCheck } from '@plannotator/ui/hooks/useUpdateCheck'; import { PlanAIAnnouncementDialog } from '@plannotator/ui/components/PlanAIAnnouncementDialog'; import { getObsidianSettings, getEffectiveVaultPath, isObsidianConfigured, CUSTOM_PATH_SENTINEL } from '@plannotator/ui/utils/obsidian'; import { getBearSettings } from '@plannotator/ui/utils/bear'; @@ -153,6 +153,7 @@ const App: React.FC = () => { const [origin, setOrigin] = useState(null); const [gitUser, setGitUser] = useState(); const [isWSL, setIsWSL] = useState(false); + const updateInfo = useUpdateCheck(); const [globalAttachments, setGlobalAttachments] = useState([]); const [annotateMode, setAnnotateMode] = useState(false); const [gate, setGate] = useState(false); @@ -1993,6 +1994,8 @@ const App: React.FC = () => { onSaveToBear={handleSaveToBear} onSaveToOctarine={handleSaveToOctarine} appVersion={typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'} + updateInfo={updateInfo} + isWSL={isWSL} agentInstructionsEnabled={isApiMode && !archive.archiveMode && !annotateMode && !goalSetupMode} obsidianConfigured={isObsidianConfigured()} bearConfigured={getBearSettings().enabled} @@ -2519,9 +2522,6 @@ const App: React.FC = () => { agentLabel={agentName} /> - {/* Update notification */} - - (({ onSaveToBear, onSaveToOctarine, appVersion, + updateInfo, + isWSL, agentInstructionsEnabled, obsidianConfigured, bearConfigured, @@ -321,6 +326,9 @@ export const AppHeader = React.memo(({ { const mrNumberLabel = prMetadata ? getMRNumberLabel(prMetadata) : ''; const displayRepo = prMetadata ? getDisplayRepo(prMetadata) : ''; const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'; + const updateInfo = useUpdateCheck(); const identity = useConfigValue('displayName'); @@ -2079,6 +2080,9 @@ const ReviewApp: React.FC = () => { isFileTreeOpen={isFileTreeOpen} isSidebarOpen={reviewSidebar.isOpen} appVersion={appVersion} + updateInfo={updateInfo} + origin={origin} + isWSL={isWSL} />
@@ -2467,9 +2471,6 @@ const ReviewApp: React.FC = () => { agentLabel={getAgentName(origin)} /> - {/* Update notification */} - - {/* GitHub general comment dialog */} void; @@ -16,6 +19,9 @@ interface ReviewHeaderMenuProps { isFileTreeOpen: boolean; isSidebarOpen: boolean; appVersion: string; + updateInfo?: UpdateInfo | null; + origin?: Origin | null; + isWSL?: boolean; } export const ReviewHeaderMenu: React.FC = ({ @@ -26,18 +32,26 @@ export const ReviewHeaderMenu: React.FC = ({ isFileTreeOpen, isSidebarOpen, appVersion, + updateInfo, + origin, + isWSL = false, }) => { const { theme, resolvedMode, setTheme } = useTheme(); const activeTheme = useMemo<'light' | 'dark'>(() => { return theme === 'system' ? resolvedMode : theme; }, [resolvedMode, theme]); + const showUpdateDot = !!updateInfo?.updateAvailable && !updateInfo.dismissed; + return ( ( )} > @@ -118,34 +135,13 @@ export const ReviewHeaderMenu: React.FC = ({ -
-
- Plannotator - - v{appVersion} - -
- -
+ )}
diff --git a/packages/ui/components/BorderTrail.tsx b/packages/ui/components/BorderTrail.tsx new file mode 100644 index 000000000..7292a5bb7 --- /dev/null +++ b/packages/ui/components/BorderTrail.tsx @@ -0,0 +1,38 @@ +import { motion, type Transition } from 'motion/react'; + +export interface BorderTrailProps { + className?: string; + size?: number; + transition?: Transition; + style?: React.CSSProperties; +} + +export function BorderTrail({ + className, + size = 60, + transition, + style, +}: BorderTrailProps) { + const defaultTransition: Transition = { + repeat: Infinity, + duration: 5, + ease: 'linear', + }; + + return ( +
+ +
+ ); +} diff --git a/packages/ui/components/MenuVersionSection.tsx b/packages/ui/components/MenuVersionSection.tsx new file mode 100644 index 000000000..35e7c6cf7 --- /dev/null +++ b/packages/ui/components/MenuVersionSection.tsx @@ -0,0 +1,91 @@ +import React, { useState } from 'react'; +import { TextShimmer } from './TextShimmer'; +import type { UpdateInfo } from '../hooks/useUpdateCheck'; +import type { Origin } from '@plannotator/shared/agents'; +import { isWindows } from '../utils/platform'; + +const PI_INSTALL_COMMAND = 'pi install npm:@plannotator/pi-extension'; + +function getInstallCommand(origin?: Origin | null, isWSL = false): string { + if (origin === 'pi') return PI_INSTALL_COMMAND; + return isWindows && !isWSL + ? 'powershell -c "irm https://plannotator.ai/install.ps1 | iex"' + : 'curl -fsSL https://plannotator.ai/install.sh | bash'; +} + +interface MenuVersionSectionProps { + appVersion: string; + updateInfo?: UpdateInfo | null; + origin?: Origin | null; + isWSL: boolean; + closeMenu: () => void; +} + +export const MenuVersionSection: React.FC = ({ + appVersion, + updateInfo, + origin, + isWSL, + closeMenu, +}) => { + const [copied, setCopied] = useState(false); + const hasUpdate = !!updateInfo?.updateAvailable; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(getInstallCommand(origin, isWSL)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (e) { + console.error('Failed to copy:', e); + } + }; + + return ( +
+
+ + Plannotator + + + v{appVersion} + +
+
+ + + Release notes + + {hasUpdate && ( + <> + ยท + + New update available! + + + )} + + {hasUpdate && ( + + )} +
+
+ ); +}; diff --git a/packages/ui/components/PlanHeaderMenu.tsx b/packages/ui/components/PlanHeaderMenu.tsx index 85baf34b9..82d2d786d 100644 --- a/packages/ui/components/PlanHeaderMenu.tsx +++ b/packages/ui/components/PlanHeaderMenu.tsx @@ -8,9 +8,15 @@ import { import { useTheme } from './ThemeProvider'; import { SunIcon, MoonIcon, SystemIcon } from './icons/themeIcons'; import { ReviewAgentsIcon } from './ReviewAgentsIcon'; +import { MenuVersionSection } from './MenuVersionSection'; +import type { UpdateInfo } from '../hooks/useUpdateCheck'; +import type { Origin } from '@plannotator/shared/agents'; interface PlanHeaderMenuProps { appVersion: string; + updateInfo?: UpdateInfo | null; + origin?: Origin | null; + isWSL?: boolean; onOpenSettings: () => void; onOpenExport: () => void; onCopyAgentInstructions: () => void; @@ -31,6 +37,9 @@ interface PlanHeaderMenuProps { export const PlanHeaderMenu: React.FC = ({ appVersion, + updateInfo, + origin, + isWSL = false, onOpenSettings, onOpenExport, onCopyAgentInstructions, @@ -50,6 +59,8 @@ export const PlanHeaderMenu: React.FC = ({ }) => { const { theme, setTheme } = useTheme(); + const showUpdateDot = !!updateInfo?.updateAvailable && !updateInfo.dismissed; + const anyNotesAppConfigured = isApiMode && (obsidianConfigured || bearConfigured || octarineConfigured); @@ -57,7 +68,10 @@ export const PlanHeaderMenu: React.FC = ({ ( )} > @@ -205,34 +222,13 @@ export const PlanHeaderMenu: React.FC = ({ -
-
- Plannotator - - v{appVersion} - -
- -
+ )}
diff --git a/packages/ui/components/TextShimmer.tsx b/packages/ui/components/TextShimmer.tsx new file mode 100644 index 000000000..9589039c8 --- /dev/null +++ b/packages/ui/components/TextShimmer.tsx @@ -0,0 +1,57 @@ +import React, { useMemo, type JSX } from 'react'; +import { motion } from 'motion/react'; + +export interface TextShimmerProps { + children: string; + as?: React.ElementType; + className?: string; + duration?: number; + spread?: number; +} + +function TextShimmerComponent({ + children, + as: Component = 'span', + className, + duration = 2, + spread = 2, +}: TextShimmerProps) { + const MotionComponent = motion.create( + Component as keyof JSX.IntrinsicElements + ); + + const dynamicSpread = useMemo(() => { + return children.length * spread; + }, [children, spread]); + + return ( + + {children} + + ); +} + +export const TextShimmer = React.memo(TextShimmerComponent); diff --git a/packages/ui/components/UpdateBanner.tsx b/packages/ui/components/UpdateBanner.tsx deleted file mode 100644 index 2cf6fab56..000000000 --- a/packages/ui/components/UpdateBanner.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import React, { useState } from 'react'; -import type { Origin } from '@plannotator/shared/agents'; -import { useUpdateCheck } from '../hooks/useUpdateCheck'; -import { isWindows } from '../utils/platform'; - -const PI_INSTALL_COMMAND = 'pi install npm:@plannotator/pi-extension'; - -function getInstallCommand(isWSL: boolean): string { - return isWindows && !isWSL - ? 'powershell -c "irm https://plannotator.ai/install.ps1 | iex"' - : 'curl -fsSL https://plannotator.ai/install.sh | bash'; -} - -interface UpdateBannerProps { - origin?: Origin | null; - isWSL?: boolean; -} - -export const UpdateBanner: React.FC = ({ origin, isWSL = false }) => { - const updateInfo = useUpdateCheck(); - const [copied, setCopied] = useState(false); - const [dismissed, setDismissed] = useState(false); - - // Debug: ?preview-origin=opencode to test OpenCode-specific UI - const urlParams = new URLSearchParams(window.location.search); - const previewOrigin = urlParams.get('preview-origin'); - const effectiveOrigin = previewOrigin || origin; - const isPi = effectiveOrigin === 'pi'; - const isOpenCode = effectiveOrigin === 'opencode'; - const installCommand = isPi ? PI_INSTALL_COMMAND : getInstallCommand(isWSL); - - if (!updateInfo?.updateAvailable || dismissed) return null; - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(installCommand); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (e) { - console.error('Failed to copy:', e); - } - }; - - const hasFeature = !!updateInfo.featureHighlight; - - // Expanded banner for milestone releases with feature highlights - if (hasFeature) { - return ( -
-
- {/* Feature highlight header */} -
-
-
-
- - - -
-
-

- {updateInfo.featureHighlight!.title} -

-

- New in {updateInfo.latestVersion} -

-
-
- -
-
- - {/* Feature description */} -
-

- {updateInfo.featureHighlight!.description} -

- -

- You have {updateInfo.currentVersion} -

- - {/* Agent-specific extra instructions */} - {isOpenCode && ( -

- Run the install script, then restart OpenCode. -

- )} - {isPi && ( -

- Run the install command, then restart Pi. -

- )} - -
- - - Release notes - -
-
-
-
- ); - } - - // Standard update banner - return ( -
-
-
-
- - - -
-
-
-

- Update available -

- -
-

- {updateInfo.latestVersion} is available (you have {updateInfo.currentVersion}) -

-
- - - Notes - -
-
-
-
-
- ); -}; diff --git a/packages/ui/hooks/useUpdateCheck.ts b/packages/ui/hooks/useUpdateCheck.ts index cc10ac681..94808997e 100644 --- a/packages/ui/hooks/useUpdateCheck.ts +++ b/packages/ui/hooks/useUpdateCheck.ts @@ -1,4 +1,5 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import { getItem, setItem } from '../utils/storage'; declare const __APP_VERSION__: string; @@ -7,7 +8,17 @@ export interface FeatureHighlight { description: string; } -interface UpdateInfo { +export interface UpdateInfo { + currentVersion: string; + latestVersion: string; + updateAvailable: boolean; + dismissed: boolean; + releaseUrl: string; + featureHighlight?: FeatureHighlight; + dismiss: () => void; +} + +interface VersionCheckResult { currentVersion: string; latestVersion: string; updateAvailable: boolean; @@ -17,6 +28,8 @@ interface UpdateInfo { const GITHUB_API = 'https://api.github.com/repos/backnotprop/plannotator/releases/latest'; +const DISMISSED_VERSION_KEY = 'update-dismissed-version'; + // Feature highlights for milestone releases const FEATURE_HIGHLIGHTS: Record = { '0.5.0': { @@ -39,8 +52,24 @@ function compareVersions(current: string, latest: string): boolean { return false; } +function isDismissedVersion(latestVersion: string): boolean { + const dismissed = getItem(DISMISSED_VERSION_KEY); + if (!dismissed) return false; + const cleanLatest = latestVersion.replace(/^v/, ''); + const cleanDismissed = dismissed.replace(/^v/, ''); + return cleanLatest === cleanDismissed; +} + export function useUpdateCheck(): UpdateInfo | null { - const [updateInfo, setUpdateInfo] = useState(null); + const [checkResult, setCheckResult] = useState(null); + const [dismissed, setDismissed] = useState(false); + + const dismiss = useCallback(() => { + if (!checkResult?.latestVersion) return; + const clean = checkResult.latestVersion.replace(/^v/, ''); + setItem(DISMISSED_VERSION_KEY, clean); + setDismissed(true); + }, [checkResult?.latestVersion]); useEffect(() => { const checkForUpdates = async () => { @@ -55,7 +84,8 @@ export function useUpdateCheck(): UpdateInfo | null { if (previewVersion) { const cleanPreview = previewVersion.replace(/^v/, ''); - setUpdateInfo({ + setDismissed(isDismissedVersion(cleanPreview)); + setCheckResult({ currentVersion, latestVersion: previewVersion, updateAvailable: true, @@ -73,11 +103,11 @@ export function useUpdateCheck(): UpdateInfo | null { const updateAvailable = compareVersions(currentVersion, latestVersion); - // Check for feature highlight for this version const cleanLatest = latestVersion.replace(/^v/, ''); const featureHighlight = FEATURE_HIGHLIGHTS[cleanLatest]; - setUpdateInfo({ + setDismissed(isDismissedVersion(latestVersion)); + setCheckResult({ currentVersion, latestVersion, updateAvailable, @@ -85,7 +115,6 @@ export function useUpdateCheck(): UpdateInfo | null { featureHighlight, }); } catch (e) { - // Silently fail - update check is not critical console.debug('Update check failed:', e); } }; @@ -93,5 +122,11 @@ export function useUpdateCheck(): UpdateInfo | null { checkForUpdates(); }, []); - return updateInfo; + if (!checkResult) return null; + + return { + ...checkResult, + dismissed, + dismiss, + }; } diff --git a/packages/ui/package.json b/packages/ui/package.json index 4374f3c3e..0f14707c7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -35,6 +35,7 @@ "overlayscrollbars": "^2.11.0", "overlayscrollbars-react": "^0.5.6", "perfect-freehand": "^1.2.2", + "motion": "^12.38.0", "react": "^19.2.3", "react-dom": "^19.2.3", "unique-username-generator": "^1.5.1"