mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-30 08:28:48 +02:00
feat: add in-app update notification banner to sidebar
Subscribe to auto-update status at the App level and render a compact UpdateBanner in the sidebar footer when an update is available, downloading, or downloaded: - Available/downloading: subtle banner with progress bar, dismissable - Downloaded: prominent 'Restart to update' action with glow effect Clicking the banner opens Settings directly on the Troubleshooting section via a new initialSection prop on SettingsPanel. New files: - src/renderer/components/ui/UpdateBanner.tsx Modified files: - App.tsx: subscribe to onUpdateStatus, wire props - Sidebar.tsx: accept and render UpdateBanner - SettingsPanel.tsx: add initialSection prop + export SettingsSection type - styles.css: add update-banner-enter slide-up animation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -29,6 +29,7 @@ interface SettingsPanelProps {
|
||||
toolingSettings: WorkspaceToolingSettings;
|
||||
discoveredUserTooling: DiscoveredToolingState;
|
||||
isRefreshingCapabilities: boolean;
|
||||
initialSection?: SettingsSection;
|
||||
onRefreshCapabilities: () => void;
|
||||
onClose: () => void;
|
||||
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
|
||||
@@ -51,7 +52,7 @@ interface SettingsPanelProps {
|
||||
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
||||
}
|
||||
|
||||
type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
|
||||
export type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
|
||||
|
||||
interface NavItem {
|
||||
id: SettingsSection;
|
||||
@@ -111,6 +112,7 @@ export function SettingsPanel({
|
||||
toolingSettings,
|
||||
discoveredUserTooling,
|
||||
isRefreshingCapabilities,
|
||||
initialSection,
|
||||
onRefreshCapabilities,
|
||||
onClose,
|
||||
onSavePattern,
|
||||
@@ -132,7 +134,7 @@ export function SettingsPanel({
|
||||
onResolveUserDiscoveredTooling,
|
||||
onGetQuota,
|
||||
}: SettingsPanelProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('appearance');
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
|
||||
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
||||
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
|
||||
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
|
||||
|
||||
@@ -33,7 +33,9 @@ import { isScratchpadProject, type ProjectRecord, type ProjectGitContext } from
|
||||
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import { querySessions } from '@shared/domain/sessionLibrary';
|
||||
import type { UpdateStatus } from '@shared/contracts/ipc';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
import { UpdateBanner } from '@renderer/components/ui';
|
||||
|
||||
interface SidebarProps {
|
||||
workspace: WorkspaceState;
|
||||
@@ -50,6 +52,9 @@ interface SidebarProps {
|
||||
onSetSessionArchived: (sessionId: string, isArchived: boolean) => void;
|
||||
onDeleteSession: (sessionId: string) => void;
|
||||
onRefreshGitContext: (projectId: string) => void;
|
||||
updateStatus?: UpdateStatus;
|
||||
onViewUpdateDetails?: () => void;
|
||||
onInstallUpdate?: () => void;
|
||||
}
|
||||
|
||||
/* ── Mode icon + accent colour mapping ─────────────────────── */
|
||||
@@ -540,6 +545,9 @@ export function Sidebar({
|
||||
onSetSessionArchived,
|
||||
onDeleteSession,
|
||||
onRefreshGitContext,
|
||||
updateStatus,
|
||||
onViewUpdateDetails,
|
||||
onInstallUpdate,
|
||||
}: SidebarProps) {
|
||||
const scratchpadProject = workspace.projects.find((project) => isScratchpadProject(project));
|
||||
const userProjects = workspace.projects.filter((project) => !isScratchpadProject(project));
|
||||
@@ -752,6 +760,15 @@ export function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Update notification banner */}
|
||||
{updateStatus && onViewUpdateDetails && onInstallUpdate && (
|
||||
<UpdateBanner
|
||||
status={updateStatus}
|
||||
onViewDetails={onViewUpdateDetails}
|
||||
onInstallUpdate={onInstallUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
{userProjects.length > 0 && (
|
||||
<div className="border-t border-[var(--color-border-subtle)] px-3 py-2">
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { ArrowDownToLine, Download, RefreshCw, Sparkles, X } from 'lucide-react';
|
||||
|
||||
import type { UpdateStatus } from '@shared/contracts/ipc';
|
||||
|
||||
export interface UpdateBannerProps {
|
||||
status: UpdateStatus;
|
||||
onViewDetails: () => void;
|
||||
onInstallUpdate: () => void;
|
||||
}
|
||||
|
||||
export function UpdateBanner({ status, onViewDetails, onInstallUpdate }: UpdateBannerProps) {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
const isActionable =
|
||||
status.state === 'available' ||
|
||||
status.state === 'downloading' ||
|
||||
status.state === 'downloaded';
|
||||
|
||||
// Nothing to show
|
||||
if (!isActionable) return null;
|
||||
|
||||
// Allow dismissal for transient states, never for downloaded
|
||||
if (dismissed && status.state !== 'downloaded') return null;
|
||||
|
||||
const version = status.version ? `v${status.version}` : '';
|
||||
|
||||
if (status.state === 'downloaded') {
|
||||
return (
|
||||
<div className="update-banner-enter px-3 pb-2" role="alert">
|
||||
<button
|
||||
className="group relative flex w-full items-center gap-2.5 overflow-hidden rounded-xl border border-[var(--color-status-success)]/25 bg-[var(--color-status-success)]/[0.07] px-3 py-2.5 text-left transition-all duration-200 hover:border-[var(--color-status-success)]/40 hover:bg-[var(--color-status-success)]/[0.12]"
|
||||
onClick={onInstallUpdate}
|
||||
type="button"
|
||||
>
|
||||
{/* Subtle glow effect */}
|
||||
<div className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100" style={{ background: 'radial-gradient(ellipse at center, rgba(52, 211, 153, 0.08), transparent 70%)' }} />
|
||||
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-[var(--color-status-success)]/15">
|
||||
<Sparkles className="size-3.5 text-[var(--color-status-success)]" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-[12px] font-semibold text-[var(--color-status-success)]">
|
||||
Update ready {version}
|
||||
</span>
|
||||
<span className="block text-[10px] text-[var(--color-text-muted)]">
|
||||
Restart to apply
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-lg bg-[var(--color-status-success)]/15 px-2 py-1 text-[10px] font-semibold text-[var(--color-status-success)] transition-all duration-200 group-hover:bg-[var(--color-status-success)]/25">
|
||||
<RefreshCw className="inline-block size-3 mr-1 align-[-2px]" />
|
||||
Restart
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// available / downloading
|
||||
const isDownloading = status.state === 'downloading';
|
||||
const percent = status.downloadProgress ? Math.round(status.downloadProgress.percent) : 0;
|
||||
|
||||
return (
|
||||
<div className="update-banner-enter px-3 pb-2" role="status">
|
||||
<div className="relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-2)]/60">
|
||||
<button
|
||||
className="group flex w-full items-center gap-2.5 px-3 py-2 text-left transition-all duration-200 hover:bg-[var(--color-surface-2)]"
|
||||
onClick={onViewDetails}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-[var(--color-accent)]/10">
|
||||
{isDownloading
|
||||
? <Download className="size-3 text-[var(--color-accent)] animate-pulse" />
|
||||
: <ArrowDownToLine className="size-3 text-[var(--color-accent)]" />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-[11px] font-medium text-[var(--color-text-primary)]">
|
||||
{isDownloading
|
||||
? `Downloading ${version}${percent > 0 ? ` · ${percent}%` : '…'}`
|
||||
: `Update available ${version}`}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="flex size-5 shrink-0 items-center justify-center rounded text-[var(--color-text-muted)] opacity-0 transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)] group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDismissed(true);
|
||||
}}
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</button>
|
||||
|
||||
{/* Download progress bar */}
|
||||
{isDownloading && percent > 0 && (
|
||||
<div className="h-[2px] w-full bg-[var(--color-surface-3)]">
|
||||
<div
|
||||
className="h-full bg-[var(--color-accent)] transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,3 +7,5 @@ export { TextInput } from './TextInput';
|
||||
export { TextareaInput } from './TextareaInput';
|
||||
export { SelectInput } from './SelectInput';
|
||||
export { InfoCallout } from './InfoCallout';
|
||||
export type { UpdateBannerProps } from './UpdateBanner';
|
||||
export { UpdateBanner } from './UpdateBanner';
|
||||
|
||||
Reference in New Issue
Block a user