feat(workspace): one global switcher anchor in the shell sidebar

The switcher used to be embedded per page (Flows/Runs/Audit/
Approvals) — invisible on the other five pages and sitting in a
different corner depending on the page (persona review 2026-08-27,
consensus finding). It now lives ONCE in the sidebar, above the
destinations: active project/area always visible, opens the same
menu everywhere, Cmd+P from anywhere. The shell listens to the
workspace, so the sidebar endpoint label can no longer lag a
sealed switch until the next health tick.

Also in this rebuild:

* Stopped sealed areas ask before starting ("Start area X?") —
  a context switch must never boot a hub daemon as a click
  side-effect; running areas keep switching with one click.
* The switcher tooltip told a wrong scope ("filters this view") —
  it now says the choice applies everywhere and stamps new runs.
* The aggregated sealed row explains itself in place (names can
  reveal client identities) and links to the Settings toggle
  (Settings dialog gained an initialCategory jump).
* The active entry carries a checkmark in the menu.
* The Cmd+K palette knows projects and areas, ranked by recent
  use; sealed names honour the privacy setting — while hidden,
  the palette offers the guarded picker instead of the names.
* The runs empty state names the active project filter as the
  cause ("No runs in project X" + show-all action) instead of
  claiming the feature is off.

Tests updated to the anchor and made hermetic (scriptable
projects on the fake hub, sealed-area fake); new coverage for the
checkmark, the why-line, and the start confirmation.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-08-28 00:00:06 +02:00
parent afe782e826
commit 64c2a77dc9
16 changed files with 988 additions and 324 deletions

View file

