feat(footer): clickable version badge → Updates, with an update-available indicator (#671)

The bottom status bar showed no version and had no quick path to updates. Add a
small `v<version>` badge next to the network/share icon; clicking it opens
Settings → Updates. When an update is available (or downloaded and ready), the
badge highlights and shows a pulsing notification dot, and its tooltip names the
new version — so users can see at a glance that an update is waiting and one
click takes them to install it.

Mechanism: a one-shot `pendingSettingsTab` hand-off in the UI store (mirrors the
existing `pendingProfileId` pattern) + an `openSettingsTab(tab)` convenience that
sets the tab and navigates in one call. Settings consumes it as its initial tab
and clears it (an effect covers the already-open case). The indicator reads the
existing `updateStatus`/`updateVersion` from the updater slice — no new update
plumbing. Version from the shared APP_VERSION constant; new strings via i18n.

Test: openSettingsTab.test.js — the convenience sets mode=settings + the pending
tab, and the value can be cleared after consumption.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-24 12:48:51 +05:30
committed by GitHub
co-authored by mergetest Claude Opus 4.8
parent b7cecde57e
commit dba8934aa2
6 changed files with 119 additions and 1 deletions
+44
View File
@@ -219,6 +219,50 @@
gap: 2px;
}
/* Clickable app-version badge → Settings → Updates (sits by the network icon) */
.logs-footer__version {
background: none;
border: none;
cursor: pointer;
flex-shrink: 0;
padding: 0 6px;
height: var(--chrome-icon-btn);
border-radius: 4px;
font-size: 11px;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
color: var(--chrome-fg, #ebdbb2);
opacity: 0.55;
transition: opacity 0.15s, color 0.15s;
}
.logs-footer__version:hover {
opacity: 1;
color: var(--chrome-accent, #b8bb26);
text-decoration: underline;
}
/* Update-available: highlight the badge + a pulsing notification dot. */
.logs-footer__version--update {
position: relative;
opacity: 1;
color: var(--chrome-accent, #b8bb26);
font-weight: 600;
}
.logs-footer__version-dot {
display: inline-block;
width: 6px;
height: 6px;
margin-left: 5px;
border-radius: 50%;
background: var(--chrome-accent, #b8bb26);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--chrome-accent, #b8bb26) 60%, transparent);
animation: version-dot-pulse 2s ease-in-out infinite;
vertical-align: middle;
}
@keyframes version-dot-pulse {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--chrome-accent, #b8bb26) 55%, transparent); }
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--chrome-accent, #b8bb26) 0%, transparent); }
}
/* Discord button */
.logs-footer__discord {
background: none;
+19
View File
@@ -12,6 +12,7 @@ import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
import { useTranslation } from 'react-i18next';
import { useAppStore } from '../store';
import NetworkToggle from './NetworkToggle';
import { APP_VERSION } from '../utils/appVersion';
import './LogsFooter.css';
/**
@@ -142,6 +143,10 @@ export default function LogsFooter() {
}
const [collapsed, setCollapsed] = useState(true);
const { t } = useTranslation();
// Update availability drives the footer version badge's notification dot.
const updateStatus = useAppStore((s) => s.updateStatus);
const updateVersion = useAppStore((s) => s.updateVersion);
const updateReady = updateStatus === 'available' || updateStatus === 'ready';
const [height, setHeight] = useState(() => {
const v = Number(localStorage.getItem(LS_HEIGHT));
return Number.isFinite(v) && v >= MIN_H && v <= MAX_H ? v : 300;
@@ -412,6 +417,20 @@ export default function LogsFooter() {
</button>
</div>
)}
<button
type="button"
className={`logs-footer__version ${updateReady ? 'logs-footer__version--update' : ''}`}
onClick={() => useAppStore.getState().openSettingsTab?.('updates')}
title={updateReady
? t('logs.update_available', { version: updateVersion || '', defaultValue: 'Update available ({{version}}) — click to update' })
: t('logs.version_updates', { defaultValue: 'Check for updates' })}
aria-label={updateReady
? t('logs.update_available_aria', { version: updateVersion || '', defaultValue: 'Update available ({{version}}) — open updates' })
: t('logs.version_updates_aria', { defaultValue: 'Open updates — app version {{v}}', v: APP_VERSION })}
>
v{APP_VERSION}
{updateReady && <span className="logs-footer__version-dot" aria-hidden="true" />}
</button>
<NetworkToggle />
<button
type="button"
+4
View File
@@ -1552,6 +1552,10 @@
"close_aria": "Close logs panel",
"join_discord": "Join our Discord",
"join_discord_aria": "Join our Discord community",
"version_updates": "Check for updates",
"version_updates_aria": "Open updates — app version {{v}}",
"update_available": "Update available ({{version}}) — click to update",
"update_available_aria": "Update available ({{version}}) — open updates",
"support_project": "Support this project",
"support_project_aria": "Support this project",
"contact": "Contact",
+15 -1
View File
@@ -1157,7 +1157,11 @@ async function askConfirm(message, title = 'Confirm') {
export default function Settings() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('models');
// One-shot deep-link: a caller (e.g. the footer version badge → Updates) can
// set `pendingSettingsTab` and navigate here; consume it as the initial tab.
const pendingSettingsTab = useAppStore((s) => s.pendingSettingsTab);
const setPendingSettingsTab = useAppStore((s) => s.setPendingSettingsTab);
const [activeTab, setActiveTab] = useState(() => pendingSettingsTab || 'models');
const [logSource, setLogSource] = useState('backend');
const [logs, setLogs] = useState([]);
const [logMeta, setLogMeta] = useState({ path: '', exists: false });
@@ -1167,6 +1171,16 @@ export default function Settings() {
const [updateState, setUpdateState] = useState('idle'); // idle|checking|downloading|uptodate|error
const updateChannel = useAppStore((s) => s.updateChannel);
// Consume a one-shot deep-link tab (covers the case where Settings is already
// open and the value changes after mount); clear it so a later plain open of
// Settings doesn't jump tabs.
useEffect(() => {
if (pendingSettingsTab) {
setActiveTab(pendingSettingsTab);
setPendingSettingsTab(null);
}
}, [pendingSettingsTab, setPendingSettingsTab]);
// TanStack Query — shared cache with App.jsx, no duplicate requests
const { data: hw } = useSysinfo();
const { data: status } = useModelStatus();
+13
View File
@@ -54,6 +54,13 @@ export interface UiSlice {
* that profile once it appears in the loaded profiles list, then clears this.
*/
pendingProfileId: string | null;
/**
* One-shot hand-off for "open Settings on a specific tab": a caller (e.g. the
* footer version badge) sets the tab id and navigates to `settings`; the
* Settings page consumes it as its initial/active tab, then clears it. Mirrors
* `pendingProfileId`.
*/
pendingSettingsTab: string | null;
isSidebarCollapsed: boolean;
isSidebarProjectsCollapsed: boolean;
sidebarTab: SidebarTab;
@@ -66,6 +73,9 @@ export interface UiSlice {
setActiveVoiceId: (id: string | null) => void;
setModeBeforeVoice: (mode: AppMode | null) => void;
setPendingProfileId: (id: string | null) => void;
setPendingSettingsTab: (tab: string | null) => void;
/** Navigate to Settings on a specific tab in one call. */
openSettingsTab: (tab: string) => void;
setIsSidebarCollapsed: (collapsed: boolean) => void;
setIsSidebarProjectsCollapsed: (collapsed: boolean) => void;
setSidebarTab: (tab: SidebarTab) => void;
@@ -86,6 +96,7 @@ export const createUiSlice: StateCreator<UiSlice, [], [], UiSlice> = (set, get)
activeVoiceId: null,
modeBeforeVoice: null,
pendingProfileId: null,
pendingSettingsTab: null,
isSidebarCollapsed: false,
isSidebarProjectsCollapsed: false,
sidebarTab: 'projects',
@@ -98,6 +109,8 @@ export const createUiSlice: StateCreator<UiSlice, [], [], UiSlice> = (set, get)
setActiveVoiceId: (id) => set({ activeVoiceId: id }),
setModeBeforeVoice: (mode) => set({ modeBeforeVoice: mode }),
setPendingProfileId: (id) => set({ pendingProfileId: id }),
setPendingSettingsTab: (tab) => set({ pendingSettingsTab: tab }),
openSettingsTab: (tab) => set({ pendingSettingsTab: tab, mode: 'settings' }),
setIsSidebarCollapsed: (collapsed) => set({ isSidebarCollapsed: collapsed }),
setIsSidebarProjectsCollapsed: (collapsed) => set({ isSidebarProjectsCollapsed: collapsed }),
setSidebarTab: (tab) => set({ sidebarTab: tab }),
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useAppStore } from '../store';
// The footer version badge deep-links to Settings → Updates via openSettingsTab.
describe('openSettingsTab — footer version badge → Settings tab deep-link', () => {
beforeEach(() => {
useAppStore.getState().setMode('launchpad');
useAppStore.getState().setPendingSettingsTab(null);
});
it('sets the pending tab AND navigates to settings in one call', () => {
useAppStore.getState().openSettingsTab('updates');
expect(useAppStore.getState().mode).toBe('settings');
expect(useAppStore.getState().pendingSettingsTab).toBe('updates');
});
it('setPendingSettingsTab can clear the one-shot value after Settings consumes it', () => {
useAppStore.getState().openSettingsTab('updates');
useAppStore.getState().setPendingSettingsTab(null);
expect(useAppStore.getState().pendingSettingsTab).toBeNull();
// mode stays on settings — only the one-shot tab hint is cleared
expect(useAppStore.getState().mode).toBe('settings');
});
});