diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e414ae..a0ab36e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ lockstep. ## Unreleased +### Changed (0.82.0) + +Workspace-switcher rebuild (persona review 2026-08-27; both +high-rated findings fixed): + +- **Sealed-switch privacy race closed.** A connection switch now + runs inside an explicit switching window: the identity bar + announces the target BEFORE the client re-points, page pollers + pause, replies from the previous hub are dropped, and the sealed + context is announced only after the new hub answered healthy. + Guard: `workspace_switch_race_test`. +- **Project filter survives the sealed round trip.** Entering an + area parks the shared-hub filter; returning restores it — live + state and prefs agree again. +- **One global switcher anchor.** The switcher lives once in the + sidebar (context visible on every page, Cmd+P opens it); the four + per-page embeddings are gone. The sidebar endpoint label updates + with the workspace instead of lagging until the next health tick. +- **Stopped areas ask before starting** ("Bereich starten?") — + running areas keep switching with one click. +- **Honest scope tooltip** (the switcher applies everywhere, not + "this view"), **why-line at the aggregated sealed row** with a + jump to Settings → Security, **checkmark on the active entry**, + and the Cmd+K palette knows projects/areas (recent-use ranked, + sealed names honour the privacy setting). +- **Sealed-aware flow list.** Inside a sealed area the editor lists + the instance's own flows dir (it used to keep showing the shared + hub's files), the editor state is dropped on a connection switch, + sample flows follow the hub's FlowSummary.sample flag (chip + + collapsed "Beispiele" group, editor 0.26.0), and an empty sealed + list offers "Beispiel-Flows importieren". +- **Runs empty state names the filter** ("Keine Läufe in Projekt X" + + show-all action) instead of claiming the feature is off. + ### Changed (0.81.0) - **Approvals explain themselves.** The pending approval card now diff --git a/integration_test/workspace_shots_test.dart b/integration_test/workspace_shots_test.dart new file mode 100644 index 0000000..490916b --- /dev/null +++ b/integration_test/workspace_shots_test.dart @@ -0,0 +1,193 @@ +// Workspace-anchor capture harness — visual proof for the sidebar +// switcher rebuild (persona review 2026-08-27). Renders the full +// shell against the scriptable fake hub and writes a PNG per theme +// via a RepaintBoundary (driverless, headed macOS `flutter test`). +// +// flutter test integration_test/workspace_shots_test.dart -d macos +// +// Output: build/workspace-shots/-.png +// (or $WORKSPACE_SHOTS_OUT). +// +// Fixture names are deliberately generic — never real client or +// area names. + +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chain_client_sdk/chain_client_sdk.dart' show HubEndpoint; +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/data/sealed_areas.dart'; +import 'package:chain_studio/data/sidebar_prefs.dart'; +import 'package:chain_studio/data/workspace.dart'; +import 'package:chain_studio/data/workspace_prefs.dart'; +import 'package:chain_studio/main.dart'; +import 'package:chain_studio/widgets/chain_workspace_switcher.dart'; + +import '../test/support/fake_hub.dart'; + +final GlobalKey _shotKey = GlobalKey(); + +String get _outDir => + Platform.environment['WORKSPACE_SHOTS_OUT'] ?? 'build/workspace-shots'; + +Future _shot(WidgetTester tester, String name) async { + await tester.pump(const Duration(milliseconds: 150)); + await tester.pump(const Duration(milliseconds: 150)); + final boundary = + _shotKey.currentContext!.findRenderObject() as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 2.0); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + final file = File('$_outDir/$name.png'); + file.parent.createSync(recursive: true); + file.writeAsBytesSync(bytes!.buffer.asUint8List()); + // ignore: avoid_print + print('workspace-shot: ${file.path}'); +} + +const _projects = [ + ProjectRef(slug: 'general', name: 'General'), + ProjectRef( + slug: 'projekt-alpha', + name: 'Projekt Alpha', + color: '#8b7cf6', + isolation: 'protected', + ), + ProjectRef(slug: 'projekt-beta', name: 'Projekt Beta', color: '#2e8f9e'), +]; + +const _areas = [ + SealedArea( + slug: 'bereich-nord', + name: 'Bereich Nord', + color: '#e0a458', + port: 51100, + running: true, + ), + SealedArea( + slug: 'bereich-sued', + name: 'Bereich Süd', + color: '#c25e5e', + port: 51101, + running: false, + ), +]; + +class _FakeSealedAreas extends SealedAreaService { + _FakeSealedAreas() : super.forTest(); + + @override + Future> list() async => _areas; + + @override + Future boundEndpoint(String slug) async => null; +} + +class _GatedHub extends FakeHubService { + Completer? healthyGate; + + @override + Future reconnect( + HubEndpoint endpoint, { + Object? authToken = const Object(), + bool persist = true, + }) async {} + + @override + Future healthy() async { + final gate = healthyGate; + if (gate != null) await gate.future; + return true; + } +} + +Future _boot(WidgetTester tester, _GatedHub hub, String theme) async { + SharedPreferences.setMockInitialValues({}); + hub.projects = _projects; + HubService.debugSetInstance(hub); + SealedAreaService.debugSetInstance(_FakeSealedAreas()); + SidebarPrefs.pinned.value = true; + Workspace.instance.debugSeed( + projects: _projects, + active: 'projekt-alpha', + sealed: _areas, + ); + tester.view.physicalSize = const Size(2560, 1600); + tester.view.devicePixelRatio = 2.0; + await tester.pumpWidget( + RepaintBoundary( + key: _shotKey, + child: StudioApp( + initialThemeMode: theme == 'dark' + ? ThemeModeValue.dark + : ThemeModeValue.light, + initialLocale: const Locale('de'), + startSidebarExpanded: true, + ), + ), + ); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 300)); +} + +Future _teardown(WidgetTester tester) async { + HubService.debugSetInstance(null); + SealedAreaService.debugSetInstance(null); + SidebarPrefs.pinned.value = false; + WorkspacePrefs.sealedNamesVisible.value = false; + Workspace.instance.debugSeed(projects: const [], active: ''); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(minutes: 1)); + tester.view.reset(); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + for (final theme in ['light', 'dark']) { + testWidgets('anchor + menu + switching bar — $theme', (tester) async { + final hub = _GatedHub(); + await _boot(tester, hub, theme); + + // 01: the shell with the sidebar anchor (active project). + await _shot(tester, '01-anchor-shell-$theme'); + + // 02: the switcher menu — checkmark on the active project, + // aggregated sealed row with the why-line + Settings link. + await tester.tap(find.byType(ChainWorkspaceAnchor)); + await tester.pump(const Duration(milliseconds: 300)); + await _shot(tester, '02-switcher-menu-$theme'); + + // 03: names revealed for this menu opening. + await tester.tap(find.text('Namen einblenden')); + await tester.pump(const Duration(milliseconds: 200)); + await _shot(tester, '03-switcher-menu-revealed-$theme'); + await tester.tapAt(const Offset(1200, 700)); // dismiss menu + await tester.pump(const Duration(milliseconds: 300)); + + // 04: the switching state — identity bar announces the + // target BEFORE the connection settles (privacy race fix). + hub.healthyGate = Completer(); + final switching = Workspace.instance.switchToSealed(_areas.first); + await tester.pump(const Duration(milliseconds: 100)); + await _shot(tester, '04-identity-switching-$theme'); + + // 05: inside the sealed area — bar + anchor agree. + hub.healthyGate!.complete(); + hub.healthyGate = null; + await switching; + await tester.pump(const Duration(milliseconds: 300)); + await _shot(tester, '05-identity-in-area-$theme'); + + await Workspace.instance.switchToShared(); + await _teardown(tester); + }); + } +} diff --git a/lib/data/about_info.dart b/lib/data/about_info.dart index 0e5af17..f799b54 100644 --- a/lib/data/about_info.dart +++ b/lib/data/about_info.dart @@ -4,7 +4,7 @@ /// Studio's own build version. Bump on every UI release so the /// running app self-identifies. -const String kStudioVersion = '0.81.0'; +const String kStudioVersion = '0.82.0'; const String kProductName = 'Ch∆In Studio'; const String kVendorName = 'Flemming.AI (F∆I)'; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index cdb6e49..b038016 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1837,7 +1837,7 @@ "workspaceAll": "Alle Projekte", "workspaceDefaultProject": "Allgemein", "workspaceSwitcherTooltip": "Arbeitskontext wählen — gilt überall: filtert Flows, Läufe, Audit und Freigaben und stempelt neue Läufe mit dem gewählten Projekt", - "workspaceAnchorCaption": "Projekt / Bereich", + "workspaceAnchorCaption": "Kontext", "workspaceStartConfirmTitle": "Bereich „{name}“ starten?", "@workspaceStartConfirmTitle": {"placeholders": {"name": {"type": "String"}}}, "workspaceStartConfirmBody": "Dieser abgeschottete Bereich ist gerade gestoppt. Studio startet seine eigene Hub-Instanz auf diesem Rechner und verbindet sich mit ihr — laufende Bereiche wechseln ohne diese Rückfrage.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 33d5f44..3070276 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1876,7 +1876,7 @@ "workspaceAll": "All projects", "workspaceDefaultProject": "General", "workspaceSwitcherTooltip": "Choose the working context — applies everywhere: filters flows, runs, audit and approvals, and stamps new runs with the selected project", - "workspaceAnchorCaption": "Project / area", + "workspaceAnchorCaption": "Context", "workspaceStartConfirmTitle": "Start area “{name}”?", "@workspaceStartConfirmTitle": {"placeholders": {"name": {"type": "String"}}}, "workspaceStartConfirmBody": "This sealed area is currently stopped. Studio starts its own hub instance on this machine and connects to it — running areas switch without this prompt.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 935df78..b87bb43 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5776,7 +5776,7 @@ abstract class AppLocalizations { /// No description provided for @workspaceAnchorCaption. /// /// In en, this message translates to: - /// **'Project / area'** + /// **'Context'** String get workspaceAnchorCaption; /// No description provided for @workspaceStartConfirmTitle. diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 74fc698..65a2489 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3426,7 +3426,7 @@ class AppLocalizationsDe extends AppLocalizations { 'Arbeitskontext wählen — gilt überall: filtert Flows, Läufe, Audit und Freigaben und stempelt neue Läufe mit dem gewählten Projekt'; @override - String get workspaceAnchorCaption => 'Projekt / Bereich'; + String get workspaceAnchorCaption => 'Kontext'; @override String workspaceStartConfirmTitle(String name) { diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index aa2ab68..42e37fa 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3419,7 +3419,7 @@ class AppLocalizationsEn extends AppLocalizations { 'Choose the working context — applies everywhere: filters flows, runs, audit and approvals, and stamps new runs with the selected project'; @override - String get workspaceAnchorCaption => 'Project / area'; + String get workspaceAnchorCaption => 'Context'; @override String workspaceStartConfirmTitle(String name) { diff --git a/pubspec.yaml b/pubspec.yaml index b79dbb0..e242644 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: chain_studio description: "Ch∆In Studio — desktop GUI for the Ch∆In hub" publish_to: 'none' -version: 0.81.0 +version: 0.82.0 environment: sdk: ^3.11.0-200.1.beta