@ -183,9 +183,14 @@ class FakeHubService extends Fake implements HubService {
@override
Future<List<SavedFlow>> listFlows() => _async('listFlows', () => const []);
/// Registry projects [listProjects] answers with scriptable so
/// workspace suites can keep their seed across the switcher's
/// open-menu refresh.
List<ProjectRef> projects = const [];
@override
Future<List<ProjectRef>> listProjects() =>
_async('listProjects', () => const []);
_async('listProjects', () => projects);
@override
Stream<AuditEvent> streamEvents({

View file

@ -1,10 +1,13 @@
// Workspace switcher stage-1 contract: the control renders the
// active selection, lists "All projects" plus every registry
// project (colour dot, shield for protected), and switching
// updates the shared Workspace notifier.
// Workspace anchor contract: the sidebar's ONE switcher control
// renders the active selection, lists "All projects" plus every
// registry project (colour dot, shield for protected, checkmark on
// the active entry), keeps sealed-area names aggregated until
// deliberately revealed, asks before starting a stopped area, and
// switching updates the shared Workspace notifier.
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/data/sealed_areas.dart';
@ -13,16 +16,25 @@ import 'package:chain_studio/data/workspace_prefs.dart';
import 'package:chain_studio/l10n/app_localizations.dart';
import 'package:chain_studio/widgets/chain_workspace_switcher.dart';
import 'support/fake_hub.dart';
Widget _host() {
return const MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
appBar: PreferredSize(
preferredSize: Size.fromHeight(kToolbarHeight),
child: Material(child: ChainWorkspaceSwitcher()),
body: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 220,
height: 44,
child: ChainWorkspaceAnchor(
t: 1,
labelsInteractive: true,
iconColumnWidth: 72,
),
),
),
body: SizedBox(),
),
);
}
@ -36,26 +48,46 @@ const _clientA = ProjectRef(
);
void main() {
late FakeHubService fakeHub;
// Seed the workspace singleton AND script both service fakes to
// match: opening the menu triggers Workspace.refresh, which must
// answer from the fakes never from a live hub or the real
// ~/.chain (hermeticity, shared/TESTING.md).
void seed({
List<ProjectRef> projects = const [_general, _clientA],
String active = '',
List<SealedArea> sealed = const [],
SealedArea? activeSealed,
}) {
fakeHub.projects = projects;
SealedAreaService.debugSetInstance(_FakeSealedAreas(sealed));
Workspace.instance.debugSeed(
projects: projects,
active: active,
sealed: sealed,
activeSealed: activeSealed,
);
}
setUp(() {
// Seed the singleton without a hub: tests drive the notifier
// directly through its test hook.
Workspace.instance.debugSeed(projects: [_general, _clientA], active: '');
SharedPreferences.setMockInitialValues({});
fakeHub = installFakeHub();
addTearDown(() => SealedAreaService.debugSetInstance(null));
seed();
});
tearDown(() {
WorkspacePrefs.sealedNamesVisible.value = false;
WorkspacePrefs.recentContexts.value = const [];
});
testWidgets('sealed areas stay aggregated until deliberately revealed', (
tester,
) async {
Workspace.instance.debugSeed(
projects: [_general],
active: '',
sealed: [_sealedGrid, _sealedLab],
);
seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]);
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceSwitcher));
await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle();
// No names on a casual glance only the aggregate row.
expect(find.text('grid'), findsNothing);
@ -67,17 +99,30 @@ void main() {
expect(find.text('lab'), findsOneWidget);
});
testWidgets('the aggregate row explains itself and links to Settings', (
tester,
) async {
seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]);
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle();
// The why-line: aggregation is a confidentiality decision and
// must say so in place (in-app docs rule), plus the jump to the
// Settings toggle.
expect(
find.textContaining('reveal client identities'),
findsOneWidget,
);
expect(find.textContaining('Settings'), findsWidgets);
});
testWidgets('the Settings toggle restores the direct listing', (
tester,
) async {
WorkspacePrefs.sealedNamesVisible.value = true;
Workspace.instance.debugSeed(
projects: [_general],
active: '',
sealed: [_sealedGrid, _sealedLab],
);
seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]);
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceSwitcher));
await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle();
expect(find.text('grid'), findsOneWidget);
expect(find.text('lab'), findsOneWidget);
@ -95,7 +140,7 @@ void main() {
tester,
) async {
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceSwitcher));
await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle();
expect(find.text('All projects'), findsWidgets);
@ -105,21 +150,38 @@ void main() {
expect(find.byIcon(Icons.shield_outlined), findsOneWidget);
});
testWidgets('the active entry carries the checkmark', (tester) async {
seed(active: 'client-a');
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle();
// Exactly one checkmark, and it sits in the active project's row.
expect(find.byIcon(Icons.check), findsOneWidget);
expect(
find.descendant(
of: find.ancestor(
of: find.text('Client A'),
matching: find.byType(PopupMenuItem<String>),
),
matching: find.byIcon(Icons.check),
),
findsOneWidget,
);
});
testWidgets('selecting a project updates the workspace and the label', (
tester,
) async {
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceSwitcher));
await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle();
await tester.tap(find.text('Client A').last);
await tester.pumpAndSettle();
expect(Workspace.instance.activeSlug, 'client-a');
expect(Workspace.instance.active?.isProtected, isTrue);
// The closed control now shows the active project (label +
// shield marker for protected).
// The closed control now shows the active project.
expect(find.text('Client A'), findsOneWidget);
expect(find.byIcon(Icons.shield_outlined), findsOneWidget);
});
test('active falls back to null when the slug left the registry', () {
@ -132,9 +194,8 @@ void main() {
testWidgets('lists sealed areas with lock + running/stopped status', (
tester,
) async {
Workspace.instance.debugSeed(
seed(
projects: [_general],
active: '',
sealed: const [
SealedArea(
slug: 'grid',
@ -153,7 +214,7 @@ void main() {
],
);
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceSwitcher));
await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle();
// Sealed areas appear under the sealed header, aggregated by
@ -169,13 +230,31 @@ void main() {
expect(find.text('stopped'), findsOneWidget);
});
testWidgets('the pill shows the active sealed area with a lock', (
testWidgets('selecting a STOPPED area asks before starting its hub', (
tester,
) async {
Workspace.instance.debugSeed(
WorkspacePrefs.sealedNamesVisible.value = true;
seed(projects: [_general], sealed: [_sealedGrid]); // grid: stopped
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle();
await tester.tap(find.text('grid'));
await tester.pumpAndSettle();
// The confirmation dialog an area switch must never boot a
// hub daemon as a click side-effect.
expect(find.text('Start area “grid”?'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(Workspace.instance.inSealedArea, isFalse);
});
testWidgets('the anchor shows the active sealed area with a lock', (
tester,
) async {
seed(
projects: [_general],
active: '',
sealed: const [],
activeSealed: const SealedArea(
slug: 'grid',
name: 'Grid',
@ -186,12 +265,24 @@ void main() {
);
await tester.pumpWidget(_host());
expect(Workspace.instance.inSealedArea, isTrue);
// The closed pill names the sealed area and carries a lock.
// The closed anchor names the sealed area and carries a lock.
expect(find.text('Grid'), findsOneWidget);
expect(find.byIcon(Icons.lock_outline), findsWidgets);
});
}
class _FakeSealedAreas extends SealedAreaService {
_FakeSealedAreas(this.areas) : super.forTest();
final List<SealedArea> areas;
@override
Future<List<SealedArea>> list() async => areas;
@override
Future<String?> boundEndpoint(String slug) async => null;
}
// Sealed-area confidentiality (usertest security finding): the
// switcher must not disclose sealed-area names often client
// identity on a casual glance. Aggregated row by default,