feat(doctor,shell): ollama host-service suggestion + hub-update hint (0.80.0)
Some checks failed
Security / Security check (push) Failing after 2s
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:
parent
351c5a82bc
commit
14f824b8ef
13 changed files with 703 additions and 135 deletions
166
lib/main.dart
166
lib/main.dart
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue