feat: implement update management system with new update store and UI components

This commit is contained in:
Bohdan Triapitsyn
2025-12-20 01:04:32 +02:00
parent 5982767bdf
commit 79ed6abab7
8 changed files with 317 additions and 288 deletions
+1 -1
View File
@@ -2847,7 +2847,7 @@ dependencies = [
[[package]]
name = "openchamber-desktop"
version = "1.2.7"
version = "1.2.8"
dependencies = [
"anyhow",
"axum",
+10 -2
View File
@@ -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<HTMLElement | null>(null);
@@ -491,13 +493,19 @@ export const Header: React.FC = () => {
type="button"
onClick={handleOpenSettings}
aria-label="Open settings"
className={headerIconButtonClass}
className={cn(headerIconButtonClass, 'relative')}
>
<RiSettings3Line className="h-5 w-5" />
{updateAvailable && (
<span
className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-primary"
aria-label="Update available"
/>
)}
</button>
</TooltipTrigger>
<TooltipContent>
<p>Settings</p>
<p>{updateAvailable ? 'Settings (Update available)' : 'Settings'}</p>
</TooltipContent>
</Tooltip>
</div>
@@ -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) {
+12 -12
View File
@@ -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<SidebarProps> = ({ 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<boolean>(() => {
if (typeof window === 'undefined') {
@@ -267,14 +267,14 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
<UpdateDialog
open={updateDialogOpen}
onOpenChange={setUpdateDialogOpen}
info={update.info}
downloading={update.downloading}
downloaded={update.downloaded}
progress={update.progress}
error={update.error}
onDownload={update.downloadUpdate}
onRestart={update.restartToUpdate}
runtimeType={update.runtimeType}
info={updateStore.info}
downloading={updateStore.downloading}
downloaded={updateStore.downloaded}
progress={updateStore.progress}
error={updateStore.error}
onDownload={updateStore.downloadUpdate}
onRestart={updateStore.restartToUpdate}
runtimeType={updateStore.runtimeType}
/>
</div>
</aside>
@@ -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 (
<div className="w-full space-y-6">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
About OpenChamber
</h3>
</div>
{/* Version and Update */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="typography-ui-label text-muted-foreground">Version</div>
<div className="typography-ui-header font-mono">{currentVersion}</div>
</div>
{updateStore.checking && (
<div className="flex items-center gap-2 text-muted-foreground">
<RiLoaderLine className="h-4 w-4 animate-spin" />
<span className="typography-meta">Checking...</span>
</div>
)}
{!updateStore.checking && updateStore.available && (
<button
onClick={() => setUpdateDialogOpen(true)}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-md',
'text-sm font-medium',
'bg-primary text-primary-foreground',
'hover:bg-primary/90',
'transition-colors'
)}
>
<RiDownloadLine className="h-4 w-4" />
Update to {updateStore.info?.version}
</button>
)}
{!updateStore.checking && !updateStore.available && !updateStore.error && (
<span className="typography-meta text-muted-foreground">Up to date</span>
)}
</div>
{updateStore.error && (
<p className="typography-meta text-destructive">{updateStore.error}</p>
)}
<button
onClick={() => updateStore.checkForUpdates()}
disabled={updateStore.checking}
className={cn(
'typography-meta text-muted-foreground hover:text-foreground',
'underline-offset-2 hover:underline',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
Check for updates
</button>
</div>
{/* Links */}
<div className="flex items-center gap-4">
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex items-center gap-1.5 text-muted-foreground hover:text-foreground',
'typography-meta transition-colors'
)}
>
<RiGithubFill className="h-4 w-4" />
<span>GitHub</span>
</a>
<a
href="https://x.com/btriapitsyn"
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex items-center gap-1.5 text-muted-foreground hover:text-foreground',
'typography-meta transition-colors'
)}
>
<RiTwitterXFill className="h-4 w-4" />
<span>@btriapitsyn</span>
</a>
</div>
{/* Update Dialog */}
<UpdateDialog
open={updateDialogOpen}
onOpenChange={setUpdateDialogOpen}
info={updateStore.info}
downloading={updateStore.downloading}
downloaded={updateStore.downloaded}
progress={updateStore.progress}
error={updateStore.error}
onDownload={updateStore.downloadUpdate}
onRestart={updateStore.restartToUpdate}
runtimeType={updateStore.runtimeType}
/>
</div>
);
};
@@ -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 (
<ScrollableOverlay
outerClassName="h-full"
className="settings-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6"
>
<AppearanceSettings />
{showAbout && (
<div className="border-t border-border/40 pt-6">
<AboutSettings />
</div>
)}
</ScrollableOverlay>
);
};
-273
View File
@@ -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<void>;
downloadUpdate: () => Promise<void>;
restartToUpdate: () => Promise<void>;
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<UpdateInfo | null> {
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<UpdateState>({
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<UpdateState | null>(() => {
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,
};
};
+153
View File
@@ -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<void>;
downloadUpdate: () => Promise<void>;
restartToUpdate: () => Promise<void>;
dismiss: () => void;
reset: () => void;
}
async function checkForWebUpdates(): Promise<UpdateInfo | null> {
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<UpdateStore>()((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);
},
}));