diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index e07d8284..2828f8e9 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2847,7 +2847,7 @@ dependencies = [ [[package]] name = "openchamber-desktop" -version = "1.2.7" +version = "1.2.8" dependencies = [ "anyhow", "axum", diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 53f6715d..698738e0 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -7,6 +7,7 @@ import { import { RiChat4Line, RiCodeLine, RiCommandLine, RiGitBranchLine, RiLayoutLeftLine, RiPlayListAddLine, RiQuestionLine, RiSettings3Line, RiTerminalBoxLine, type RemixiconComponentType } from '@remixicon/react'; import { useUIStore, type MainTab } from '@/stores/useUIStore'; +import { useUpdateStore } from '@/stores/useUpdateStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; @@ -95,6 +96,7 @@ export const Header: React.FC = () => { const getContextUsage = useSessionStore((state) => state.getContextUsage); const { isMobile } = useDeviceInfo(); const diffFileCount = useDiffFileCount(); + const updateAvailable = useUpdateStore((state) => state.available); const headerRef = React.useRef(null); @@ -491,13 +493,19 @@ export const Header: React.FC = () => { type="button" onClick={handleOpenSettings} aria-label="Open settings" - className={headerIconButtonClass} + className={cn(headerIconButtonClass, 'relative')} > + {updateAvailable && ( + + )} -

Settings

+

{updateAvailable ? 'Settings (Update available)' : 'Settings'}

diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 76e30ae2..f5f01b07 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -11,6 +11,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider'; import { useUIStore } from '@/stores/useUIStore'; +import { useUpdateStore } from '@/stores/useUpdateStore'; import { useDeviceInfo } from '@/lib/device'; import { useEdgeSwipe } from '@/hooks/useEdgeSwipe'; import { cn } from '@/lib/utils'; @@ -44,6 +45,15 @@ export const MainLayout: React.FC = () => { useEdgeSwipe({ enabled: true }); + // Trigger update check 3 seconds after mount (for both mobile and desktop) + const checkForUpdates = useUpdateStore((state) => state.checkForUpdates); + React.useEffect(() => { + const timer = setTimeout(() => { + checkForUpdates(); + }, 3000); + return () => clearTimeout(timer); + }, [checkForUpdates]); + React.useEffect(() => { const previous = useUIStore.getState().isMobile; if (previous !== isMobile) { diff --git a/packages/ui/src/components/layout/Sidebar.tsx b/packages/ui/src/components/layout/Sidebar.tsx index dd56dd73..ecc8fc3a 100644 --- a/packages/ui/src/components/layout/Sidebar.tsx +++ b/packages/ui/src/components/layout/Sidebar.tsx @@ -4,7 +4,7 @@ import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { ErrorBoundary } from '../ui/ErrorBoundary'; import { useUIStore } from '@/stores/useUIStore'; -import { useUpdateCheck } from '@/hooks/useUpdateCheck'; +import { useUpdateStore } from '@/stores/useUpdateStore'; import { UpdateDialog } from '../ui/UpdateDialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; @@ -27,11 +27,11 @@ export const Sidebar: React.FC = ({ isOpen, isMobile, children }) const startWidthRef = React.useRef(sidebarWidth || SIDEBAR_CONTENT_WIDTH); const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false); - const update = useUpdateCheck(); + const updateStore = useUpdateStore(); const pendingMenuUpdateCheckRef = React.useRef(false); - const checkForUpdates = update.checkForUpdates; - const { available, downloaded, checking } = update; + const checkForUpdates = updateStore.checkForUpdates; + const { available, downloaded, checking } = updateStore; const [isDesktopApp, setIsDesktopApp] = React.useState(() => { if (typeof window === 'undefined') { @@ -267,14 +267,14 @@ export const Sidebar: React.FC = ({ isOpen, isMobile, children }) diff --git a/packages/ui/src/components/sections/settings/AboutSettings.tsx b/packages/ui/src/components/sections/settings/AboutSettings.tsx new file mode 100644 index 00000000..65998de5 --- /dev/null +++ b/packages/ui/src/components/sections/settings/AboutSettings.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { RiDownloadLine, RiGithubFill, RiLoaderLine, RiTwitterXFill } from '@remixicon/react'; +import { useUpdateStore } from '@/stores/useUpdateStore'; +import { UpdateDialog } from '@/components/ui/UpdateDialog'; +import { cn } from '@/lib/utils'; + +const GITHUB_URL = 'https://github.com/btriapitsyn/openchamber'; + +export const AboutSettings: React.FC = () => { + const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false); + const updateStore = useUpdateStore(); + + const currentVersion = updateStore.info?.currentVersion || 'unknown'; + + return ( +
+
+

+ About OpenChamber +

+
+ + {/* Version and Update */} +
+
+
+
Version
+
{currentVersion}
+
+ + {updateStore.checking && ( +
+ + Checking... +
+ )} + + {!updateStore.checking && updateStore.available && ( + + )} + + {!updateStore.checking && !updateStore.available && !updateStore.error && ( + Up to date + )} +
+ + {updateStore.error && ( +

{updateStore.error}

+ )} + + +
+ + {/* Links */} + + + {/* Update Dialog */} + +
+ ); +}; diff --git a/packages/ui/src/components/sections/settings/SettingsPage.tsx b/packages/ui/src/components/sections/settings/SettingsPage.tsx index e2b40b5a..1f46b963 100644 --- a/packages/ui/src/components/sections/settings/SettingsPage.tsx +++ b/packages/ui/src/components/sections/settings/SettingsPage.tsx @@ -1,14 +1,25 @@ import React from 'react'; import { AppearanceSettings } from './AppearanceSettings'; +import { AboutSettings } from './AboutSettings'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { useDeviceInfo } from '@/lib/device'; +import { isWebRuntime } from '@/lib/desktop'; export const SettingsPage: React.FC = () => { + const { isMobile } = useDeviceInfo(); + const showAbout = isMobile && isWebRuntime(); + return ( + {showAbout && ( +
+ +
+ )}
); }; diff --git a/packages/ui/src/hooks/useUpdateCheck.ts b/packages/ui/src/hooks/useUpdateCheck.ts deleted file mode 100644 index 4af72a48..00000000 --- a/packages/ui/src/hooks/useUpdateCheck.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { useEffect, useState, useCallback } from 'react'; -import { - checkForDesktopUpdates, - downloadDesktopUpdate, - restartToApplyUpdate, - isDesktopRuntime, - isWebRuntime, - type UpdateInfo, - type UpdateProgress, -} from '@/lib/desktop'; - -export type UpdateState = { - checking: boolean; - available: boolean; - downloading: boolean; - downloaded: boolean; - info: UpdateInfo | null; - progress: UpdateProgress | null; - error: string | null; - /** Runtime type for conditional UI rendering */ - runtimeType: 'desktop' | 'web' | 'vscode' | null; -}; - -export type UseUpdateCheckReturn = UpdateState & { - checkForUpdates: () => Promise; - downloadUpdate: () => Promise; - restartToUpdate: () => Promise; - dismiss: () => void; -}; - -interface MockUpdateConfig { - currentVersion?: string; - newVersion?: string; -} - -// Set window.__OPENCHAMBER_MOCK_UPDATE__ = { currentVersion: '1.0.3', newVersion: '1.0.8' } to test with real changelog -declare global { - interface Window { - __OPENCHAMBER_MOCK_UPDATE__?: boolean | MockUpdateConfig; - } -} - -const getMockConfig = (): MockUpdateConfig | null => { - if (typeof window === 'undefined') return null; - const mock = window.__OPENCHAMBER_MOCK_UPDATE__; - if (!mock) return null; - if (mock === true) return { currentVersion: '1.0.3', newVersion: '1.0.8' }; - return mock; -}; - -const createMockUpdate = (config: MockUpdateConfig): UpdateState => ({ - checking: false, - available: true, - downloading: false, - downloaded: false, - info: { - available: true, - version: config.newVersion ?? '99.0.0-test', - currentVersion: config.currentVersion ?? '0.0.0', - body: undefined, - }, - progress: null, - error: null, - runtimeType: 'desktop', -}); - -const shouldMockUpdate = (): boolean => { - return getMockConfig() !== null; -}; - -/** - * Check for web updates via server API - */ -async function checkForWebUpdates(): Promise { - try { - const response = await fetch('/api/openchamber/update-check', { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - throw new Error(`Server responded with ${response.status}`); - } - - const data = await response.json(); - return { - available: data.available ?? false, - version: data.version, - currentVersion: data.currentVersion ?? 'unknown', - body: data.body, - packageManager: data.packageManager, - updateCommand: data.updateCommand, - }; - } catch (error) { - console.warn('Failed to check for web updates:', error); - return null; - } -} - -function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | null { - if (isDesktopRuntime()) return 'desktop'; - if (isWebRuntime()) return 'web'; - // VSCode doesn't support updates through this mechanism - return null; -} - -export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => { - const [state, setState] = useState({ - checking: false, - available: false, - downloading: false, - downloaded: false, - info: null, - progress: null, - error: null, - runtimeType: null, - }); - - // Only check mock mode once at startup - no polling - const [mockMode] = useState(shouldMockUpdate); - const [mockState, setMockState] = useState(() => { - if (shouldMockUpdate()) { - const config = getMockConfig(); - return config ? createMockUpdate(config) : null; - } - return null; - }); - - // Detect runtime type on mount - useEffect(() => { - const runtime = detectRuntimeType(); - setState((prev) => ({ ...prev, runtimeType: runtime })); - }, []); - - const checkForUpdates = useCallback(async () => { - if (mockMode) { - const config = getMockConfig(); - if (config) { - const mockUpdate = createMockUpdate(config); - mockUpdate.info = { - ...mockUpdate.info!, - body: `## [${config.newVersion}] - 2025-01-01\n- Mock update for UI testing`, - }; - setMockState(mockUpdate); - } - return; - } - - const runtime = detectRuntimeType(); - if (!runtime) { - return; - } - - setState((prev) => ({ ...prev, checking: true, error: null, runtimeType: runtime })); - - try { - let info: UpdateInfo | null = null; - - if (runtime === 'desktop') { - info = await checkForDesktopUpdates(); - } else if (runtime === 'web') { - info = await checkForWebUpdates(); - } - - setState((prev) => ({ - ...prev, - checking: false, - available: info?.available ?? false, - info, - })); - } catch (error) { - setState((prev) => ({ - ...prev, - checking: false, - error: error instanceof Error ? error.message : 'Failed to check for updates', - })); - } - }, [mockMode]); - - const downloadUpdate = useCallback(async () => { - if (mockMode) { - setMockState((prev) => prev ? { ...prev, downloading: true } : prev); - let progress = 0; - const interval = setInterval(() => { - progress += 20; - setMockState((prev) => prev ? { - ...prev, - progress: { downloaded: progress * 1000, total: 100000 } - } : prev); - if (progress >= 100) { - clearInterval(interval); - setMockState((prev) => prev ? { - ...prev, - downloading: false, - downloaded: true, - progress: null, - } : prev); - } - }, 500); - return; - } - - // For web runtime, there's no download - user runs CLI command - // This is only applicable to desktop - if (!isDesktopRuntime() || !state.available) { - return; - } - - setState((prev) => ({ ...prev, downloading: true, error: null, progress: null })); - - try { - await downloadDesktopUpdate((progress) => { - setState((prev) => ({ ...prev, progress })); - }); - setState((prev) => ({ ...prev, downloading: false, downloaded: true })); - } catch (error) { - setState((prev) => ({ - ...prev, - downloading: false, - error: error instanceof Error ? error.message : 'Failed to download update', - })); - } - }, [mockMode, state.available]); - - const restartToUpdate = useCallback(async () => { - if (mockMode) { - return; - } - - // Only applicable to desktop - if (!isDesktopRuntime() || !state.downloaded) { - return; - } - - try { - await restartToApplyUpdate(); - } catch (error) { - setState((prev) => ({ - ...prev, - error: error instanceof Error ? error.message : 'Failed to restart', - })); - } - }, [mockMode, state.downloaded]); - - const dismiss = useCallback(() => { - if (mockMode) { - setMockState(null); - return; - } - setState((prev) => ({ ...prev, available: false, downloaded: false, info: null })); - }, [mockMode]); - - useEffect(() => { - const runtime = detectRuntimeType(); - if (checkOnMount && (runtime === 'desktop' || runtime === 'web' || mockMode)) { - const timer = setTimeout(() => { - checkForUpdates(); - }, 3000); - - return () => clearTimeout(timer); - } - }, [checkOnMount, checkForUpdates, mockMode]); - - const currentState = (mockMode && mockState) ? mockState : state; - - return { - ...currentState, - checkForUpdates, - downloadUpdate, - restartToUpdate, - dismiss, - }; -}; diff --git a/packages/ui/src/stores/useUpdateStore.ts b/packages/ui/src/stores/useUpdateStore.ts new file mode 100644 index 00000000..d990ae08 --- /dev/null +++ b/packages/ui/src/stores/useUpdateStore.ts @@ -0,0 +1,153 @@ +import { create } from 'zustand'; +import type { UpdateInfo, UpdateProgress } from '@/lib/desktop'; +import { + checkForDesktopUpdates, + downloadDesktopUpdate, + restartToApplyUpdate, + isDesktopRuntime, + isWebRuntime, +} from '@/lib/desktop'; + +export type UpdateState = { + checking: boolean; + available: boolean; + downloading: boolean; + downloaded: boolean; + info: UpdateInfo | null; + progress: UpdateProgress | null; + error: string | null; + runtimeType: 'desktop' | 'web' | 'vscode' | null; + lastChecked: number | null; +}; + +interface UpdateStore extends UpdateState { + checkForUpdates: () => Promise; + downloadUpdate: () => Promise; + restartToUpdate: () => Promise; + dismiss: () => void; + reset: () => void; +} + +async function checkForWebUpdates(): Promise { + try { + const response = await fetch('/api/openchamber/update-check', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`Server responded with ${response.status}`); + } + + const data = await response.json(); + return { + available: data.available ?? false, + version: data.version, + currentVersion: data.currentVersion ?? 'unknown', + body: data.body, + packageManager: data.packageManager, + updateCommand: data.updateCommand, + }; + } catch (error) { + console.warn('Failed to check for web updates:', error); + return null; + } +} + +function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | null { + if (isDesktopRuntime()) return 'desktop'; + if (isWebRuntime()) return 'web'; + return null; +} + +const initialState: UpdateState = { + checking: false, + available: false, + downloading: false, + downloaded: false, + info: null, + progress: null, + error: null, + runtimeType: null, + lastChecked: null, +}; + +export const useUpdateStore = create()((set, get) => ({ + ...initialState, + + checkForUpdates: async () => { + const runtime = detectRuntimeType(); + if (!runtime) return; + + set({ checking: true, error: null, runtimeType: runtime }); + + try { + let info: UpdateInfo | null = null; + + if (runtime === 'desktop') { + info = await checkForDesktopUpdates(); + } else if (runtime === 'web') { + info = await checkForWebUpdates(); + } + + set({ + checking: false, + available: info?.available ?? false, + info, + lastChecked: Date.now(), + }); + } catch (error) { + set({ + checking: false, + error: error instanceof Error ? error.message : 'Failed to check for updates', + }); + } + }, + + downloadUpdate: async () => { + const { available, runtimeType } = get(); + + // For web runtime, there's no download - user uses in-app update or CLI + if (runtimeType !== 'desktop' || !available) { + return; + } + + set({ downloading: true, error: null, progress: null }); + + try { + await downloadDesktopUpdate((progress) => { + set({ progress }); + }); + set({ downloading: false, downloaded: true }); + } catch (error) { + set({ + downloading: false, + error: error instanceof Error ? error.message : 'Failed to download update', + }); + } + }, + + restartToUpdate: async () => { + const { downloaded, runtimeType } = get(); + + if (runtimeType !== 'desktop' || !downloaded) { + return; + } + + try { + await restartToApplyUpdate(); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to restart', + }); + } + }, + + dismiss: () => { + set({ available: false, downloaded: false, info: null }); + }, + + reset: () => { + set(initialState); + }, +}));