From ea62461459977c1b6f61878b9896f8074f759d81 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 25 May 2026 10:51:26 -0700 Subject: [PATCH 1/3] Replace persistent update banner with passive dot indicator in Options menu (#790) The floating update banner that appeared every session is removed. Update availability is now surfaced as a small dot on the Options button that auto-dismisses (cookie-persisted per version) when the menu is opened. The version section inside the menu shows install command and release notes when an update exists. --- bun.lock | 6 +- packages/editor/App.tsx | 8 +- packages/editor/components/AppHeader.tsx | 8 + packages/review-editor/App.tsx | 9 +- .../components/ReviewHeaderMenu.tsx | 57 +++--- packages/ui/components/MenuVersionSection.tsx | 106 +++++++++++ packages/ui/components/PlanHeaderMenu.tsx | 55 +++--- packages/ui/components/UpdateBanner.tsx | 172 ------------------ packages/ui/hooks/useUpdateCheck.ts | 51 +++++- 9 files changed, 220 insertions(+), 252 deletions(-) create mode 100644 packages/ui/components/MenuVersionSection.tsx delete mode 100644 packages/ui/components/UpdateBanner.tsx diff --git a/bun.lock b/bun.lock index 0ec6689ea..305751e3d 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:*", 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 +18,9 @@ interface ReviewHeaderMenuProps { isFileTreeOpen: boolean; isSidebarOpen: boolean; appVersion: string; + updateInfo?: UpdateInfo | null; + origin?: Origin | null; + isWSL?: boolean; } export const ReviewHeaderMenu: React.FC = ({ @@ -26,18 +31,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 +134,13 @@ export const ReviewHeaderMenu: React.FC = ({ -
-
- Plannotator - - v{appVersion} - -
- -
+ )}
diff --git a/packages/ui/components/MenuVersionSection.tsx b/packages/ui/components/MenuVersionSection.tsx new file mode 100644 index 000000000..93d69b3f0 --- /dev/null +++ b/packages/ui/components/MenuVersionSection.tsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; +import { ActionMenuSectionLabel } from './ActionMenu'; +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} + +
+ {hasUpdate && ( +
+
+
+ {updateInfo!.latestVersion} available +
+ {updateInfo!.featureHighlight && ( +
+ {updateInfo!.featureHighlight.title} +
+ )} +
+ +
+ )} + +
+ ); +}; diff --git a/packages/ui/components/PlanHeaderMenu.tsx b/packages/ui/components/PlanHeaderMenu.tsx index 85baf34b9..e0875bb8f 100644 --- a/packages/ui/components/PlanHeaderMenu.tsx +++ b/packages/ui/components/PlanHeaderMenu.tsx @@ -3,14 +3,19 @@ import { ActionMenu, ActionMenuDivider, ActionMenuItem, - ActionMenuSectionLabel, } from './ActionMenu'; 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 +36,9 @@ interface PlanHeaderMenuProps { export const PlanHeaderMenu: React.FC = ({ appVersion, + updateInfo, + origin, + isWSL = false, onOpenSettings, onOpenExport, onCopyAgentInstructions, @@ -50,6 +58,8 @@ export const PlanHeaderMenu: React.FC = ({ }) => { const { theme, setTheme } = useTheme(); + const showUpdateDot = !!updateInfo?.updateAvailable && !updateInfo.dismissed; + const anyNotesAppConfigured = isApiMode && (obsidianConfigured || bearConfigured || octarineConfigured); @@ -57,7 +67,10 @@ export const PlanHeaderMenu: React.FC = ({ ( )} > @@ -205,34 +221,13 @@ export const PlanHeaderMenu: React.FC = ({ -
-
- Plannotator - - v{appVersion} - -
- -
+ )}
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, + }; } From f57181ff4f717a68f772110935fee8bf0e4827b8 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 25 May 2026 10:53:35 -0700 Subject: [PATCH 2/3] fix: restore ActionMenuSectionLabel import in both header menus --- packages/review-editor/components/ReviewHeaderMenu.tsx | 1 + packages/ui/components/PlanHeaderMenu.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/review-editor/components/ReviewHeaderMenu.tsx b/packages/review-editor/components/ReviewHeaderMenu.tsx index 7c1a7267c..9149c7349 100644 --- a/packages/review-editor/components/ReviewHeaderMenu.tsx +++ b/packages/review-editor/components/ReviewHeaderMenu.tsx @@ -3,6 +3,7 @@ import { ActionMenu, ActionMenuDivider, ActionMenuItem, + ActionMenuSectionLabel, } from '@plannotator/ui/components/ActionMenu'; import { useTheme } from '@plannotator/ui/components/ThemeProvider'; import { MenuVersionSection } from '@plannotator/ui/components/MenuVersionSection'; diff --git a/packages/ui/components/PlanHeaderMenu.tsx b/packages/ui/components/PlanHeaderMenu.tsx index e0875bb8f..82d2d786d 100644 --- a/packages/ui/components/PlanHeaderMenu.tsx +++ b/packages/ui/components/PlanHeaderMenu.tsx @@ -3,6 +3,7 @@ import { ActionMenu, ActionMenuDivider, ActionMenuItem, + ActionMenuSectionLabel, } from './ActionMenu'; import { useTheme } from './ThemeProvider'; import { SunIcon, MoonIcon, SystemIcon } from './icons/themeIcons'; From 1f4e9212d1c237d13ca16469a6bad405a1f262c5 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 25 May 2026 11:52:00 -0700 Subject: [PATCH 3/3] Add TextShimmer to update notice and simplify menu version section - Add reusable TextShimmer component (motion-primitives adaptation) - Add reusable BorderTrail component for future use - Shimmer "New update available!" text in the Options menu - Simplify button to plain text link in emerald - Remove Project repo link, make "Plannotator" header link to repo --- bun.lock | 1 + packages/ui/components/BorderTrail.tsx | 38 ++++++++++ packages/ui/components/MenuVersionSection.tsx | 71 ++++++++----------- packages/ui/components/TextShimmer.tsx | 57 +++++++++++++++ packages/ui/package.json | 1 + 5 files changed, 125 insertions(+), 43 deletions(-) create mode 100644 packages/ui/components/BorderTrail.tsx create mode 100644 packages/ui/components/TextShimmer.tsx diff --git a/bun.lock b/bun.lock index 305751e3d..61d9b8d4a 100644 --- a/bun.lock +++ b/bun.lock @@ -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/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 index 93d69b3f0..35e7c6cf7 100644 --- a/packages/ui/components/MenuVersionSection.tsx +++ b/packages/ui/components/MenuVersionSection.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { ActionMenuSectionLabel } from './ActionMenu'; +import { TextShimmer } from './TextShimmer'; import type { UpdateInfo } from '../hooks/useUpdateCheck'; import type { Origin } from '@plannotator/shared/agents'; import { isWindows } from '../utils/platform'; @@ -44,45 +44,23 @@ export const MenuVersionSection: React.FC = ({ return (
- Plannotator + + Plannotator + v{appVersion}
- {hasUpdate && ( -
-
-
- {updateInfo!.latestVersion} available -
- {updateInfo!.featureHighlight && ( -
- {updateInfo!.featureHighlight.title} -
- )} -
- -
- )}
- {hasUpdate ? ( - - Release notes - - ) : ( + = ({ > Release notes + {hasUpdate && ( + <> + ยท + + New update available! + + + )} + + {hasUpdate && ( + )} - - Project repo -
); 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/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"