feat(studio): pending-approvals sidebar badge + per-category settings help
Some checks failed
Security / Security check (push) Failing after 1s

Two operator-attention improvements:

  - The sidebar's Approvals item gains a small accent-coloured
    badge (e.g. '2', '9+') when there are pending approvals,
    polled on the same 5 s tick the health check uses. Operators
    no longer need to open the Approvals page to discover new
    pending decisions; the badge surfaces them at the rail
    level.

  - The Settings dialog's per-category panel titles grow an
    optional help icon (?) that opens the matching bundled
    docs explainer via showFaiDoc. Wired for General →
    architecture, Security → security, Maintenance → audit.
    Other categories deliberately stay un-iced for now —
    Appearance / System AI / Integrations don't yet have a
    standalone bundled doc, and showing a broken help icon
    would be worse than showing none.

Studio bumped to 0.69.0.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-06-09 09:41:14 +02:00
parent 5aae3285df
commit 7511867774
3 changed files with 95 additions and 15 deletions

View file

@ -27,7 +27,7 @@ import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the /// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header /// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build. /// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.68.0'; const String kStudioVersion = '0.69.0';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -313,6 +313,11 @@ class StudioShellState extends State<StudioShell> {
), ),
]; ];
/// Pending-approval count surfaced as a sidebar badge so the
/// operator notices new approvals without polling the page.
/// Refreshed alongside the health probe same 5 s tick.
int _pendingApprovals = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -327,9 +332,6 @@ class StudioShellState extends State<StudioShell> {
final ok = await HubService.instance.healthy(); final ok = await HubService.instance.healthy();
if (!mounted) return; if (!mounted) return;
if (_connected != ok) setState(() => _connected = ok); if (_connected != ok) setState(() => _connected = ok);
// Refresh the active channel pointer alongside the health
// probe cheap, and keeps the sidebar pill honest if the
// operator switched channels from the CLI.
if (ok) { if (ok) {
try { try {
final snap = await HubService.instance.channelStatus(); final snap = await HubService.instance.channelStatus();
@ -337,9 +339,14 @@ class StudioShellState extends State<StudioShell> {
if (_activeChannel != snap.active) { if (_activeChannel != snap.active) {
setState(() => _activeChannel = snap.active); setState(() => _activeChannel = snap.active);
} }
} catch (_) { } catch (_) {/* best-effort */}
// Best-effort; pill simply doesn't update this tick. try {
} final pending = await HubService.instance.pendingApprovals();
if (!mounted) return;
if (_pendingApprovals != pending.length) {
setState(() => _pendingApprovals = pending.length);
}
} catch (_) {/* best-effort */}
} }
} }
@ -443,6 +450,7 @@ class StudioShellState extends State<StudioShell> {
connected: _connected, connected: _connected,
endpointLabel: HubService.instance.endpointLabel, endpointLabel: HubService.instance.endpointLabel,
activeChannel: _activeChannel, activeChannel: _activeChannel,
pendingApprovals: _pendingApprovals,
), ),
Container(width: 1, color: theme.colorScheme.outlineVariant), Container(width: 1, color: theme.colorScheme.outlineVariant),
Expanded( Expanded(
@ -501,6 +509,11 @@ class _Sidebar extends StatefulWidget {
/// initial fetch is in flight or the hub is unreachable. /// initial fetch is in flight or the hub is unreachable.
final String? activeChannel; final String? activeChannel;
/// Number of pending approvals surfaced as a badge on the
/// matching nav item so the operator notices a new approval
/// without polling the page.
final int pendingApprovals;
const _Sidebar({ const _Sidebar({
required this.selectedIndex, required this.selectedIndex,
required this.onSelect, required this.onSelect,
@ -508,6 +521,7 @@ class _Sidebar extends StatefulWidget {
required this.connected, required this.connected,
required this.endpointLabel, required this.endpointLabel,
required this.activeChannel, required this.activeChannel,
this.pendingApprovals = 0,
}); });
@override @override
@ -672,6 +686,10 @@ class _SidebarState extends State<_Sidebar>
t: t, t: t,
labelsInteractive: labelsInteractive, labelsInteractive: labelsInteractive,
iconColumnWidth: _collapsedWidth, iconColumnWidth: _collapsedWidth,
badge: widget.pages[i].id == 'approvals' &&
widget.pendingApprovals > 0
? widget.pendingApprovals
: null,
onTap: () => widget.onSelect(i), onTap: () => widget.onSelect(i),
), ),
], ],
@ -1016,6 +1034,11 @@ class _SidebarItem extends StatefulWidget {
final double iconColumnWidth; final double iconColumnWidth;
final VoidCallback onTap; final VoidCallback onTap;
/// Optional count badge null hides it, non-null renders a
/// small accent pill on top-right of the icon. Used by the
/// Approvals item for pending count.
final int? badge;
const _SidebarItem({ const _SidebarItem({
required this.page, required this.page,
required this.selected, required this.selected,
@ -1023,6 +1046,7 @@ class _SidebarItem extends StatefulWidget {
required this.labelsInteractive, required this.labelsInteractive,
required this.iconColumnWidth, required this.iconColumnWidth,
required this.onTap, required this.onTap,
this.badge,
}); });
@override @override
@ -1047,11 +1071,45 @@ class _SidebarItemState extends State<_SidebarItem> {
: Colors.transparent; : Colors.transparent;
final label = widget.page.labelOf(context); final label = widget.page.labelOf(context);
final icon = Icon( Widget icon = Icon(
widget.selected ? widget.page.selectedIcon : widget.page.icon, widget.selected ? widget.page.selectedIcon : widget.page.icon,
size: 18, size: 18,
color: color, color: color,
); );
final badge = widget.badge;
if (badge != null) {
icon = Stack(
clipBehavior: Clip.none,
children: [
icon,
Positioned(
right: -6,
top: -4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 1,
),
constraints: const BoxConstraints(minWidth: 16, minHeight: 14),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(8),
),
child: Text(
badge > 9 ? '9+' : '$badge',
textAlign: TextAlign.center,
style: TextStyle(
color: theme.colorScheme.onPrimary,
fontSize: 9,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
),
),
],
);
}
// Label widget is ALWAYS in the tree opacity = t makes // Label widget is ALWAYS in the tree opacity = t makes
// it fade in/out in lockstep with the rail's width // it fade in/out in lockstep with the rail's width
// animation. Expanded's flex space goes from 0 (at t=0, // animation. Expanded's flex space goes from 0 (at t=0,

View file

@ -11,6 +11,7 @@ import '../data/hub_auth_token.dart';
import '../data/registry_token.dart'; import '../data/registry_token.dart';
import '../data/system_actions.dart'; import '../data/system_actions.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../pages/welcome.dart' show showFaiDoc;
import '../theme/theme.dart'; import '../theme/theme.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import 'fai_error_box.dart'; import 'fai_error_box.dart';
@ -394,17 +395,35 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
); );
} }
Widget _panelTitle(String title, String subtitle, ThemeData theme) { Widget _panelTitle(
String title,
String subtitle,
ThemeData theme, {
String? docSlug,
}) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: FaiSpace.lg), padding: const EdgeInsets.only(bottom: FaiSpace.lg),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
title, children: [
style: theme.textTheme.titleLarge?.copyWith( Expanded(
fontWeight: FontWeight.w700, child: Text(
), title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (docSlug != null)
IconButton(
icon: const Icon(Icons.help_outline, size: 18),
tooltip: AppLocalizations.of(context)!.helpTooltip,
onPressed: () => showFaiDoc(context, docSlug),
visualDensity: VisualDensity.compact,
),
],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
@ -424,6 +443,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
l.settingsTitle, l.settingsTitle,
l.settingsHubEndpointHint, l.settingsHubEndpointHint,
theme, theme,
docSlug: 'architecture',
), ),
TextField( TextField(
controller: _host, controller: _host,
@ -618,6 +638,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
l.settingsSecurityPanelTitle, l.settingsSecurityPanelTitle,
l.settingsSecurityPanelBody, l.settingsSecurityPanelBody,
theme, theme,
docSlug: 'security',
), ),
_RegistryCredentialsPanel( _RegistryCredentialsPanel(
configuredChars: _registryTokenChars, configuredChars: _registryTokenChars,
@ -639,6 +660,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
l.settingsMaintenancePanelTitle, l.settingsMaintenancePanelTitle,
l.settingsMaintenancePanelBody, l.settingsMaintenancePanelBody,
theme, theme,
docSlug: 'audit',
), ),
_MaintenancePanel( _MaintenancePanel(
onResetDone: () async { onResetDone: () async {

View file

@ -1,7 +1,7 @@
name: fai_studio name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub" description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none' publish_to: 'none'
version: 0.68.0 version: 0.69.0
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta