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:
co-authored by
mergetest
Claude Opus 4.8
parent
b7cecde57e
commit
dba8934aa2
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user