feat: add sidebar update indicator toggle and Stack Health badge (#1570)

* feat: add sidebar update indicator toggle and Stack Health badge

- Add image_update_sidebar_indicators setting (default off, node-scoped)
- Gate the Updates filter chip and sidebar status indicators on the setting
- Add "Update available" badge to Stack Health table (always visible)
- Extend ImageUpdateStatus with sidebarIndicators boolean
- Poll /api/image-updates/status alongside /detail in useImageUpdates
- React to SENCHO_SETTINGS_CHANGED for instant toggle propagation
- Reset sidebar state on node switch; generation-guard stale responses
- Disable toggle when status is null (loading) or field is absent (old node)
- Wire stackUpdates through ViewRouter → HomeDashboard → StackHealthTable
- Update settings registry, operator docs, and sidebar/dashboard docs

* fix: guard against stale node renders, memo drift, and cross-node error toasts

- Track owning node ID in useImageUpdates state so React never renders
  node B with node A's data before the passive effect resets (P2)
- Replace incorrect stackUpdates dependency with sidebarStackUpdates in
  chipFilteredFiles useMemo (P3)
- Guard the error toast in handleSidebarIndicatorsChange so a stale PATCH
  failure from node A does not surface while viewing node B (P3)

* fix: default sidebar update indicators to on (opt-out)

The sidebar indicators are a safe convenience that most users want.
Switching the default from off to on matches the opt-out convention
used by prune_on_update, reclaim_hero, and health_gate_enabled.
This commit is contained in:
Anso
2026-07-05 02:52:17 -04:00
committed by GitHub
parent c677b8bb66
commit bb35c1bc92
19 changed files with 256 additions and 47 deletions
@@ -8,6 +8,7 @@ import { useSidebarGroupCollapse } from '@/hooks/useSidebarGroupCollapse';
import { useBulkStackActions, type BulkAction } from '@/hooks/useBulkStackActions';
import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch';
import { SENCHO_LABELS_CHANGED } from '@/lib/events';
import type { StackUpdateInfo } from '@/types/imageUpdates';
import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards';
import type { StackAction, StackActionResult } from '../EditorView';
import type { Label as StackLabel } from '../../label-types';
@@ -60,6 +61,8 @@ export interface RemoteResult {
files: Array<{ file: string; status: StackRowStatus }>;
}
const EMPTY_UPDATES: Record<string, StackUpdateInfo> = {};
export function useStackListState() {
const { nodes, activeNode } = useNodes();
@@ -96,7 +99,8 @@ export function useStackListState() {
const [bulkMode, setBulkMode] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const { stackUpdates, refresh: fetchImageUpdates } = useImageUpdates(activeNode?.id);
const { stackUpdates, refresh: fetchImageUpdates, sidebarIndicators } = useImageUpdates(activeNode?.id);
const sidebarStackUpdates = sidebarIndicators ? stackUpdates : EMPTY_UPDATES;
const { pinned, pin, unpin, isPinned, evictedOldest } = usePinnedStacks(activeNode?.id);
const { isCollapsed, toggle: toggleCollapse } = useSidebarGroupCollapse(activeNode?.id);
const { runBulk } = useBulkStackActions();
@@ -295,16 +299,16 @@ export function useStackListState() {
all: filteredFiles.length,
up: filteredFiles.filter(f => stackStatuses[f] === 'running').length,
down: filteredFiles.filter(f => isDownStatus(stackStatuses[f])).length,
updates: filteredFiles.filter(f => stackUpdates[f]?.hasUpdate).length,
}), [filteredFiles, stackStatuses, stackUpdates]);
updates: filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate).length,
}), [filteredFiles, stackStatuses, sidebarStackUpdates]);
const chipFilteredFiles = useMemo(() => {
if (filterChip === 'all') return filteredFiles;
if (filterChip === 'up') return filteredFiles.filter(f => stackStatuses[f] === 'running');
if (filterChip === 'down') return filteredFiles.filter(f => isDownStatus(stackStatuses[f]));
if (filterChip === 'updates') return filteredFiles.filter(f => stackUpdates[f]?.hasUpdate);
if (filterChip === 'updates') return filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate);
return filteredFiles;
}, [filteredFiles, filterChip, stackStatuses, stackUpdates]);
}, [filteredFiles, filterChip, stackStatuses, sidebarStackUpdates]);
const toggleBulkMode = useCallback(() => {
setBulkMode(prev => {
@@ -381,6 +385,15 @@ export function useStackListState() {
});
}, [remoteStackResults, nodes]);
// When the sidebar indicator toggle is turned off, reset an active Updates
// filter to 'all' so the user is not stuck in a filter that shows nothing.
useEffect(() => {
if (!sidebarIndicators && filterChip === 'updates') {
// eslint-disable-next-line react-hooks/set-state-in-effect
setFilterChip('all');
}
}, [sidebarIndicators, filterChip]);
return {
files, setFiles, filesNodeId,
selectedFile, setSelectedFile,
@@ -410,6 +423,7 @@ export function useStackListState() {
scheduleStateInvalidateRefresh,
toggleBulkMode, toggleSelect, clearSelection, handleBulkAction,
stackUpdates, fetchImageUpdates,
sidebarIndicators, sidebarStackUpdates,
pinned, pin, unpin, isPinned,
isCollapsed, toggleCollapse,
remoteSearchLoading,