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:
David Kaya
2026-03-30 17:15:38 +02:00
co-authored by Copilot
parent 9647b5fdb5
commit 772c84fed3
6 changed files with 169 additions and 4 deletions
+24 -2
View File
@@ -10,7 +10,7 @@ import { NewSessionModal } from '@renderer/components/NewSessionModal';
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
import { BookmarksPanel } from '@renderer/components/BookmarksPanel';
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
import { SettingsPanel } from '@renderer/components/SettingsPanel';
import { SettingsPanel, type SettingsSection } from '@renderer/components/SettingsPanel';
import { Sidebar } from '@renderer/components/Sidebar';
import { TerminalPanel, DEFAULT_HEIGHT as DEFAULT_TERMINAL_HEIGHT, MIN_HEIGHT as MIN_TERMINAL_HEIGHT } from '@renderer/components/TerminalPanel';
import { resolveChatToolingSettings } from '@renderer/lib/chatTooling';
@@ -46,6 +46,7 @@ import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/proje
import { applySessionModelConfig } from '@shared/domain/session';
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
import type { WorkspaceState } from '@shared/domain/workspace';
import type { UpdateStatus } from '@shared/contracts/ipc';
import { createId, nowIso } from '@shared/utils/ids';
function createDraftPattern(defaultModelId: string, defaultReasoningEffort: PatternDefinition['agents'][0]['reasoningEffort']): PatternDefinition {
@@ -113,6 +114,8 @@ export default function App() {
const [activeSubagents, setActiveSubagents] = useState<ActiveSubagentMap>({});
const [showSettings, setShowSettings] = useState(false);
const [settingsSection, setSettingsSection] = useState<SettingsSection>();
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({ state: 'idle' });
const [projectSettingsId, setProjectSettingsId] = useState<string>();
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
@@ -188,6 +191,12 @@ export default function App() {
};
}, [api]);
// Subscribe to auto-update status pushes from the main process
useEffect(() => {
const off = api.onUpdateStatus((status) => setUpdateStatus(status));
return off;
}, [api]);
// Apply theme to the document root
const themeSetting: AppearanceTheme = workspace?.settings.theme ?? 'dark';
useTheme(themeSetting);
@@ -494,6 +503,15 @@ export default function App() {
}
}, [api, workspace]);
const handleOpenSettingsAt = useCallback((section?: SettingsSection) => {
setSettingsSection(section);
setShowSettings(true);
}, []);
const handleInstallUpdate = useCallback(() => {
void api.installUpdate();
}, [api]);
// Listen for tray "Quick Scratchpad" action
const scratchpadRef = useRef(handleCreateScratchpad);
scratchpadRef.current = handleCreateScratchpad;
@@ -637,8 +655,9 @@ export default function App() {
const overlay = showSettings ? (
<SettingsPanel
availableModels={availableModels}
initialSection={settingsSection}
isRefreshingCapabilities={isRefreshingCapabilities}
onClose={() => setShowSettings(false)}
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
onDeleteLspProfile={async (id) => {
await api.deleteLspProfile(id);
}}
@@ -741,6 +760,9 @@ export default function App() {
onRefreshGitContext={(projectId) => {
void api.refreshProjectGitContext(projectId);
}}
updateStatus={updateStatus}
onViewUpdateDetails={() => handleOpenSettingsAt('troubleshooting')}
onInstallUpdate={handleInstallUpdate}
workspace={workspace}
/>
}
+4 -2
View File
@@ -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);
+17
View File
@@ -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">
+108
View File
@@ -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>
);
}
+2
View File
@@ -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';
+14
View File
@@ -626,6 +626,19 @@ body {
animation: banner-slide-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
/* ── Update banner slide-up ──────────────────────────────────── */
@keyframes update-banner-in {
from {
opacity: 0;
transform: translateY(100%);
}
}
.update-banner-enter {
animation: update-banner-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
/* ── Thinking process section ────────────────────────────────── */
@keyframes thinking-process-in {
@@ -651,6 +664,7 @@ body {
.msg-actions-enter,
.session-item-enter,
.banner-slide-enter,
.update-banner-enter,
.thinking-process-enter {
animation: none;
}