feat(doctor,shell): ollama host-service suggestion + hub-update hint (0.80.0)
Some checks failed
Security / Security check (push) Failing after 2s

Doctor: when the system AI uses an Ollama endpoint that no declared
host service covers (host:port match, /v1 suffix stripped), the
services panel says so in one sentence with a one-click 'declare as
host service' via the new DeclareService RPC — and states honestly
that it takes effect after a daemon restart (the restart button sits
on the same page). Pure suggestOllamaServiceEndpoint pins every
branch.

Shell: one slim, dismissible banner after connecting when the
release manifest offers a newer hub version; dismissal is persisted
per version so each release hints exactly once (pure
shouldShowUpdateHint + a widget test through the fake hub).
Deliberately manifest-based — Studio and hub versions are
independent counters, so a direct comparison would be wrong;
unreleased dev skew stays with the per-page classified states.
The probe stays inert under the test probe override: its timeout
timer leaked into hub_banner_test (the hermeticity class again).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-23 00:03:42 +02:00
parent 351c5a82bc
commit 14f824b8ef
13 changed files with 703 additions and 135 deletions

View file

@ -4,6 +4,7 @@
// Tokens in lib/theme/, primitives in lib/widgets/.
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chain_client_sdk/chain_client_sdk.dart';
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
@ -285,8 +286,7 @@ class StudioAppState extends State<StudioApp> {
startSidebarExpanded: widget.startSidebarExpanded,
)
: SetupGateScreen(
onDone: () =>
setState(() => _setupGateDone = true),
onDone: () => setState(() => _setupGateDone = true),
),
);
},
@ -375,9 +375,9 @@ class StudioShellState extends State<StudioShell> {
final override = debugReloadTokenOverride;
if (override != null) return override();
if (debugProbeOverride != null) return Future.value(false);
return HubService.instance
.reloadAuthTokenIfChanged()
.catchError((_) => false);
return HubService.instance.reloadAuthTokenIfChanged().catchError(
(_) => false,
);
}
/// Current connection state, for descendants (e.g. WelcomePage)
@ -392,9 +392,9 @@ class StudioShellState extends State<StudioShell> {
final r = await SystemActions.chainDaemon(['start']);
if (!mounted) return;
if (r.ok) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.daemonStartRequested)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.daemonStartRequested)));
Future.delayed(const Duration(seconds: 1), _checkHealth);
return;
}
@ -515,6 +515,43 @@ class StudioShellState extends State<StudioShell> {
);
}
/// One-time hub-update hint (zero-learning-curve: the shell says
/// actively that a newer hub release exists instead of hiding it
/// on the doctor page). Null = nothing to show. Dismissal is
/// persisted per latest-version, so each release hints once.
UpdateStatus? _updateHint;
bool _updateProbed = false;
Future<void> _probeUpdateOnce() async {
if (_updateProbed) return;
_updateProbed = true;
// Inert under a test probe override (same rule as
// _reloadTokenIfChanged): those suites drive the shell against
// the REAL HubService, and the update probe's timeout timer
// would leak past the test body. Banner tests inject the fake
// hub instead, which answers without timers.
if (debugProbeOverride != null) return;
final st = await HubService.instance.checkHubUpdate();
if (!mounted || st == null) return;
final prefs = await SharedPreferences.getInstance();
final dismissed = prefs.getString('update.hint.dismissed');
if (!mounted) return;
if (shouldShowUpdateHint(
latestVersion: st.latestVersion,
dismissedVersion: dismissed,
)) {
setState(() => _updateHint = st);
}
}
Future<void> _dismissUpdateHint() async {
final latest = _updateHint?.latestVersion;
setState(() => _updateHint = null);
if (latest == null) return;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('update.hint.dismissed', latest);
}
Future<void> _checkHealth({bool retriedAfterTokenReload = false}) async {
final probe = debugProbeOverride != null
? await debugProbeOverride!()
@ -528,6 +565,10 @@ class StudioShellState extends State<StudioShell> {
final endpointChanged = endpoint != _polledEndpoint;
_polledEndpoint = endpoint;
final ok = probe == HubProbeResult.serving;
if (ok) {
// Fire-and-forget; guarded by _updateProbed.
unawaited(_probeUpdateOnce());
}
final authRejected = probe == HubProbeResult.authRejected;
final wasUnreachable = _hubUnreachable;
final nextFailed = ok ? 0 : (endpointChanged ? 1 : _failedPolls + 1);
@ -561,7 +602,9 @@ class StudioShellState extends State<StudioShell> {
if (_activeChannel != snap.active) {
setState(() => _activeChannel = snap.active);
}
} catch (_) {/* best-effort */}
} catch (_) {
/* best-effort */
}
try {
// The badge counts the active workspace's pending approvals
// (empty slug = all projects), matching the Approvals page.
@ -572,7 +615,9 @@ class StudioShellState extends State<StudioShell> {
if (_pendingApprovals != pending.length) {
setState(() => _pendingApprovals = pending.length);
}
} catch (_) {/* best-effort */}
} catch (_) {
/* best-effort */
}
}
}
@ -696,11 +741,21 @@ class StudioShellState extends State<StudioShell> {
child: Column(
children: [
const ChainSealedIdentityBar(),
if (_updateHint != null)
_UpdateHintBanner(
status: _updateHint!,
onOpenDoctor: () {
navigateTo('doctor');
unawaited(_dismissUpdateHint());
},
onDismiss: _dismissUpdateHint,
),
if (_hubUnreachable)
_HubUnreachableBanner(
endpoint: HubService.instance.endpointLabel,
authRejected: _authRejected,
onOpenSettings: () => ChainSettingsDialog.show(context),
onOpenSettings: () =>
ChainSettingsDialog.show(context),
),
Expanded(
child: AnimatedSwitcher(
@ -916,9 +971,9 @@ class _SidebarState extends State<_Sidebar>
final r = await SystemActions.chainDaemon(['start']);
if (!context.mounted) return;
if (r.ok) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.daemonStartRequested)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.daemonStartRequested)));
return;
}
// The daemon may already be running (port in use) probe before
@ -1064,14 +1119,17 @@ class _SidebarState extends State<_Sidebar>
t: t,
labelsInteractive: labelsInteractive,
iconColumnWidth: _collapsedWidth,
badge: widget.pages[i].id == 'approvals' &&
badge:
widget.pages[i].id == 'approvals' &&
widget.pendingApprovals > 0
? widget.pendingApprovals
: null,
// Cmd+1..9 jump to the destination; the
// hint rides in tooltip + expanded label
// so the shortcut is discoverable.
shortcutHint: i < 9 ? _metaShortcut('${i + 1}') : null,
shortcutHint: i < 9
? _metaShortcut('${i + 1}')
: null,
onTap: () => widget.onSelect(i),
),
],
@ -1308,7 +1366,9 @@ Future<void> _showChannelSwitchMenu(BuildContext anchor, String current) async {
HubEndpoint(host: '127.0.0.1', port: target.port),
persist: false,
);
} catch (_) {/* health poll will retry */}
} catch (_) {
/* health poll will retry */
}
if (anchor.mounted) {
messenger.showSnackBar(
SnackBar(content: Text(l.channelSwitchOk(selected))),
@ -1549,7 +1609,9 @@ class _ConnectionLabel extends StatelessWidget {
// which reports the *active* channel's daemon.
final ch = HubService.instance.connectedChannelName;
final caption = connected == true
? (ch != null ? '${l.connectionConnected} · $ch' : l.connectionConnected)
? (ch != null
? '${l.connectionConnected} · $ch'
: l.connectionConnected)
: connected == false
? l.connectionTapToStart
: l.connectionConnecting;
@ -1653,10 +1715,7 @@ class _SidebarItemState extends State<_SidebarItem> {
right: -6,
top: -4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 1,
),
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
constraints: const BoxConstraints(minWidth: 16, minHeight: 14),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
@ -2187,3 +2246,66 @@ class _LanguageToggle extends StatelessWidget {
// the editor directly with the full runDriver bridge. There
// is one Flows destination; the standalone editor route is
// gone.
/// Whether the one-time update hint should show for [latestVersion]
/// given the persisted [dismissedVersion]. Pure so the unit test
/// drives it: each release hints exactly once.
bool shouldShowUpdateHint({
required String latestVersion,
required String? dismissedVersion,
}) {
if (latestVersion.isEmpty) return false;
return latestVersion != dismissedVersion;
}
/// Slim, dismissible banner: a newer hub release exists. Neutral
/// tone (informational, not an error); the doctor page holds the
/// apply button.
class _UpdateHintBanner extends StatelessWidget {
final UpdateStatus status;
final VoidCallback onOpenDoctor;
final VoidCallback onDismiss;
const _UpdateHintBanner({
required this.status,
required this.onOpenDoctor,
required this.onDismiss,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Material(
color: theme.colorScheme.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: ChainSpace.lg,
vertical: ChainSpace.xs,
),
child: Row(
children: [
Icon(
Icons.system_update_alt_outlined,
size: 16,
color: theme.colorScheme.primary,
),
const SizedBox(width: ChainSpace.sm),
Expanded(
child: Text(
l.updateHintBanner(status.localVersion, status.latestVersion),
style: theme.textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
TextButton(
onPressed: onOpenDoctor,
child: Text(l.updateHintOpenDoctor),
),
TextButton(onPressed: onDismiss, child: Text(l.updateHintDismiss)),
],
),
),
);
}
}