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

@ -262,10 +262,11 @@ void main() {
}
// 09: the workspace switcher, opened (shows the demo projects;
// with the sealed seed also the shield entry).
// with the sealed seed also the shield entry). The switcher is
// the ONE sidebar anchor now global on every page.
shell.navigateTo('audit');
await _pumpFrames(tester);
final switcher = find.byType(ChainWorkspaceSwitcher);
final switcher = find.byType(ChainWorkspaceAnchor);
if (switcher.evaluate().isNotEmpty) {
await tester.tap(switcher.first);
await _pumpFrames(tester);

View file

@ -22,6 +22,8 @@ class WorkspacePrefs {
static Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
sealedNamesVisible.value = prefs.getBool(_kSealedNamesKey) ?? false;
recentContexts.value =
prefs.getStringList(_kRecentContextsKey) ?? const [];
}
static Future<void> setSealedNamesVisible(bool value) async {
@ -29,4 +31,25 @@ class WorkspacePrefs {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kSealedNamesKey, value);
}
static const _kRecentContextsKey = 'workspace.recent_contexts';
static const _kRecentContextsMax = 5;
/// Recently chosen switcher values (menu-value scheme: '' /
/// `p:<slug>` / `s:<slug>`), most recent first. The command
/// palette ranks matching contexts by this list so a poweruser's
/// frequent areas surface before the alphabet.
static final ValueNotifier<List<String>> recentContexts =
ValueNotifier(const []);
/// Record a switcher selection (deduped, capped, persisted).
static Future<void> recordRecentContext(String value) async {
final next = [
value,
...recentContexts.value.where((v) => v != value),
].take(_kRecentContextsMax).toList();
recentContexts.value = next;
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_kRecentContextsKey, next);
}
}

View file

@ -1413,6 +1413,9 @@
"searchHintOpen": "Enter öffnen",
"searchHintClose": "Esc schließen",
"searchGroupPages": "Seiten",
"searchGroupWorkspace": "Projekte & Bereiche",
"searchWorkspaceHint": "Kontext wechseln · ⌘P",
"searchWorkspaceSealedPickerHint": "Auswahl öffnen · ⌘P",
"searchGroupModules": "Module",
"searchGroupStore": "Store",
"searchGroupFlows": "Flows",
@ -1833,7 +1836,14 @@
"federationEnrollmentHint": "Übergeben Sie das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte Zertifikatsstelle authentifiziert die erste Verbindung des Satelliten.",
"workspaceAll": "Alle Projekte",
"workspaceDefaultProject": "Allgemein",
"workspaceSwitcherTooltip": "Arbeitsbereich — filtert diese Ansicht und stempelt neue Läufe mit dem gewählten Projekt",
"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",
"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.",
"workspaceStartConfirmAction": "Starten und verbinden",
"workspaceSealedAggregateWhy": "Namen bleiben zusammengefasst, weil sie Mandanten verraten können.",
"workspaceSealedAggregateSettings": "Immer anzeigen: Einstellungen → Sicherheit",
"workspaceProtectedHint": "Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich.",
"runsTitle": "Läufe",
"runsReloadTooltip": "Lauf-Liste neu laden",
@ -1841,6 +1851,10 @@
"runsEmptyHint": "Hier erscheinen Läufe, die im Hintergrund weiterlaufen, während Sie anderes tun. Diese Funktion ist optional und standardmäßig ausgeschaltet — die Anleitung zeigt Schritt für Schritt, wie sie eingeschaltet wird.",
"runsEmptyEnabledHint": "Läufe im Hintergrund sind eingeschaltet — es wurde nur noch keiner gestartet. Starten Sie einen Flow mit der Option „im Hintergrund ausführen“, dann erscheint er hier.",
"runsEmptyGuideButton": "Anleitung öffnen",
"runsEmptyFilteredTitle": "Keine Läufe in Projekt „{name}“",
"@runsEmptyFilteredTitle": {"placeholders": {"name": {"type": "String"}}},
"runsEmptyFilteredHint": "Der Arbeitskontext filtert diese Liste — in anderen Projekten kann es Läufe geben.",
"runsEmptyShowAll": "Alle Projekte anzeigen",
"runsHubTooOldTitle": "Diese Ansicht braucht eine neuere Hub-Version",
"runsHubTooOldHint": "Der Hub ist verbunden, aber seine Version kennt die Laufübersicht noch nicht. Aktualisieren Sie den Hub, dann erscheinen die Läufe hier.",
"runsHubTooOldButton": "Diagnose öffnen",

View file

@ -1437,6 +1437,9 @@
"searchHintOpen": "enter open",
"searchHintClose": "esc close",
"searchGroupPages": "Pages",
"searchGroupWorkspace": "Projects & areas",
"searchWorkspaceHint": "switch context · ⌘P",
"searchWorkspaceSealedPickerHint": "open the picker · ⌘P",
"searchGroupModules": "Modules",
"searchGroupStore": "Store",
"searchGroupFlows": "Flows",
@ -1872,7 +1875,14 @@
"federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite's first connect.",
"workspaceAll": "All projects",
"workspaceDefaultProject": "General",
"workspaceSwitcherTooltip": "Workspace — filters this view and stamps new runs with the selected project",
"workspaceSwitcherTooltip": "Choose the working context — applies everywhere: filters flows, runs, audit and approvals, and stamps new runs with the selected project",
"workspaceAnchorCaption": "Project / area",
"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.",
"workspaceStartConfirmAction": "Start and connect",
"workspaceSealedAggregateWhy": "Names stay aggregated because they can reveal client identities.",
"workspaceSealedAggregateSettings": "Always show: Settings → Security",
"workspaceProtectedHint": "Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.",
"runsTitle": "Runs",
"runsReloadTooltip": "Reload the runs list",
@ -1880,6 +1890,10 @@
"runsEmptyHint": "Runs that keep working in the background while you do something else appear here. The feature is optional and off by default — the guide shows step by step how to turn it on.",
"runsEmptyEnabledHint": "Background runs are switched on — none has been started yet. Start a flow with the \"run in background\" option and it will appear here.",
"runsEmptyGuideButton": "Open the guide",
"runsEmptyFilteredTitle": "No runs in project “{name}”",
"@runsEmptyFilteredTitle": {"placeholders": {"name": {"type": "String"}}},
"runsEmptyFilteredHint": "The working context filters this list — other projects may have runs.",
"runsEmptyShowAll": "Show all projects",
"runsHubTooOldTitle": "This view needs a newer hub version",
"runsHubTooOldHint": "The hub is connected, but its version does not know the runs monitor yet. Update the hub and the runs will appear here.",
"runsHubTooOldButton": "Open Doctor",

View file

@ -4400,6 +4400,24 @@ abstract class AppLocalizations {
/// **'Pages'**
String get searchGroupPages;
/// No description provided for @searchGroupWorkspace.
///
/// In en, this message translates to:
/// **'Projects & areas'**
String get searchGroupWorkspace;
/// No description provided for @searchWorkspaceHint.
///
/// In en, this message translates to:
/// **'switch context · ⌘P'**
String get searchWorkspaceHint;
/// No description provided for @searchWorkspaceSealedPickerHint.
///
/// In en, this message translates to:
/// **'open the picker · ⌘P'**
String get searchWorkspaceSealedPickerHint;
/// No description provided for @searchGroupModules.
///
/// In en, this message translates to:
@ -5752,9 +5770,45 @@ abstract class AppLocalizations {
/// No description provided for @workspaceSwitcherTooltip.
///
/// In en, this message translates to:
/// **'Workspace — filters this view and stamps new runs with the selected project'**
/// **'Choose the working context — applies everywhere: filters flows, runs, audit and approvals, and stamps new runs with the selected project'**
String get workspaceSwitcherTooltip;
/// No description provided for @workspaceAnchorCaption.
///
/// In en, this message translates to:
/// **'Project / area'**
String get workspaceAnchorCaption;
/// No description provided for @workspaceStartConfirmTitle.
///
/// In en, this message translates to:
/// **'Start area “{name}”?'**
String workspaceStartConfirmTitle(String name);
/// No description provided for @workspaceStartConfirmBody.
///
/// In en, this message translates to:
/// **'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.'**
String get workspaceStartConfirmBody;
/// No description provided for @workspaceStartConfirmAction.
///
/// In en, this message translates to:
/// **'Start and connect'**
String get workspaceStartConfirmAction;
/// No description provided for @workspaceSealedAggregateWhy.
///
/// In en, this message translates to:
/// **'Names stay aggregated because they can reveal client identities.'**
String get workspaceSealedAggregateWhy;
/// No description provided for @workspaceSealedAggregateSettings.
///
/// In en, this message translates to:
/// **'Always show: Settings → Security'**
String get workspaceSealedAggregateSettings;
/// No description provided for @workspaceProtectedHint.
///
/// In en, this message translates to:
@ -5797,6 +5851,24 @@ abstract class AppLocalizations {
/// **'Open the guide'**
String get runsEmptyGuideButton;
/// No description provided for @runsEmptyFilteredTitle.
///
/// In en, this message translates to:
/// **'No runs in project “{name}”'**
String runsEmptyFilteredTitle(String name);
/// No description provided for @runsEmptyFilteredHint.
///
/// In en, this message translates to:
/// **'The working context filters this list — other projects may have runs.'**
String get runsEmptyFilteredHint;
/// No description provided for @runsEmptyShowAll.
///
/// In en, this message translates to:
/// **'Show all projects'**
String get runsEmptyShowAll;
/// No description provided for @runsHubTooOldTitle.
///
/// In en, this message translates to:

View file

@ -2595,6 +2595,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get searchGroupPages => 'Seiten';
@override
String get searchGroupWorkspace => 'Projekte & Bereiche';
@override
String get searchWorkspaceHint => 'Kontext wechseln · ⌘P';
@override
String get searchWorkspaceSealedPickerHint => 'Auswahl öffnen · ⌘P';
@override
String get searchGroupModules => 'Module';
@ -3414,7 +3423,30 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get workspaceSwitcherTooltip =>
'Arbeitsbereich — filtert diese Ansicht und stempelt neue Läufe mit dem gewählten Projekt';
'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';
@override
String workspaceStartConfirmTitle(String name) {
return 'Bereich „$name“ starten?';
}
@override
String get 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.';
@override
String get workspaceStartConfirmAction => 'Starten und verbinden';
@override
String get workspaceSealedAggregateWhy =>
'Namen bleiben zusammengefasst, weil sie Mandanten verraten können.';
@override
String get workspaceSealedAggregateSettings =>
'Immer anzeigen: Einstellungen → Sicherheit';
@override
String get workspaceProtectedHint =>
@ -3440,6 +3472,18 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get runsEmptyGuideButton => 'Anleitung öffnen';
@override
String runsEmptyFilteredTitle(String name) {
return 'Keine Läufe in Projekt „$name';
}
@override
String get runsEmptyFilteredHint =>
'Der Arbeitskontext filtert diese Liste — in anderen Projekten kann es Läufe geben.';
@override
String get runsEmptyShowAll => 'Alle Projekte anzeigen';
@override
String get runsHubTooOldTitle =>
'Diese Ansicht braucht eine neuere Hub-Version';

View file

@ -2593,6 +2593,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get searchGroupPages => 'Pages';
@override
String get searchGroupWorkspace => 'Projects & areas';
@override
String get searchWorkspaceHint => 'switch context · ⌘P';
@override
String get searchWorkspaceSealedPickerHint => 'open the picker · ⌘P';
@override
String get searchGroupModules => 'Modules';
@ -3407,7 +3416,30 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get workspaceSwitcherTooltip =>
'Workspace — filters this view and stamps new runs with the selected project';
'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';
@override
String workspaceStartConfirmTitle(String name) {
return 'Start area “$name”?';
}
@override
String get 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.';
@override
String get workspaceStartConfirmAction => 'Start and connect';
@override
String get workspaceSealedAggregateWhy =>
'Names stay aggregated because they can reveal client identities.';
@override
String get workspaceSealedAggregateSettings =>
'Always show: Settings → Security';
@override
String get workspaceProtectedHint =>
@ -3433,6 +3465,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get runsEmptyGuideButton => 'Open the guide';
@override
String runsEmptyFilteredTitle(String name) {
return 'No runs in project “$name';
}
@override
String get runsEmptyFilteredHint =>
'The working context filters this list — other projects may have runs.';
@override
String get runsEmptyShowAll => 'Show all projects';
@override
String get runsHubTooOldTitle => 'This view needs a newer hub version';

View file

@ -505,9 +505,18 @@ class StudioShellState extends State<StudioShell> {
/// Refreshed alongside the health probe same 5 s tick.
int _pendingApprovals = 0;
/// Opens the workspace anchor's menu from the Cmd+P shortcut.
final GlobalKey<PopupMenuButtonState<String>> _workspaceMenuKey =
GlobalKey<PopupMenuButtonState<String>>();
@override
void initState() {
super.initState();
// The shell renders connection-derived labels (sidebar endpoint,
// workspace anchor): rebuild when the workspace switches context
// so they can never show the previous hub (persona finding: the
// sidebar endpoint stayed stale until the next health tick).
Workspace.instance.addListener(_onWorkspaceChanged);
_checkHealth();
_healthPoll = Timer.periodic(
const Duration(seconds: 5),
@ -515,6 +524,10 @@ class StudioShellState extends State<StudioShell> {
);
}
void _onWorkspaceChanged() {
if (mounted) setState(() {});
}
/// 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
@ -627,6 +640,7 @@ class StudioShellState extends State<StudioShell> {
@override
void dispose() {
Workspace.instance.removeListener(_onWorkspaceChanged);
_healthPoll?.cancel();
super.dispose();
}
@ -656,10 +670,76 @@ class StudioShellState extends State<StudioShell> {
ChainSettingsDialog.show(context);
},
),
..._workspaceSearchHits(l),
];
ChainSearchPalette.show(context, staticHits: hits);
}
/// The palette's "Projects & areas" group — every context the
/// switcher offers, ranked by the operator's recent choices so a
/// poweruser's frequent areas surface before the alphabet. Sealed
/// names honour the Settings privacy choice: while the switcher
/// aggregates them, the palette must not list them either it
/// offers one entry that opens the switcher's guarded picker.
List<ChainSearchHit> _workspaceSearchHits(AppLocalizations l) {
final ws = Workspace.instance;
final group = l.searchGroupWorkspace;
final recents = WorkspacePrefs.recentContexts.value;
int rank(String v) {
final i = recents.indexOf(v);
return i < 0 ? recents.length : i;
}
void select(String value) {
Navigator.of(context).pop();
handleWorkspaceMenuSelection(context, value);
}
final entries = <({String value, String label, IconData icon})>[
(
value: workspaceMenuValueAll(),
label: l.workspaceAll,
icon: Icons.grid_view_outlined,
),
for (final p in ws.projects)
(
value: workspaceMenuValueProject(p.slug),
label: p.slug == 'general' ? l.workspaceDefaultProject : p.name,
icon: Icons.circle_outlined,
),
if (WorkspacePrefs.sealedNamesVisible.value)
for (final a in ws.sealedAreas)
(
value: workspaceMenuValueSealed(a.slug),
label: a.name,
icon: Icons.lock_outline,
),
]..sort((a, b) => rank(a.value).compareTo(rank(b.value)));
return [
for (final e in entries)
ChainSearchHit(
label: e.label,
hint: l.searchWorkspaceHint,
icon: e.icon,
group: group,
onSelect: () => select(e.value),
),
if (!WorkspacePrefs.sealedNamesVisible.value &&
ws.sealedAreas.isNotEmpty)
ChainSearchHit(
label: l.workspaceSealedAggregate(ws.sealedAreas.length),
hint: l.searchWorkspaceSealedPickerHint,
icon: Icons.lock_outline,
group: group,
onSelect: () {
Navigator.of(context).pop();
_workspaceMenuKey.currentState?.showButtonMenu();
},
),
];
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -699,6 +779,12 @@ class StudioShellState extends State<StudioShell> {
const _OpenSearchIntent(),
const SingleActivator(LogicalKeyboardKey.keyK, control: true):
const _OpenSearchIntent(),
// Workspace switcher (project/area picker) P as in
// project. Opens the sidebar anchor's menu.
const SingleActivator(LogicalKeyboardKey.keyP, meta: true):
const _OpenWorkspaceIntent(),
const SingleActivator(LogicalKeyboardKey.keyP, control: true):
const _OpenWorkspaceIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
@ -720,6 +806,12 @@ class StudioShellState extends State<StudioShell> {
return null;
},
),
_OpenWorkspaceIntent: CallbackAction<_OpenWorkspaceIntent>(
onInvoke: (_) {
_workspaceMenuKey.currentState?.showButtonMenu();
return null;
},
),
},
child: Focus(
autofocus: true,
@ -738,6 +830,7 @@ class StudioShellState extends State<StudioShell> {
pendingApprovals: _pendingApprovals,
forceExpanded: widget.startSidebarExpanded || pinned,
onOpenSearch: _openSearchPalette,
workspaceMenuKey: _workspaceMenuKey,
),
),
Container(width: 1, color: theme.colorScheme.outlineVariant),
@ -866,6 +959,10 @@ class _OpenSearchIntent extends Intent {
const _OpenSearchIntent();
}
class _OpenWorkspaceIntent extends Intent {
const _OpenWorkspaceIntent();
}
/// Platform-truthful label for the app's primary-modifier
/// shortcuts (the activators bind on macOS and Ctrl elsewhere).
String _metaShortcut(String key) =>
@ -899,6 +996,11 @@ class _Sidebar extends StatefulWidget {
/// knowing the shortcut.
final VoidCallback? onOpenSearch;
/// Menu key for the workspace anchor so the shell's Cmd+P
/// shortcut (and the palette's guarded sealed-picker entry) can
/// open the switcher menu.
final GlobalKey<PopupMenuButtonState<String>>? workspaceMenuKey;
const _Sidebar({
required this.selectedIndex,
required this.onSelect,
@ -909,6 +1011,7 @@ class _Sidebar extends StatefulWidget {
this.pendingApprovals = 0,
this.forceExpanded = false,
this.onOpenSearch,
this.workspaceMenuKey,
});
@override
@ -932,6 +1035,7 @@ class _SidebarState extends State<_Sidebar>
static const double _brandRowH = 48;
static const double _connRowH = 44;
static const double _channelRowH = 28;
static const double _anchorRowH = 44;
static const double _rowGap = 8;
late final AnimationController _ctrl;
// True while the channel-switch menu is open suppresses the
@ -1110,6 +1214,31 @@ class _SidebarState extends State<_Sidebar>
)
: const SizedBox.shrink(),
),
const SizedBox(height: _rowGap),
// The ONE workspace anchor active project/area,
// visible on every page, opens the switcher menu
// (per-page switcher embeddings are gone; persona
// review 2026-08-27). Fixed height like the other
// header rows so expanding the rail never shifts
// the destinations below.
SizedBox(
height: _anchorRowH,
child: ChainWorkspaceAnchor(
t: t,
labelsInteractive: labelsInteractive,
iconColumnWidth: _collapsedWidth,
menuKey: widget.workspaceMenuKey,
onMenuOpen: () {
setState(() => _menuOpen = true);
_ctrl.forward();
},
onMenuClose: () {
if (!mounted) return;
setState(() => _menuOpen = false);
_ctrl.reverse();
},
),
),
const SizedBox(height: ChainSpace.md),
Expanded(
child: ListView(

View file

@ -376,8 +376,6 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
),
),
actions: [
const ChainWorkspaceSwitcher(),
const SizedBox(width: ChainSpace.md),
IconButton(
icon: const Icon(Icons.help_outline, size: 18),
tooltip: AppLocalizations.of(context)!.helpTooltip,

View file

@ -306,11 +306,9 @@ class _AuditPageState extends State<AuditPage> {
appBar: AppBar(
title: Text(AppLocalizations.of(context)!.auditTitle),
actions: [
const ChainWorkspaceSwitcher(),
const SizedBox(width: ChainSpace.md),
// Narrow windows can't fit the inline chip row next to the
// workspace switcher collapse to a checkmark menu so the
// app bar never overflows (responsive_test.dart pins this).
// Narrow windows can't fit the inline chip row — collapse
// to a checkmark menu so the app bar never overflows
// (responsive_test.dart pins this).
Padding(
padding: const EdgeInsets.only(right: ChainSpace.lg),
child: MediaQuery.sizeOf(context).width < 900

View file

@ -23,7 +23,6 @@ import '../data/store_caps.dart';
import '../data/workspace.dart';
import '../l10n/app_localizations.dart';
import '../widgets/chain_install_confirm.dart';
import '../widgets/chain_workspace_switcher.dart';
class FlowsPage extends StatefulWidget {
/// Pre-load this flow when the editor first builds. Studio
@ -220,11 +219,9 @@ class _FlowsPageState extends State<FlowsPage> {
onInstallCapability: _onInstallCapability,
onAddModuleSource: _onAddModuleSource,
activeProject: Workspace.instance.activeSlug,
// Same switcher as Audit/Approvals/Runs, hosted in the
// editor's toolbar (the page's single header): it
// filters the flow list and is the project a new flow
// gets stamped with.
toolbarTrailing: const ChainWorkspaceSwitcher(),
// The workspace switcher lives ONCE in the shell sidebar
// (global anchor) the editor toolbar no longer hosts
// its own copy.
// Native file dialog for the Run tab's file inputs —
// nobody should have to type an absolute path by hand.
onPickFile: _pickFlowInputFile,

View file

@ -254,6 +254,14 @@ class _RunsPageState extends State<RunsPage> {
}
}
/// Display name of the filtered project ("General" localized).
String _activeProjectLabel(AppLocalizations l) {
final ws = Workspace.instance;
final p = ws.active;
if (p == null) return ws.activeSlug;
return p.slug == 'general' ? l.workspaceDefaultProject : p.name;
}
Future<void> _cancel(DetachedRun run) async {
final l = AppLocalizations.of(context)!;
setState(() => _cancelling.add(run.id));
@ -333,8 +341,6 @@ class _RunsPageState extends State<RunsPage> {
appBar: AppBar(
title: Text(l.runsTitle),
actions: [
const ChainWorkspaceSwitcher(),
const SizedBox(width: ChainSpace.md),
IconButton(
icon: const Icon(Icons.help_outline, size: 18),
tooltip: l.helpTooltip,
@ -361,22 +367,41 @@ class _RunsPageState extends State<RunsPage> {
updating: _updatingHub,
)
: _runs.isEmpty
// With an active project filter the honest cause is the
// filter, not the feature name the project and offer the
// way out instead of the enable-the-feature guide
// (persona finding: the old hint claimed a wrong cause).
? (_detachedEnabled && !Workspace.instance.isAll
? ChainEmptyState(
icon: Icons.rocket_launch_outlined,
title: l.runsEmptyFilteredTitle(_activeProjectLabel(l)),
hint: l.runsEmptyFilteredHint,
action: OutlinedButton.icon(
icon: const Icon(Icons.grid_view_outlined, size: 16),
label: Text(l.runsEmptyShowAll),
onPressed: () => Workspace.instance.setActive(''),
),
)
: ChainEmptyState(
icon: Icons.rocket_launch_outlined,
title: l.runsEmptyTitle,
// The hub reports whether the operator enabled the
// feature never claim "switched off" while it is on
// and there simply are no runs yet (usertest finding).
hint: _detachedEnabled ? l.runsEmptyEnabledHint : l.runsEmptyHint,
// The guide carries the plain-language explanation plus
// the exact operator steps (config snippet) a click
// target instead of a raw config key in the hint.
// feature never claim "switched off" while it is
// on and there simply are no runs yet (usertest
// finding).
hint: _detachedEnabled
? l.runsEmptyEnabledHint
: l.runsEmptyHint,
// The guide carries the plain-language explanation
// plus the exact operator steps (config snippet)
// a click target instead of a raw config key in
// the hint.
action: OutlinedButton.icon(
icon: const Icon(Icons.menu_book_outlined, size: 16),
label: Text(l.runsEmptyGuideButton),
onPressed: () => showFaiDoc(context, 'runs'),
),
)
))
: ListView.separated(
padding: const EdgeInsets.all(ChainSpace.lg),
itemCount: _runs.length,

View file

@ -26,13 +26,21 @@ import 'hub_auth_policy_panel.dart';
import 'theme_picker_grid.dart';
class ChainSettingsDialog extends StatefulWidget {
const ChainSettingsDialog({super.key});
/// Category name to open on (e.g. 'security'); null = the first
/// category. Lets in-place explainers (the sealed-aggregate
/// why-line) jump straight to the toggle they talk about.
final String? initialCategory;
const ChainSettingsDialog({super.key, this.initialCategory});
/// Convenience launcher used from the sidebar gear icon.
static Future<bool> show(BuildContext context) async {
static Future<bool> show(
BuildContext context, {
String? initialCategory,
}) async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => const ChainSettingsDialog(),
builder: (_) => ChainSettingsDialog(initialCategory: initialCategory),
);
return ok ?? false;
}
@ -88,6 +96,9 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
@override
void initState() {
super.initState();
for (final c in _Category.values) {
if (c.name == widget.initialCategory) _category = c;
}
final ep = HubService.instance.currentEndpoint;
_host = TextEditingController(text: ep.host);
_port = TextEditingController(text: ep.port.toString());

View file

@ -1,13 +1,20 @@
// ChainWorkspaceSwitcher the AppBar workspace (project) control.
// Workspace switching the ONE global control for "where am I
// working?".
//
// One control, two mechanics (docs/architecture/projects.md,
// § Studio): for open/protected projects the choice sets the page
// filter AND the label new runs are stamped with. "All projects"
// stays reachable a filter, not a jail. Sealed areas are a real
// CONNECTION SWITCH: selecting one reconnects Studio to the area's
// own hub (starting it first if stopped, with a notice) and colours
// the identity bar. Studio's blue stays the app accent — the project
// colour is marking, not theming.
// own hub (starting it first if stopped, after an explicit
// confirmation) and colours the identity bar. Studio's blue stays
// the app accent the project colour is marking, not theming.
//
// The control lives ONCE in the shell sidebar (ChainWorkspaceAnchor)
// so the active context is visible on every page it used to be
// embedded per-page (Flows/Runs/Audit/Approvals), which left it
// invisible on the other five pages and moving between positions
// (persona review 2026-08-27).
import 'package:flutter/material.dart';
@ -17,15 +24,50 @@ import '../data/workspace_prefs.dart';
import '../data/workspace.dart';
import '../l10n/app_localizations.dart';
import '../theme/tokens.dart';
import 'chain_settings_dialog.dart';
/// Menu-value scheme: '' = all projects, `p:<slug>` = shared project,
/// `s:<slug>` = sealed area.
/// `s:<slug>` = sealed area, `settings:security` = the why-line's
/// jump to the Settings toggle.
const _kAll = '';
const _pProject = 'p:';
const _pSealed = 's:';
const _kSettingsSecurity = 'settings:security';
class ChainWorkspaceSwitcher extends StatelessWidget {
const ChainWorkspaceSwitcher({super.key});
/// Menu values for external mount points (the command palette)
/// the scheme itself stays private to this file.
String workspaceMenuValueAll() => _kAll;
String workspaceMenuValueProject(String slug) => '$_pProject$slug';
String workspaceMenuValueSealed(String slug) => '$_pSealed$slug';
/// The sidebar anchor the single place the workspace switcher is
/// mounted. Geometry mirrors the sidebar's header rows: a fixed
/// icon column (context marking) + the label that fades in with the
/// rail expansion [t].
class ChainWorkspaceAnchor extends StatelessWidget {
/// Sidebar expansion 0..1 (collapsed expanded).
final double t;
final bool labelsInteractive;
final double iconColumnWidth;
/// Suppress the sidebar's hover-collapse while the menu is open —
/// same contract as the channel pill (the menu would otherwise
/// float at the pill's old x).
final VoidCallback? onMenuOpen;
final VoidCallback? onMenuClose;
/// Lets the shell open the menu from the keyboard shortcut.
final GlobalKey<PopupMenuButtonState<String>>? menuKey;
const ChainWorkspaceAnchor({
super.key,
required this.t,
required this.labelsInteractive,
required this.iconColumnWidth,
this.onMenuOpen,
this.onMenuClose,
this.menuKey,
});
@override
Widget build(BuildContext context) {
@ -35,34 +77,157 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
final l = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final ws = Workspace.instance;
final label = workspaceContextLabel(l, ws);
final label = ws.inSealedArea
? ws.activeSealed!.name
: (ws.isAll
? l.workspaceAll
: (ws.active == null
? ws.activeSlug
: _projectLabel(l, ws.active!)));
final row = Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: iconColumnWidth,
child: Center(child: _ContextMark(ws: ws)),
),
Expanded(
child: t > 0
? IgnorePointer(
ignoring: !labelsInteractive,
child: Opacity(
opacity: t,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
l.workspaceAnchorCaption,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
label,
style: theme.textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Scales away mid-expansion a fixed
// icon would overflow the row while the
// rail is still narrow.
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: const EdgeInsets.only(
right: ChainSpace.md,
),
child: Icon(
Icons.unfold_more,
size: 16,
color:
theme.colorScheme.onSurfaceVariant,
),
),
),
),
],
),
),
)
: const SizedBox.shrink(),
),
],
);
return Tooltip(
message: l.workspaceSwitcherTooltip,
waitDuration: const Duration(milliseconds: 400),
child: PopupMenuButton<String>(
onOpened: ws.refresh,
onSelected: (v) => _onSelected(context, v),
itemBuilder: (context) => _items(context, ws, l, theme),
child: _pill(context, ws, l, theme, label),
key: menuKey,
tooltip: '', // the outer Tooltip carries the message
onOpened: () {
onMenuOpen?.call();
Workspace.instance.refresh();
},
onCanceled: () => onMenuClose?.call(),
onSelected: (v) {
onMenuClose?.call();
handleWorkspaceMenuSelection(context, v);
},
itemBuilder: (context) =>
workspaceMenuItems(context, ws: ws, l: l, theme: theme),
child: SizedBox(height: 44, child: row),
),
);
},
);
}
}
List<PopupMenuEntry<String>> _items(
BuildContext context,
Workspace ws,
AppLocalizations l,
ThemeData theme,
) {
/// The collapsed-column marking for the active context: grid = all
/// projects, coloured dot = project, dot + lock = sealed area.
class _ContextMark extends StatelessWidget {
final Workspace ws;
const _ContextMark({required this.ws});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final sealed = ws.switching
? (ws.switchTarget ?? ws.activeSealed)
: ws.activeSealed;
if (sealed != null) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_ProjectDot(color: sealed.color),
const SizedBox(width: 4),
Icon(
Icons.lock_outline,
size: 13,
color: theme.colorScheme.onSurfaceVariant,
),
],
);
}
if (ws.active != null) {
return _ProjectDot(color: ws.active!.color);
}
return Icon(
Icons.grid_view_outlined,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
);
}
}
/// Display label for the active context (shared by anchor + tests).
String workspaceContextLabel(AppLocalizations l, Workspace ws) {
final sealed = ws.switching
? (ws.switchTarget ?? ws.activeSealed)
: ws.activeSealed;
if (sealed != null) return sealed.name;
if (ws.isAll) return l.workspaceAll;
final active = ws.active;
return active == null ? ws.activeSlug : _projectLabel(l, active);
}
/// The switcher menu, one source of truth for every mount point.
/// The active entry carries a checkmark so the menu answers "where
/// am I?" before anything is clicked.
List<PopupMenuEntry<String>> workspaceMenuItems(
BuildContext context, {
required Workspace ws,
required AppLocalizations l,
required ThemeData theme,
}) {
final inSealed = ws.inSealedArea;
final items = <PopupMenuEntry<String>>[
PopupMenuItem(
value: _kAll,
@ -74,7 +239,8 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: ChainSpace.sm),
Text(l.workspaceAll),
Expanded(child: Text(l.workspaceAll)),
_ActiveCheck(active: !inSealed && ws.isAll),
],
),
),
@ -105,6 +271,8 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
),
),
],
const Spacer(),
_ActiveCheck(active: !inSealed && ws.activeSlug == p.slug),
],
),
),
@ -139,6 +307,7 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
child: SealedAreaSection(
areas: ws.sealedAreas,
namesVisible: WorkspacePrefs.sealedNamesVisible.value,
activeSlug: ws.activeSealed?.slug,
),
),
);
@ -146,16 +315,29 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
return items;
}
Future<void> _onSelected(BuildContext context, String value) async {
/// Apply a switcher-menu selection. Shared by every mount point
/// (sidebar anchor, command palette). Starting a STOPPED sealed
/// area asks first an area switch must never boot a hub daemon
/// as a click side-effect; a running area stays one click.
Future<void> handleWorkspaceMenuSelection(
BuildContext context,
String value,
) async {
final ws = Workspace.instance;
final l = AppLocalizations.of(context)!;
final messenger = ScaffoldMessenger.of(context);
if (value == _kSettingsSecurity) {
await ChainSettingsDialog.show(context, initialCategory: 'security');
return;
}
if (value == _kAll) {
WorkspacePrefs.recordRecentContext(value);
await ws.setActive('');
return;
}
if (value.startsWith(_pProject)) {
WorkspacePrefs.recordRecentContext(value);
await ws.setActive(value.substring(_pProject.length));
return;
}
@ -166,13 +348,36 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
if (a.slug == slug) area = a;
}
if (area == null) return;
// Announce the intent starting a stopped area takes a beat.
if (!area.running) {
final target = area;
if (!target.running) {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l.workspaceStartConfirmTitle(target.name)),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Text(l.workspaceStartConfirmBody),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l.buttonCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(l.workspaceStartConfirmAction),
),
],
),
);
if (confirmed != true) return;
// Announce the intent starting the area's hub takes a beat.
messenger.showSnackBar(
SnackBar(content: Text(l.workspaceSealedStarting(area.name))),
SnackBar(content: Text(l.workspaceSealedStarting(target.name))),
);
}
final r = await ws.switchToSealed(area);
WorkspacePrefs.recordRecentContext(value);
final r = await ws.switchToSealed(target);
messenger.hideCurrentSnackBar();
if (!r.ok) {
messenger.showSnackBar(
@ -183,84 +388,27 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
);
} else if (r.started) {
messenger.showSnackBar(
SnackBar(content: Text(l.workspaceSealedStarted(area.name))),
SnackBar(content: Text(l.workspaceSealedStarted(target.name))),
);
}
}
}
Widget _pill(
BuildContext context,
Workspace ws,
AppLocalizations l,
ThemeData theme,
String label,
) {
final Widget leading;
if (ws.inSealedArea) {
leading = Row(
mainAxisSize: MainAxisSize.min,
children: [
_ProjectDot(color: ws.activeSealed!.color),
const SizedBox(width: 4),
Icon(
Icons.lock_outline,
size: 13,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
],
);
} else if (ws.active != null) {
leading = Padding(
padding: const EdgeInsets.only(right: 6),
child: _ProjectDot(color: ws.active!.color),
);
} else {
leading = Padding(
padding: const EdgeInsets.only(right: 6),
child: Icon(
Icons.grid_view_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
);
}
/// Trailing checkmark slot fixed width so rows with and without
/// the mark keep their text aligned.
class _ActiveCheck extends StatelessWidget {
final bool active;
return Container(
padding: const EdgeInsets.symmetric(horizontal: ChainSpace.md, vertical: 5),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(ChainRadius.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
leading,
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 160),
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium,
),
),
if (!ws.inSealedArea && (ws.active?.isProtected ?? false)) ...[
const SizedBox(width: 4),
Icon(
Icons.shield_outlined,
size: 13,
color: theme.colorScheme.onSurfaceVariant,
),
],
const SizedBox(width: 2),
Icon(
Icons.arrow_drop_down,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
const _ActiveCheck({required this.active});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: 22,
child: active
? Icon(Icons.check, size: 16, color: theme.colorScheme.primary)
: null,
);
}
}
@ -305,19 +453,26 @@ Color? parseAreaColor(String hex) {
/// widget test can pump both privacy modes directly.
///
/// [namesVisible] = the operator's Settings choice. When false the
/// block renders one aggregated row (lock + count); a deliberate
/// tap expands the named rows for THIS menu opening only nothing
/// is persisted from the reveal. Rows select via
/// block renders one aggregated row (lock + count) with a why-line
/// (the aggregation is a deliberate confidentiality decision and
/// must explain itself in place) + a jump to the Settings toggle;
/// a deliberate tap expands the named rows for THIS menu opening
/// only nothing is persisted from the reveal. Rows select via
/// `Navigator.pop(context, 's:<slug>')`, which hands the value to
/// the enclosing PopupMenuButton exactly like a regular item.
class SealedAreaSection extends StatefulWidget {
final List<SealedArea> areas;
final bool namesVisible;
/// Slug of the area Studio is connected to, or null its row
/// carries the active checkmark.
final String? activeSlug;
const SealedAreaSection({
super.key,
required this.areas,
required this.namesVisible,
this.activeSlug,
});
@override
@ -332,7 +487,11 @@ class _SealedAreaSectionState extends State<SealedAreaSection> {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
if (!widget.namesVisible && !_revealed) {
return InkWell(
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => setState(() => _revealed = true),
child: Padding(
padding: const EdgeInsets.symmetric(
@ -387,6 +546,43 @@ class _SealedAreaSectionState extends State<SealedAreaSection> {
],
),
),
),
// The why-line: aggregation is not a glitch but a
// confidentiality choice say so where it happens, and
// point at the Settings toggle for machines where the
// listing is fine.
Padding(
padding: const EdgeInsets.fromLTRB(37, 0, 16, 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 240),
child: Text(
l.workspaceSealedAggregateWhy,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
InkWell(
onTap: () =>
Navigator.pop(context, _kSettingsSecurity),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
l.workspaceSealedAggregateSettings,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
),
),
],
),
),
],
);
}
return Column(
@ -440,6 +636,8 @@ class _SealedAreaSectionState extends State<SealedAreaSection> {
),
),
),
const Spacer(),
_ActiveCheck(active: a.slug == widget.activeSlug),
],
),
),

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,