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
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -6,6 +6,23 @@ lockstep.
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added (0.80.0)
|
||||||
|
|
||||||
|
- **Ollama joins the host services.** Ollama-backed store entries
|
||||||
|
now declare `requires_services: [ollama]` hub-side, and the
|
||||||
|
doctor's services panel notices when the system AI uses an Ollama
|
||||||
|
endpoint that is not declared as a host service: one plain
|
||||||
|
sentence plus a one-click "declare as host service" (via the new
|
||||||
|
DeclareService RPC; states honestly that a daemon restart is
|
||||||
|
required).
|
||||||
|
- **The shell says when a hub update exists.** One slim, dismissible
|
||||||
|
banner after connecting when the release manifest offers a newer
|
||||||
|
hub version — dismissal is remembered per version, the doctor
|
||||||
|
page keeps the apply button. (Deliberately manifest-based:
|
||||||
|
Studio and hub version numbers are independent counters, so a
|
||||||
|
direct comparison would be wrong; unreleased dev skew stays with
|
||||||
|
the per-page "needs a newer hub" states.)
|
||||||
|
|
||||||
### Added (0.79.0)
|
### Added (0.79.0)
|
||||||
|
|
||||||
- **Maintainers in the store.** The detail sheet always answers "who
|
- **Maintainers in the store.** The detail sheet always answers "who
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
/// Studio's own build version. Bump on every UI release so the
|
/// Studio's own build version. Bump on every UI release so the
|
||||||
/// running app self-identifies.
|
/// running app self-identifies.
|
||||||
const String kStudioVersion = '0.79.0';
|
const String kStudioVersion = '0.80.0';
|
||||||
|
|
||||||
const String kProductName = 'Ch∆In Studio';
|
const String kProductName = 'Ch∆In Studio';
|
||||||
const String kVendorName = 'Flemming.AI (F∆I)';
|
const String kVendorName = 'Flemming.AI (F∆I)';
|
||||||
|
|
|
||||||
|
|
@ -1221,6 +1221,42 @@ class HubService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Composite "doctor" snapshot. One round-trip per piece, run
|
/// Composite "doctor" snapshot. One round-trip per piece, run
|
||||||
|
/// Declare a host service in the operator config. Returns true
|
||||||
|
/// when a daemon restart is required for it to take effect
|
||||||
|
/// (always, today — services are read at start).
|
||||||
|
Future<bool> declareService({
|
||||||
|
required String name,
|
||||||
|
required String endpoint,
|
||||||
|
String healthPath = '',
|
||||||
|
List<String> tags = const [],
|
||||||
|
}) => _client.declareService(
|
||||||
|
name: name,
|
||||||
|
endpoint: endpoint,
|
||||||
|
healthPath: healthPath,
|
||||||
|
tags: tags,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Lightweight update probe for the shell's one-time hint:
|
||||||
|
/// null when the manifest is unreachable / no update / any
|
||||||
|
/// error — the hint simply stays away then.
|
||||||
|
Future<UpdateStatus?> checkHubUpdate() async {
|
||||||
|
try {
|
||||||
|
final r = await _client.checkUpdate().timeout(
|
||||||
|
const Duration(seconds: 3),
|
||||||
|
);
|
||||||
|
if (!r.updateAvailable) return null;
|
||||||
|
return UpdateStatus(
|
||||||
|
channel: r.channel,
|
||||||
|
localVersion: r.localVersion,
|
||||||
|
latestVersion: r.latestVersion,
|
||||||
|
updateAvailable: r.updateAvailable,
|
||||||
|
manifestReachable: r.manifestReachable,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Version string of the RUNNING hub (from CheckUpdate's
|
/// Version string of the RUNNING hub (from CheckUpdate's
|
||||||
/// local_version — a local RPC field; the manifest fetch result
|
/// local_version — a local RPC field; the manifest fetch result
|
||||||
/// is ignored). Empty when the hub is unreachable. Used by
|
/// is ignored). Empty when the hub is unreachable. Used by
|
||||||
|
|
|
||||||
|
|
@ -1191,6 +1191,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctorSummaryDeclared": "deklariert",
|
"doctorSummaryDeclared": "deklariert",
|
||||||
|
"doctorOllamaSuggest": "Die System-KI nutzt Ollama auf {endpoint} — als Host-Dienst deklarieren, damit Module und Diagnose ihn kennen?",
|
||||||
|
"@doctorOllamaSuggest": {"placeholders": {"endpoint": {"type": "String"}}},
|
||||||
|
"doctorOllamaDeclareButton": "Als Host-Dienst deklarieren",
|
||||||
|
"doctorOllamaDeclared": "Als Host-Dienst „ollama“ deklariert — wirksam nach einem Daemon-Neustart (Knopf unten auf dieser Seite).",
|
||||||
|
"updateHintBanner": "Für den Hub ({local}) ist Version {latest} verfügbar.",
|
||||||
|
"@updateHintBanner": {"placeholders": {"local": {"type": "String"}, "latest": {"type": "String"}}},
|
||||||
|
"updateHintOpenDoctor": "Diagnose öffnen",
|
||||||
|
"updateHintDismiss": "Später",
|
||||||
"svcExposureLoopback": "nur lokal",
|
"svcExposureLoopback": "nur lokal",
|
||||||
"svcExposurePrivate": "privates Netz",
|
"svcExposurePrivate": "privates Netz",
|
||||||
"svcExposurePublic": "öffentlich erreichbar",
|
"svcExposurePublic": "öffentlich erreichbar",
|
||||||
|
|
|
||||||
|
|
@ -1215,6 +1215,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctorSummaryDeclared": "declared",
|
"doctorSummaryDeclared": "declared",
|
||||||
|
"doctorOllamaSuggest": "The system AI uses Ollama at {endpoint} — declare it as a host service so modules and the doctor know it?",
|
||||||
|
"@doctorOllamaSuggest": {"placeholders": {"endpoint": {"type": "String"}}},
|
||||||
|
"doctorOllamaDeclareButton": "Declare as host service",
|
||||||
|
"doctorOllamaDeclared": "Declared as host service \"ollama\" — takes effect after a daemon restart (button further down this page).",
|
||||||
|
"updateHintBanner": "Version {latest} is available for the hub ({local}).",
|
||||||
|
"@updateHintBanner": {"placeholders": {"local": {"type": "String"}, "latest": {"type": "String"}}},
|
||||||
|
"updateHintOpenDoctor": "Open Doctor",
|
||||||
|
"updateHintDismiss": "Later",
|
||||||
"svcExposureLoopback": "local only",
|
"svcExposureLoopback": "local only",
|
||||||
"svcExposurePrivate": "private network",
|
"svcExposurePrivate": "private network",
|
||||||
"svcExposurePublic": "publicly reachable",
|
"svcExposurePublic": "publicly reachable",
|
||||||
|
|
|
||||||
|
|
@ -3704,6 +3704,42 @@ abstract class AppLocalizations {
|
||||||
/// **'declared'**
|
/// **'declared'**
|
||||||
String get doctorSummaryDeclared;
|
String get doctorSummaryDeclared;
|
||||||
|
|
||||||
|
/// No description provided for @doctorOllamaSuggest.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The system AI uses Ollama at {endpoint} — declare it as a host service so modules and the doctor know it?'**
|
||||||
|
String doctorOllamaSuggest(String endpoint);
|
||||||
|
|
||||||
|
/// No description provided for @doctorOllamaDeclareButton.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Declare as host service'**
|
||||||
|
String get doctorOllamaDeclareButton;
|
||||||
|
|
||||||
|
/// No description provided for @doctorOllamaDeclared.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Declared as host service \"ollama\" — takes effect after a daemon restart (button further down this page).'**
|
||||||
|
String get doctorOllamaDeclared;
|
||||||
|
|
||||||
|
/// No description provided for @updateHintBanner.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Version {latest} is available for the hub ({local}).'**
|
||||||
|
String updateHintBanner(String local, String latest);
|
||||||
|
|
||||||
|
/// No description provided for @updateHintOpenDoctor.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Open Doctor'**
|
||||||
|
String get updateHintOpenDoctor;
|
||||||
|
|
||||||
|
/// No description provided for @updateHintDismiss.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Later'**
|
||||||
|
String get updateHintDismiss;
|
||||||
|
|
||||||
/// No description provided for @svcExposureLoopback.
|
/// No description provided for @svcExposureLoopback.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -2157,6 +2157,29 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get doctorSummaryDeclared => 'deklariert';
|
String get doctorSummaryDeclared => 'deklariert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String doctorOllamaSuggest(String endpoint) {
|
||||||
|
return 'Die System-KI nutzt Ollama auf $endpoint — als Host-Dienst deklarieren, damit Module und Diagnose ihn kennen?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get doctorOllamaDeclareButton => 'Als Host-Dienst deklarieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get doctorOllamaDeclared =>
|
||||||
|
'Als Host-Dienst „ollama“ deklariert — wirksam nach einem Daemon-Neustart (Knopf unten auf dieser Seite).';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String updateHintBanner(String local, String latest) {
|
||||||
|
return 'Für den Hub ($local) ist Version $latest verfügbar.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateHintOpenDoctor => 'Diagnose öffnen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateHintDismiss => 'Später';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get svcExposureLoopback => 'nur lokal';
|
String get svcExposureLoopback => 'nur lokal';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2160,6 +2160,29 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get doctorSummaryDeclared => 'declared';
|
String get doctorSummaryDeclared => 'declared';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String doctorOllamaSuggest(String endpoint) {
|
||||||
|
return 'The system AI uses Ollama at $endpoint — declare it as a host service so modules and the doctor know it?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get doctorOllamaDeclareButton => 'Declare as host service';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get doctorOllamaDeclared =>
|
||||||
|
'Declared as host service \"ollama\" — takes effect after a daemon restart (button further down this page).';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String updateHintBanner(String local, String latest) {
|
||||||
|
return 'Version $latest is available for the hub ($local).';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateHintOpenDoctor => 'Open Doctor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateHintDismiss => 'Later';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get svcExposureLoopback => 'local only';
|
String get svcExposureLoopback => 'local only';
|
||||||
|
|
||||||
|
|
|
||||||
166
lib/main.dart
166
lib/main.dart
|
|
@ -4,6 +4,7 @@
|
||||||
// Tokens in lib/theme/, primitives in lib/widgets/.
|
// Tokens in lib/theme/, primitives in lib/widgets/.
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:chain_client_sdk/chain_client_sdk.dart';
|
import 'package:chain_client_sdk/chain_client_sdk.dart';
|
||||||
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
|
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
|
||||||
|
|
@ -285,8 +286,7 @@ class StudioAppState extends State<StudioApp> {
|
||||||
startSidebarExpanded: widget.startSidebarExpanded,
|
startSidebarExpanded: widget.startSidebarExpanded,
|
||||||
)
|
)
|
||||||
: SetupGateScreen(
|
: SetupGateScreen(
|
||||||
onDone: () =>
|
onDone: () => setState(() => _setupGateDone = true),
|
||||||
setState(() => _setupGateDone = true),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -375,9 +375,9 @@ class StudioShellState extends State<StudioShell> {
|
||||||
final override = debugReloadTokenOverride;
|
final override = debugReloadTokenOverride;
|
||||||
if (override != null) return override();
|
if (override != null) return override();
|
||||||
if (debugProbeOverride != null) return Future.value(false);
|
if (debugProbeOverride != null) return Future.value(false);
|
||||||
return HubService.instance
|
return HubService.instance.reloadAuthTokenIfChanged().catchError(
|
||||||
.reloadAuthTokenIfChanged()
|
(_) => false,
|
||||||
.catchError((_) => false);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Current connection state, for descendants (e.g. WelcomePage)
|
/// Current connection state, for descendants (e.g. WelcomePage)
|
||||||
|
|
@ -392,9 +392,9 @@ class StudioShellState extends State<StudioShell> {
|
||||||
final r = await SystemActions.chainDaemon(['start']);
|
final r = await SystemActions.chainDaemon(['start']);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text(l.daemonStartRequested)),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text(l.daemonStartRequested)));
|
||||||
Future.delayed(const Duration(seconds: 1), _checkHealth);
|
Future.delayed(const Duration(seconds: 1), _checkHealth);
|
||||||
return;
|
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 {
|
Future<void> _checkHealth({bool retriedAfterTokenReload = false}) async {
|
||||||
final probe = debugProbeOverride != null
|
final probe = debugProbeOverride != null
|
||||||
? await debugProbeOverride!()
|
? await debugProbeOverride!()
|
||||||
|
|
@ -528,6 +565,10 @@ class StudioShellState extends State<StudioShell> {
|
||||||
final endpointChanged = endpoint != _polledEndpoint;
|
final endpointChanged = endpoint != _polledEndpoint;
|
||||||
_polledEndpoint = endpoint;
|
_polledEndpoint = endpoint;
|
||||||
final ok = probe == HubProbeResult.serving;
|
final ok = probe == HubProbeResult.serving;
|
||||||
|
if (ok) {
|
||||||
|
// Fire-and-forget; guarded by _updateProbed.
|
||||||
|
unawaited(_probeUpdateOnce());
|
||||||
|
}
|
||||||
final authRejected = probe == HubProbeResult.authRejected;
|
final authRejected = probe == HubProbeResult.authRejected;
|
||||||
final wasUnreachable = _hubUnreachable;
|
final wasUnreachable = _hubUnreachable;
|
||||||
final nextFailed = ok ? 0 : (endpointChanged ? 1 : _failedPolls + 1);
|
final nextFailed = ok ? 0 : (endpointChanged ? 1 : _failedPolls + 1);
|
||||||
|
|
@ -561,7 +602,9 @@ class StudioShellState extends State<StudioShell> {
|
||||||
if (_activeChannel != snap.active) {
|
if (_activeChannel != snap.active) {
|
||||||
setState(() => _activeChannel = snap.active);
|
setState(() => _activeChannel = snap.active);
|
||||||
}
|
}
|
||||||
} catch (_) {/* best-effort */}
|
} catch (_) {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// The badge counts the active workspace's pending approvals
|
// The badge counts the active workspace's pending approvals
|
||||||
// (empty slug = all projects), matching the Approvals page.
|
// (empty slug = all projects), matching the Approvals page.
|
||||||
|
|
@ -572,7 +615,9 @@ class StudioShellState extends State<StudioShell> {
|
||||||
if (_pendingApprovals != pending.length) {
|
if (_pendingApprovals != pending.length) {
|
||||||
setState(() => _pendingApprovals = pending.length);
|
setState(() => _pendingApprovals = pending.length);
|
||||||
}
|
}
|
||||||
} catch (_) {/* best-effort */}
|
} catch (_) {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -696,11 +741,21 @@ class StudioShellState extends State<StudioShell> {
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const ChainSealedIdentityBar(),
|
const ChainSealedIdentityBar(),
|
||||||
|
if (_updateHint != null)
|
||||||
|
_UpdateHintBanner(
|
||||||
|
status: _updateHint!,
|
||||||
|
onOpenDoctor: () {
|
||||||
|
navigateTo('doctor');
|
||||||
|
unawaited(_dismissUpdateHint());
|
||||||
|
},
|
||||||
|
onDismiss: _dismissUpdateHint,
|
||||||
|
),
|
||||||
if (_hubUnreachable)
|
if (_hubUnreachable)
|
||||||
_HubUnreachableBanner(
|
_HubUnreachableBanner(
|
||||||
endpoint: HubService.instance.endpointLabel,
|
endpoint: HubService.instance.endpointLabel,
|
||||||
authRejected: _authRejected,
|
authRejected: _authRejected,
|
||||||
onOpenSettings: () => ChainSettingsDialog.show(context),
|
onOpenSettings: () =>
|
||||||
|
ChainSettingsDialog.show(context),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
|
|
@ -916,9 +971,9 @@ class _SidebarState extends State<_Sidebar>
|
||||||
final r = await SystemActions.chainDaemon(['start']);
|
final r = await SystemActions.chainDaemon(['start']);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text(l.daemonStartRequested)),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text(l.daemonStartRequested)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The daemon may already be running (port in use) — probe before
|
// The daemon may already be running (port in use) — probe before
|
||||||
|
|
@ -1064,14 +1119,17 @@ class _SidebarState extends State<_Sidebar>
|
||||||
t: t,
|
t: t,
|
||||||
labelsInteractive: labelsInteractive,
|
labelsInteractive: labelsInteractive,
|
||||||
iconColumnWidth: _collapsedWidth,
|
iconColumnWidth: _collapsedWidth,
|
||||||
badge: widget.pages[i].id == 'approvals' &&
|
badge:
|
||||||
|
widget.pages[i].id == 'approvals' &&
|
||||||
widget.pendingApprovals > 0
|
widget.pendingApprovals > 0
|
||||||
? widget.pendingApprovals
|
? widget.pendingApprovals
|
||||||
: null,
|
: null,
|
||||||
// Cmd+1..9 jump to the destination; the
|
// Cmd+1..9 jump to the destination; the
|
||||||
// hint rides in tooltip + expanded label
|
// hint rides in tooltip + expanded label
|
||||||
// so the shortcut is discoverable.
|
// so the shortcut is discoverable.
|
||||||
shortcutHint: i < 9 ? _metaShortcut('${i + 1}') : null,
|
shortcutHint: i < 9
|
||||||
|
? _metaShortcut('${i + 1}')
|
||||||
|
: null,
|
||||||
onTap: () => widget.onSelect(i),
|
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),
|
HubEndpoint(host: '127.0.0.1', port: target.port),
|
||||||
persist: false,
|
persist: false,
|
||||||
);
|
);
|
||||||
} catch (_) {/* health poll will retry */}
|
} catch (_) {
|
||||||
|
/* health poll will retry */
|
||||||
|
}
|
||||||
if (anchor.mounted) {
|
if (anchor.mounted) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(content: Text(l.channelSwitchOk(selected))),
|
SnackBar(content: Text(l.channelSwitchOk(selected))),
|
||||||
|
|
@ -1549,7 +1609,9 @@ class _ConnectionLabel extends StatelessWidget {
|
||||||
// which reports the *active* channel's daemon.
|
// which reports the *active* channel's daemon.
|
||||||
final ch = HubService.instance.connectedChannelName;
|
final ch = HubService.instance.connectedChannelName;
|
||||||
final caption = connected == true
|
final caption = connected == true
|
||||||
? (ch != null ? '${l.connectionConnected} · $ch' : l.connectionConnected)
|
? (ch != null
|
||||||
|
? '${l.connectionConnected} · $ch'
|
||||||
|
: l.connectionConnected)
|
||||||
: connected == false
|
: connected == false
|
||||||
? l.connectionTapToStart
|
? l.connectionTapToStart
|
||||||
: l.connectionConnecting;
|
: l.connectionConnecting;
|
||||||
|
|
@ -1653,10 +1715,7 @@ class _SidebarItemState extends State<_SidebarItem> {
|
||||||
right: -6,
|
right: -6,
|
||||||
top: -4,
|
top: -4,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||||
horizontal: 5,
|
|
||||||
vertical: 1,
|
|
||||||
),
|
|
||||||
constraints: const BoxConstraints(minWidth: 16, minHeight: 14),
|
constraints: const BoxConstraints(minWidth: 16, minHeight: 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.primary,
|
color: theme.colorScheme.primary,
|
||||||
|
|
@ -2187,3 +2246,66 @@ class _LanguageToggle extends StatelessWidget {
|
||||||
// the editor directly with the full runDriver bridge. There
|
// the editor directly with the full runDriver bridge. There
|
||||||
// is one Flows destination; the standalone editor route is
|
// is one Flows destination; the standalone editor route is
|
||||||
// gone.
|
// 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -392,8 +392,11 @@ class _EventLogPanel extends StatelessWidget {
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onOpenAudit,
|
onTap: onOpenAudit,
|
||||||
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
||||||
child: Semantics(button: true, label: l.doctorLinkAudit,
|
child: Semantics(
|
||||||
child: headline),
|
button: true,
|
||||||
|
label: l.doctorLinkAudit,
|
||||||
|
child: headline,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -539,7 +542,8 @@ class _PathRow extends StatelessWidget {
|
||||||
// Text config files get an in-Studio viewer too (read top-down,
|
// Text config files get an in-Studio viewer too (read top-down,
|
||||||
// no log colouring) so the operator can read config.yaml
|
// no log colouring) so the operator can read config.yaml
|
||||||
// without leaving Studio or hunting for an external editor.
|
// without leaving Studio or hunting for an external editor.
|
||||||
final isConfig = !isDirectory &&
|
final isConfig =
|
||||||
|
!isDirectory &&
|
||||||
(lower.endsWith('.yaml') ||
|
(lower.endsWith('.yaml') ||
|
||||||
lower.endsWith('.yml') ||
|
lower.endsWith('.yml') ||
|
||||||
lower.endsWith('.toml'));
|
lower.endsWith('.toml'));
|
||||||
|
|
@ -575,9 +579,9 @@ class _PathRow extends StatelessWidget {
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await Clipboard.setData(ClipboardData(text: path));
|
await Clipboard.setData(ClipboardData(text: path));
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text(l.aboutCopiedToast)),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text(l.aboutCopiedToast)));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: ChainSpace.xs),
|
const SizedBox(width: ChainSpace.xs),
|
||||||
|
|
@ -917,9 +921,7 @@ String _sourceKindLabel(String kind) {
|
||||||
case 'system':
|
case 'system':
|
||||||
return 'System';
|
return 'System';
|
||||||
default:
|
default:
|
||||||
return kind.isEmpty
|
return kind.isEmpty ? kind : kind[0].toUpperCase() + kind.substring(1);
|
||||||
? kind
|
|
||||||
: kind[0].toUpperCase() + kind.substring(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1065,20 +1067,106 @@ class DoctorModulesPanel extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ServicesPanel extends StatelessWidget {
|
class _ServicesPanel extends StatefulWidget {
|
||||||
final DoctorSnapshot snapshot;
|
final DoctorSnapshot snapshot;
|
||||||
|
|
||||||
const _ServicesPanel({required this.snapshot});
|
const _ServicesPanel({required this.snapshot});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ServicesPanel> createState() => _ServicesPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServicesPanelState extends State<_ServicesPanel> {
|
||||||
|
DoctorSnapshot get snapshot => widget.snapshot;
|
||||||
|
|
||||||
|
/// Best-effort system-AI probe for the Ollama suggestion; null
|
||||||
|
/// (no suggestion) on any failure.
|
||||||
|
SystemAiStatus? _ai;
|
||||||
|
|
||||||
|
/// True once the operator declared the suggested service this
|
||||||
|
/// session — the hub's services() stays the boot snapshot, so
|
||||||
|
/// the row flips to the honest "takes effect after restart"
|
||||||
|
/// note instead of re-suggesting.
|
||||||
|
bool _declared = false;
|
||||||
|
bool _declaring = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
HubService.instance
|
||||||
|
.systemAiStatus()
|
||||||
|
.then((st) {
|
||||||
|
if (mounted) setState(() => _ai = st);
|
||||||
|
})
|
||||||
|
.catchError((_) {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _declare(String endpoint) async {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
setState(() => _declaring = true);
|
||||||
|
try {
|
||||||
|
await HubService.instance.declareService(
|
||||||
|
name: 'ollama',
|
||||||
|
endpoint: endpoint,
|
||||||
|
healthPath: '/',
|
||||||
|
tags: const ['llm'],
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_declared = true;
|
||||||
|
_declaring = false;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(l.doctorOllamaDeclared)));
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _declaring = false);
|
||||||
|
showChainErrorSnack(context, 'doctor.declare-service', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The suggestion strip, or null when there is nothing to say.
|
||||||
|
Widget? _suggestionRow(AppLocalizations l) {
|
||||||
|
final ai = _ai;
|
||||||
|
if (ai == null || _declared) {
|
||||||
|
return _declared
|
||||||
|
? ChainInlineHelp(
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
text: l.doctorOllamaDeclared,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
final endpoint = suggestOllamaServiceEndpoint(
|
||||||
|
ai: ai,
|
||||||
|
services: snapshot.services,
|
||||||
|
);
|
||||||
|
if (endpoint == null) return null;
|
||||||
|
return ChainInlineHelp(
|
||||||
|
icon: Icons.dns_outlined,
|
||||||
|
text: l.doctorOllamaSuggest(endpoint),
|
||||||
|
onLearnMore: _declaring ? null : () => _declare(endpoint),
|
||||||
|
learnMoreLabel: l.doctorOllamaDeclareButton,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final suggestion = _suggestionRow(l);
|
||||||
if (snapshot.services.isEmpty) {
|
if (snapshot.services.isEmpty) {
|
||||||
// Wrap on narrow windows so the mono-spaced hint
|
// Wrap on narrow windows so the mono-spaced hint
|
||||||
// (`add to ~/.chain/config.yaml under services:`) does not
|
// (`add to ~/.chain/config.yaml under services:`) does not
|
||||||
// overflow horizontally past the card's right edge.
|
// overflow horizontally past the card's right edge.
|
||||||
return ChainCard(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (suggestion != null) ...[
|
||||||
|
suggestion,
|
||||||
|
const SizedBox(height: ChainSpace.sm),
|
||||||
|
],
|
||||||
|
ChainCard(
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
spacing: ChainSpace.sm,
|
spacing: ChainSpace.sm,
|
||||||
runSpacing: ChainSpace.xs,
|
runSpacing: ChainSpace.xs,
|
||||||
|
|
@ -1125,9 +1213,18 @@ class _ServicesPanel extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ChainCard(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (suggestion != null) ...[
|
||||||
|
suggestion,
|
||||||
|
const SizedBox(height: ChainSpace.sm),
|
||||||
|
],
|
||||||
|
ChainCard(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -1172,10 +1269,44 @@ class _ServicesPanel extends StatelessWidget {
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The system AI's Ollama endpoint as a host-service suggestion,
|
||||||
|
/// or null when there is nothing to suggest: AI disabled, another
|
||||||
|
/// provider, or a declared service already covers the same
|
||||||
|
/// host:port. Returns the base URL (the /v1 suffix the OpenAI-
|
||||||
|
/// compatible config carries is stripped — the service entry
|
||||||
|
/// describes the server, not one API flavour). Top-level so the
|
||||||
|
/// unit test drives every branch.
|
||||||
|
String? suggestOllamaServiceEndpoint({
|
||||||
|
required SystemAiStatus ai,
|
||||||
|
required List<ServiceEntry> services,
|
||||||
|
}) {
|
||||||
|
if (!ai.enabled || ai.provider != 'ollama' || ai.endpoint.trim().isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var base = ai.endpoint.trim();
|
||||||
|
for (final suffix in ['/v1/', '/v1']) {
|
||||||
|
if (base.endsWith(suffix)) {
|
||||||
|
base = base.substring(0, base.length - suffix.length);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final target = Uri.tryParse(base);
|
||||||
|
if (target == null || target.host.isEmpty) return null;
|
||||||
|
for (final s in services) {
|
||||||
|
final u = Uri.tryParse(s.endpoint);
|
||||||
|
if (u != null && u.host == target.host && u.port == target.port) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
/// Network-reach pill per declared service, from the hub's
|
/// Network-reach pill per declared service, from the hub's
|
||||||
/// endpoint classification (`DeclaredService.exposure`). Public
|
/// endpoint classification (`DeclaredService.exposure`). Public
|
||||||
/// endpoints get the warning tone — a host service reachable from
|
/// endpoints get the warning tone — a host service reachable from
|
||||||
|
|
@ -1296,20 +1427,21 @@ class _UpdateBannerState extends State<_UpdateBanner> {
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => SystemActions.openInOs(
|
onTap: () =>
|
||||||
status.releaseNotesUrl!,
|
SystemActions.openInOs(status.releaseNotesUrl!),
|
||||||
),
|
|
||||||
child: Semantics(
|
child: Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
label: l.doctorLinkReleaseNotes,
|
label: l.doctorLinkReleaseNotes,
|
||||||
child: Text(
|
child: Text(
|
||||||
l.doctorReleaseNotes(status.releaseNotesUrl!),
|
l.doctorReleaseNotes(status.releaseNotesUrl!),
|
||||||
style: ChainTheme.mono(
|
style:
|
||||||
|
ChainTheme.mono(
|
||||||
size: 11,
|
size: 11,
|
||||||
color: theme.colorScheme.primary,
|
color: theme.colorScheme.primary,
|
||||||
).copyWith(
|
).copyWith(
|
||||||
decoration: TextDecoration.underline,
|
decoration: TextDecoration.underline,
|
||||||
decorationColor: theme.colorScheme.primary,
|
decorationColor:
|
||||||
|
theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: chain_studio
|
name: chain_studio
|
||||||
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
|
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.79.0
|
version: 0.80.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
|
||||||
149
test/ollama_service_suggestion_test.dart
Normal file
149
test/ollama_service_suggestion_test.dart
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
// The doctor's "declare the system AI's Ollama as a host service"
|
||||||
|
// suggestion and the shell's one-time update hint — both are pure
|
||||||
|
// functions so every branch is pinned here.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:chain_studio/data/hub.dart';
|
||||||
|
import 'package:chain_studio/main.dart'
|
||||||
|
show StudioApp, shouldShowUpdateHint;
|
||||||
|
import 'package:chain_studio/pages/doctor.dart'
|
||||||
|
show suggestOllamaServiceEndpoint;
|
||||||
|
|
||||||
|
import 'support/fake_hub.dart';
|
||||||
|
|
||||||
|
SystemAiStatus _ai({
|
||||||
|
bool enabled = true,
|
||||||
|
String provider = 'ollama',
|
||||||
|
String endpoint = 'http://127.0.0.1:11434/v1',
|
||||||
|
}) => SystemAiStatus(
|
||||||
|
enabled: enabled,
|
||||||
|
provider: provider,
|
||||||
|
endpoint: endpoint,
|
||||||
|
model: 'qwen2.5:14b',
|
||||||
|
privacyMode: 'off',
|
||||||
|
apiKeyEnv: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('suggestOllamaServiceEndpoint', () {
|
||||||
|
test('suggests the base URL with the /v1 suffix stripped', () {
|
||||||
|
expect(
|
||||||
|
suggestOllamaServiceEndpoint(ai: _ai(), services: const []),
|
||||||
|
'http://127.0.0.1:11434',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing to suggest when AI is off or another provider', () {
|
||||||
|
expect(
|
||||||
|
suggestOllamaServiceEndpoint(ai: _ai(enabled: false), services: const []),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
suggestOllamaServiceEndpoint(
|
||||||
|
ai: _ai(provider: 'openai'),
|
||||||
|
services: const [],
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
suggestOllamaServiceEndpoint(ai: _ai(endpoint: ''), services: const []),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a declared service on the same host:port silences it', () {
|
||||||
|
expect(
|
||||||
|
suggestOllamaServiceEndpoint(
|
||||||
|
ai: _ai(),
|
||||||
|
services: const [
|
||||||
|
ServiceEntry(
|
||||||
|
name: 'my-llm',
|
||||||
|
endpoint: 'http://127.0.0.1:11434',
|
||||||
|
tags: [],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a service on another port does not silence it', () {
|
||||||
|
expect(
|
||||||
|
suggestOllamaServiceEndpoint(
|
||||||
|
ai: _ai(),
|
||||||
|
services: const [
|
||||||
|
ServiceEntry(
|
||||||
|
name: 'judge-ner',
|
||||||
|
endpoint: 'http://127.0.0.1:8756',
|
||||||
|
tags: [],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
'http://127.0.0.1:11434',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the shell hints once and dismisses persistently', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final fake = installFakeHub();
|
||||||
|
fake.updateHint = const UpdateStatus(
|
||||||
|
channel: 'stable',
|
||||||
|
localVersion: '0.22.0',
|
||||||
|
latestVersion: '0.23.0',
|
||||||
|
updateAvailable: true,
|
||||||
|
manifestReachable: true,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const StudioApp(
|
||||||
|
initialThemeMode: ThemeModeValue.dark,
|
||||||
|
initialLocale: Locale('de'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (var i = 0; i < 8; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
expect(find.textContaining('0.23.0'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Später'));
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
expect(find.textContaining('0.23.0'), findsNothing);
|
||||||
|
// Dismissal is persisted per version.
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(prefs.getString('update.hint.dismissed'), '0.23.0');
|
||||||
|
// Drain shell timers (a11y-suite pattern).
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(minutes: 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
group('shouldShowUpdateHint', () {
|
||||||
|
test('hints once per release version', () {
|
||||||
|
expect(
|
||||||
|
shouldShowUpdateHint(latestVersion: '0.23.0', dismissedVersion: null),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldShowUpdateHint(
|
||||||
|
latestVersion: '0.23.0',
|
||||||
|
dismissedVersion: '0.22.0',
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldShowUpdateHint(
|
||||||
|
latestVersion: '0.23.0',
|
||||||
|
dismissedVersion: '0.23.0',
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldShowUpdateHint(latestVersion: '', dismissedVersion: null),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -257,6 +257,20 @@ class FakeHubService extends Fake implements HubService {
|
||||||
Future<List<N8nEndpointInfo>> listN8nEndpoints() =>
|
Future<List<N8nEndpointInfo>> listN8nEndpoints() =>
|
||||||
_async('listN8nEndpoints', () => const []);
|
_async('listN8nEndpoints', () => const []);
|
||||||
|
|
||||||
|
/// Scriptable update hint for the shell banner test.
|
||||||
|
UpdateStatus? updateHint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UpdateStatus?> checkHubUpdate() async => updateHint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> declareService({
|
||||||
|
required String name,
|
||||||
|
required String endpoint,
|
||||||
|
String healthPath = '',
|
||||||
|
List<String> tags = const [],
|
||||||
|
}) => _async('declareService', () => true);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<DoctorSnapshot> doctor() => _async(
|
Future<DoctorSnapshot> doctor() => _async(
|
||||||
'doctor',
|
'doctor',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue