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; // 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'); shell.navigateTo('audit');
await _pumpFrames(tester); await _pumpFrames(tester);
final switcher = find.byType(ChainWorkspaceSwitcher); final switcher = find.byType(ChainWorkspaceAnchor);
if (switcher.evaluate().isNotEmpty) { if (switcher.evaluate().isNotEmpty) {
await tester.tap(switcher.first); await tester.tap(switcher.first);
await _pumpFrames(tester); await _pumpFrames(tester);

View file

@ -22,6 +22,8 @@ class WorkspacePrefs {
static Future<void> load() async { static Future<void> load() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
sealedNamesVisible.value = prefs.getBool(_kSealedNamesKey) ?? false; sealedNamesVisible.value = prefs.getBool(_kSealedNamesKey) ?? false;
recentContexts.value =
prefs.getStringList(_kRecentContextsKey) ?? const [];
} }
static Future<void> setSealedNamesVisible(bool value) async { static Future<void> setSealedNamesVisible(bool value) async {
@ -29,4 +31,25 @@ class WorkspacePrefs {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kSealedNamesKey, value); 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", "searchHintOpen": "Enter öffnen",
"searchHintClose": "Esc schließen", "searchHintClose": "Esc schließen",
"searchGroupPages": "Seiten", "searchGroupPages": "Seiten",
"searchGroupWorkspace": "Projekte & Bereiche",
"searchWorkspaceHint": "Kontext wechseln · ⌘P",
"searchWorkspaceSealedPickerHint": "Auswahl öffnen · ⌘P",
"searchGroupModules": "Module", "searchGroupModules": "Module",
"searchGroupStore": "Store", "searchGroupStore": "Store",
"searchGroupFlows": "Flows", "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.", "federationEnrollmentHint": "Übergeben Sie das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte Zertifikatsstelle authentifiziert die erste Verbindung des Satelliten.",
"workspaceAll": "Alle Projekte", "workspaceAll": "Alle Projekte",
"workspaceDefaultProject": "Allgemein", "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.", "workspaceProtectedHint": "Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich.",
"runsTitle": "Läufe", "runsTitle": "Läufe",
"runsReloadTooltip": "Lauf-Liste neu laden", "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.", "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.", "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", "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", "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.", "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", "runsHubTooOldButton": "Diagnose öffnen",

View file

@ -1437,6 +1437,9 @@
"searchHintOpen": "enter open", "searchHintOpen": "enter open",
"searchHintClose": "esc close", "searchHintClose": "esc close",
"searchGroupPages": "Pages", "searchGroupPages": "Pages",
"searchGroupWorkspace": "Projects & areas",
"searchWorkspaceHint": "switch context · ⌘P",
"searchWorkspaceSealedPickerHint": "open the picker · ⌘P",
"searchGroupModules": "Modules", "searchGroupModules": "Modules",
"searchGroupStore": "Store", "searchGroupStore": "Store",
"searchGroupFlows": "Flows", "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.", "federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite's first connect.",
"workspaceAll": "All projects", "workspaceAll": "All projects",
"workspaceDefaultProject": "General", "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.", "workspaceProtectedHint": "Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.",
"runsTitle": "Runs", "runsTitle": "Runs",
"runsReloadTooltip": "Reload the runs list", "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.", "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.", "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", "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", "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.", "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", "runsHubTooOldButton": "Open Doctor",

View file

@ -4400,6 +4400,24 @@ abstract class AppLocalizations {
/// **'Pages'** /// **'Pages'**
String get searchGroupPages; 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. /// No description provided for @searchGroupModules.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -5752,9 +5770,45 @@ abstract class AppLocalizations {
/// No description provided for @workspaceSwitcherTooltip. /// No description provided for @workspaceSwitcherTooltip.
/// ///
/// In en, this message translates to: /// 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; 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. /// No description provided for @workspaceProtectedHint.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -5797,6 +5851,24 @@ abstract class AppLocalizations {
/// **'Open the guide'** /// **'Open the guide'**
String get runsEmptyGuideButton; 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. /// No description provided for @runsHubTooOldTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -2595,6 +2595,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get searchGroupPages => 'Seiten'; 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 @override
String get searchGroupModules => 'Module'; String get searchGroupModules => 'Module';
@ -3414,7 +3423,30 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get workspaceSwitcherTooltip => 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 @override
String get workspaceProtectedHint => String get workspaceProtectedHint =>
@ -3440,6 +3472,18 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get runsEmptyGuideButton => 'Anleitung öffnen'; 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 @override
String get runsHubTooOldTitle => String get runsHubTooOldTitle =>
'Diese Ansicht braucht eine neuere Hub-Version'; 'Diese Ansicht braucht eine neuere Hub-Version';

View file

@ -2593,6 +2593,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get searchGroupPages => 'Pages'; 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 @override
String get searchGroupModules => 'Modules'; String get searchGroupModules => 'Modules';
@ -3407,7 +3416,30 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get workspaceSwitcherTooltip => 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 @override
String get workspaceProtectedHint => String get workspaceProtectedHint =>
@ -3433,6 +3465,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get runsEmptyGuideButton => 'Open the guide'; 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 @override
String get runsHubTooOldTitle => 'This view needs a newer hub version'; 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. /// Refreshed alongside the health probe same 5 s tick.
int _pendingApprovals = 0; int _pendingApprovals = 0;
/// Opens the workspace anchor's menu from the Cmd+P shortcut.
final GlobalKey<PopupMenuButtonState<String>> _workspaceMenuKey =
GlobalKey<PopupMenuButtonState<String>>();
@override @override
void initState() { void initState() {
super.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(); _checkHealth();
_healthPoll = Timer.periodic( _healthPoll = Timer.periodic(
const Duration(seconds: 5), 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 /// One-time hub-update hint (zero-learning-curve: the shell says
/// actively that a newer hub release exists instead of hiding it /// actively that a newer hub release exists instead of hiding it
/// on the doctor page). Null = nothing to show. Dismissal is /// on the doctor page). Null = nothing to show. Dismissal is
@ -627,6 +640,7 @@ class StudioShellState extends State<StudioShell> {
@override @override
void dispose() { void dispose() {
Workspace.instance.removeListener(_onWorkspaceChanged);
_healthPoll?.cancel(); _healthPoll?.cancel();
super.dispose(); super.dispose();
} }
@ -656,10 +670,76 @@ class StudioShellState extends State<StudioShell> {
ChainSettingsDialog.show(context); ChainSettingsDialog.show(context);
}, },
), ),
..._workspaceSearchHits(l),
]; ];
ChainSearchPalette.show(context, staticHits: hits); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -699,6 +779,12 @@ class StudioShellState extends State<StudioShell> {
const _OpenSearchIntent(), const _OpenSearchIntent(),
const SingleActivator(LogicalKeyboardKey.keyK, control: true): const SingleActivator(LogicalKeyboardKey.keyK, control: true):
const _OpenSearchIntent(), 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( child: Actions(
actions: <Type, Action<Intent>>{ actions: <Type, Action<Intent>>{
@ -720,6 +806,12 @@ class StudioShellState extends State<StudioShell> {
return null; return null;
}, },
), ),
_OpenWorkspaceIntent: CallbackAction<_OpenWorkspaceIntent>(
onInvoke: (_) {
_workspaceMenuKey.currentState?.showButtonMenu();
return null;
},
),
}, },
child: Focus( child: Focus(
autofocus: true, autofocus: true,
@ -738,6 +830,7 @@ class StudioShellState extends State<StudioShell> {
pendingApprovals: _pendingApprovals, pendingApprovals: _pendingApprovals,
forceExpanded: widget.startSidebarExpanded || pinned, forceExpanded: widget.startSidebarExpanded || pinned,
onOpenSearch: _openSearchPalette, onOpenSearch: _openSearchPalette,
workspaceMenuKey: _workspaceMenuKey,
), ),
), ),
Container(width: 1, color: theme.colorScheme.outlineVariant), Container(width: 1, color: theme.colorScheme.outlineVariant),
@ -866,6 +959,10 @@ class _OpenSearchIntent extends Intent {
const _OpenSearchIntent(); const _OpenSearchIntent();
} }
class _OpenWorkspaceIntent extends Intent {
const _OpenWorkspaceIntent();
}
/// Platform-truthful label for the app's primary-modifier /// Platform-truthful label for the app's primary-modifier
/// shortcuts (the activators bind on macOS and Ctrl elsewhere). /// shortcuts (the activators bind on macOS and Ctrl elsewhere).
String _metaShortcut(String key) => String _metaShortcut(String key) =>
@ -899,6 +996,11 @@ class _Sidebar extends StatefulWidget {
/// knowing the shortcut. /// knowing the shortcut.
final VoidCallback? onOpenSearch; 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({ const _Sidebar({
required this.selectedIndex, required this.selectedIndex,
required this.onSelect, required this.onSelect,
@ -909,6 +1011,7 @@ class _Sidebar extends StatefulWidget {
this.pendingApprovals = 0, this.pendingApprovals = 0,
this.forceExpanded = false, this.forceExpanded = false,
this.onOpenSearch, this.onOpenSearch,
this.workspaceMenuKey,
}); });
@override @override
@ -932,6 +1035,7 @@ class _SidebarState extends State<_Sidebar>
static const double _brandRowH = 48; static const double _brandRowH = 48;
static const double _connRowH = 44; static const double _connRowH = 44;
static const double _channelRowH = 28; static const double _channelRowH = 28;
static const double _anchorRowH = 44;
static const double _rowGap = 8; static const double _rowGap = 8;
late final AnimationController _ctrl; late final AnimationController _ctrl;
// True while the channel-switch menu is open suppresses the // True while the channel-switch menu is open suppresses the
@ -1110,6 +1214,31 @@ class _SidebarState extends State<_Sidebar>
) )
: const SizedBox.shrink(), : 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), const SizedBox(height: ChainSpace.md),
Expanded( Expanded(
child: ListView( child: ListView(

View file

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

View file

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

View file

@ -23,7 +23,6 @@ import '../data/store_caps.dart';
import '../data/workspace.dart'; import '../data/workspace.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../widgets/chain_install_confirm.dart'; import '../widgets/chain_install_confirm.dart';
import '../widgets/chain_workspace_switcher.dart';
class FlowsPage extends StatefulWidget { class FlowsPage extends StatefulWidget {
/// Pre-load this flow when the editor first builds. Studio /// Pre-load this flow when the editor first builds. Studio
@ -220,11 +219,9 @@ class _FlowsPageState extends State<FlowsPage> {
onInstallCapability: _onInstallCapability, onInstallCapability: _onInstallCapability,
onAddModuleSource: _onAddModuleSource, onAddModuleSource: _onAddModuleSource,
activeProject: Workspace.instance.activeSlug, activeProject: Workspace.instance.activeSlug,
// Same switcher as Audit/Approvals/Runs, hosted in the // The workspace switcher lives ONCE in the shell sidebar
// editor's toolbar (the page's single header): it // (global anchor) the editor toolbar no longer hosts
// filters the flow list and is the project a new flow // its own copy.
// gets stamped with.
toolbarTrailing: const ChainWorkspaceSwitcher(),
// Native file dialog for the Run tab's file inputs — // Native file dialog for the Run tab's file inputs —
// nobody should have to type an absolute path by hand. // nobody should have to type an absolute path by hand.
onPickFile: _pickFlowInputFile, 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 { Future<void> _cancel(DetachedRun run) async {
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
setState(() => _cancelling.add(run.id)); setState(() => _cancelling.add(run.id));
@ -333,8 +341,6 @@ class _RunsPageState extends State<RunsPage> {
appBar: AppBar( appBar: AppBar(
title: Text(l.runsTitle), title: Text(l.runsTitle),
actions: [ actions: [
const ChainWorkspaceSwitcher(),
const SizedBox(width: ChainSpace.md),
IconButton( IconButton(
icon: const Icon(Icons.help_outline, size: 18), icon: const Icon(Icons.help_outline, size: 18),
tooltip: l.helpTooltip, tooltip: l.helpTooltip,
@ -361,22 +367,41 @@ class _RunsPageState extends State<RunsPage> {
updating: _updatingHub, updating: _updatingHub,
) )
: _runs.isEmpty : _runs.isEmpty
? ChainEmptyState( // With an active project filter the honest cause is the
icon: Icons.rocket_launch_outlined, // filter, not the feature name the project and offer the
title: l.runsEmptyTitle, // way out instead of the enable-the-feature guide
// The hub reports whether the operator enabled the // (persona finding: the old hint claimed a wrong cause).
// feature never claim "switched off" while it is on ? (_detachedEnabled && !Workspace.instance.isAll
// and there simply are no runs yet (usertest finding). ? ChainEmptyState(
hint: _detachedEnabled ? l.runsEmptyEnabledHint : l.runsEmptyHint, icon: Icons.rocket_launch_outlined,
// The guide carries the plain-language explanation plus title: l.runsEmptyFilteredTitle(_activeProjectLabel(l)),
// the exact operator steps (config snippet) a click hint: l.runsEmptyFilteredHint,
// target instead of a raw config key in the hint. action: OutlinedButton.icon(
action: OutlinedButton.icon( icon: const Icon(Icons.grid_view_outlined, size: 16),
icon: const Icon(Icons.menu_book_outlined, size: 16), label: Text(l.runsEmptyShowAll),
label: Text(l.runsEmptyGuideButton), onPressed: () => Workspace.instance.setActive(''),
onPressed: () => showFaiDoc(context, 'runs'), ),
), )
) : 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.
action: OutlinedButton.icon(
icon: const Icon(Icons.menu_book_outlined, size: 16),
label: Text(l.runsEmptyGuideButton),
onPressed: () => showFaiDoc(context, 'runs'),
),
))
: ListView.separated( : ListView.separated(
padding: const EdgeInsets.all(ChainSpace.lg), padding: const EdgeInsets.all(ChainSpace.lg),
itemCount: _runs.length, itemCount: _runs.length,

View file

@ -26,13 +26,21 @@ import 'hub_auth_policy_panel.dart';
import 'theme_picker_grid.dart'; import 'theme_picker_grid.dart';
class ChainSettingsDialog extends StatefulWidget { 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. /// 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>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (_) => const ChainSettingsDialog(), builder: (_) => ChainSettingsDialog(initialCategory: initialCategory),
); );
return ok ?? false; return ok ?? false;
} }
@ -88,6 +96,9 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
for (final c in _Category.values) {
if (c.name == widget.initialCategory) _category = c;
}
final ep = HubService.instance.currentEndpoint; final ep = HubService.instance.currentEndpoint;
_host = TextEditingController(text: ep.host); _host = TextEditingController(text: ep.host);
_port = TextEditingController(text: ep.port.toString()); _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, // One control, two mechanics (docs/architecture/projects.md,
// § Studio): for open/protected projects the choice sets the page // § Studio): for open/protected projects the choice sets the page
// filter AND the label new runs are stamped with. "All projects" // filter AND the label new runs are stamped with. "All projects"
// stays reachable a filter, not a jail. Sealed areas are a real // stays reachable a filter, not a jail. Sealed areas are a real
// CONNECTION SWITCH: selecting one reconnects Studio to the area's // CONNECTION SWITCH: selecting one reconnects Studio to the area's
// own hub (starting it first if stopped, with a notice) and colours // own hub (starting it first if stopped, after an explicit
// the identity bar. Studio's blue stays the app accent — the project // confirmation) and colours the identity bar. Studio's blue stays
// colour is marking, not theming. // 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'; import 'package:flutter/material.dart';
@ -17,15 +24,50 @@ import '../data/workspace_prefs.dart';
import '../data/workspace.dart'; import '../data/workspace.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import 'chain_settings_dialog.dart';
/// Menu-value scheme: '' = all projects, `p:<slug>` = shared project, /// 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 _kAll = '';
const _pProject = 'p:'; const _pProject = 'p:';
const _pSealed = 's:'; const _pSealed = 's:';
const _kSettingsSecurity = 'settings:security';
class ChainWorkspaceSwitcher extends StatelessWidget { /// Menu values for external mount points (the command palette)
const ChainWorkspaceSwitcher({super.key}); /// 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -35,232 +77,338 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
final theme = Theme.of(context); final theme = Theme.of(context);
final ws = Workspace.instance; final ws = Workspace.instance;
final label = workspaceContextLabel(l, ws);
final label = ws.inSealedArea final row = Row(
? ws.activeSealed!.name crossAxisAlignment: CrossAxisAlignment.center,
: (ws.isAll children: [
? l.workspaceAll SizedBox(
: (ws.active == null width: iconColumnWidth,
? ws.activeSlug child: Center(child: _ContextMark(ws: ws)),
: _projectLabel(l, ws.active!))); ),
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( return Tooltip(
message: l.workspaceSwitcherTooltip, message: l.workspaceSwitcherTooltip,
waitDuration: const Duration(milliseconds: 400),
child: PopupMenuButton<String>( child: PopupMenuButton<String>(
onOpened: ws.refresh, key: menuKey,
onSelected: (v) => _onSelected(context, v), tooltip: '', // the outer Tooltip carries the message
itemBuilder: (context) => _items(context, ws, l, theme), onOpened: () {
child: _pill(context, ws, l, theme, label), 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( /// The collapsed-column marking for the active context: grid = all
BuildContext context, /// projects, coloured dot = project, dot + lock = sealed area.
Workspace ws, class _ContextMark extends StatelessWidget {
AppLocalizations l, final Workspace ws;
ThemeData theme,
) {
final items = <PopupMenuEntry<String>>[
PopupMenuItem(
value: _kAll,
child: Row(
children: [
Icon(
Icons.grid_view_outlined,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: ChainSpace.sm),
Text(l.workspaceAll),
],
),
),
];
if (ws.projects.isNotEmpty) items.add(const PopupMenuDivider());
for (final p in ws.projects) {
items.add(
PopupMenuItem(
value: '$_pProject${p.slug}',
child: Row(
children: [
_ProjectDot(color: p.color),
const SizedBox(width: ChainSpace.sm),
Flexible(
child: Text(
_projectLabel(l, p),
overflow: TextOverflow.ellipsis,
),
),
if (p.isProtected) ...[
const SizedBox(width: ChainSpace.sm),
Tooltip(
message: l.workspaceProtectedHint,
child: Icon(
Icons.shield_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
);
}
if (ws.sealedAreas.isNotEmpty) {
items.add(const PopupMenuDivider());
items.add(
PopupMenuItem(
enabled: false,
height: 28,
child: Text(
l.workspaceSealedHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
),
);
// Area names often carry client/mandate identity, so the
// section starts aggregated ("2 sealed areas") and reveals
// names only on a deliberate tap unless the operator turned
// the direct listing back on in Settings -> Security
// (usertest security finding). The disabled PopupMenuItem is
// just the host; the section handles its own taps and pops
// the menu route with the regular `s:<slug>` value.
items.add(
PopupMenuItem<String>(
enabled: false,
padding: EdgeInsets.zero,
child: SealedAreaSection(
areas: ws.sealedAreas,
namesVisible: WorkspacePrefs.sealedNamesVisible.value,
),
),
);
}
return items;
}
Future<void> _onSelected(BuildContext context, String value) async { const _ContextMark({required this.ws});
final ws = Workspace.instance;
final l = AppLocalizations.of(context)!;
final messenger = ScaffoldMessenger.of(context);
if (value == _kAll) { @override
await ws.setActive(''); Widget build(BuildContext context) {
return; final theme = Theme.of(context);
} final sealed = ws.switching
if (value.startsWith(_pProject)) { ? (ws.switchTarget ?? ws.activeSealed)
await ws.setActive(value.substring(_pProject.length)); : ws.activeSealed;
return; if (sealed != null) {
} return Row(
if (value.startsWith(_pSealed)) {
final slug = value.substring(_pSealed.length);
SealedArea? area;
for (final a in ws.sealedAreas) {
if (a.slug == slug) area = a;
}
if (area == null) return;
// Announce the intent starting a stopped area takes a beat.
if (!area.running) {
messenger.showSnackBar(
SnackBar(content: Text(l.workspaceSealedStarting(area.name))),
);
}
final r = await ws.switchToSealed(area);
messenger.hideCurrentSnackBar();
if (!r.ok) {
messenger.showSnackBar(
SnackBar(
content: SelectableText(l.workspaceSealedSwitchFailed(r.error)),
duration: const Duration(seconds: 8),
),
);
} else if (r.started) {
messenger.showSnackBar(
SnackBar(content: Text(l.workspaceSealedStarted(area.name))),
);
}
}
}
Widget _pill(
BuildContext context,
Workspace ws,
AppLocalizations l,
ThemeData theme,
String label,
) {
final Widget leading;
if (ws.inSealedArea) {
leading = Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_ProjectDot(color: ws.activeSealed!.color), _ProjectDot(color: sealed.color),
const SizedBox(width: 4), const SizedBox(width: 4),
Icon( Icon(
Icons.lock_outline, Icons.lock_outline,
size: 13, size: 13,
color: theme.colorScheme.onSurfaceVariant, 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,
),
);
} }
if (ws.active != null) {
return _ProjectDot(color: ws.active!.color);
}
return Icon(
Icons.grid_view_outlined,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
);
}
}
return Container( /// Display label for the active context (shared by anchor + tests).
padding: const EdgeInsets.symmetric(horizontal: ChainSpace.md, vertical: 5), String workspaceContextLabel(AppLocalizations l, Workspace ws) {
decoration: BoxDecoration( final sealed = ws.switching
color: theme.colorScheme.surfaceContainerHighest, ? (ws.switchTarget ?? ws.activeSealed)
borderRadius: BorderRadius.circular(ChainRadius.sm), : 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,
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ 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( Icon(
Icons.arrow_drop_down, Icons.grid_view_outlined,
size: 18, size: 16,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
const SizedBox(width: ChainSpace.sm),
Expanded(child: Text(l.workspaceAll)),
_ActiveCheck(active: !inSealed && ws.isAll),
], ],
), ),
),
];
if (ws.projects.isNotEmpty) items.add(const PopupMenuDivider());
for (final p in ws.projects) {
items.add(
PopupMenuItem(
value: '$_pProject${p.slug}',
child: Row(
children: [
_ProjectDot(color: p.color),
const SizedBox(width: ChainSpace.sm),
Flexible(
child: Text(
_projectLabel(l, p),
overflow: TextOverflow.ellipsis,
),
),
if (p.isProtected) ...[
const SizedBox(width: ChainSpace.sm),
Tooltip(
message: l.workspaceProtectedHint,
child: Icon(
Icons.shield_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
const Spacer(),
_ActiveCheck(active: !inSealed && ws.activeSlug == p.slug),
],
),
),
);
}
if (ws.sealedAreas.isNotEmpty) {
items.add(const PopupMenuDivider());
items.add(
PopupMenuItem(
enabled: false,
height: 28,
child: Text(
l.workspaceSealedHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
),
);
// Area names often carry client/mandate identity, so the
// section starts aggregated ("2 sealed areas") and reveals
// names only on a deliberate tap unless the operator turned
// the direct listing back on in Settings -> Security
// (usertest security finding). The disabled PopupMenuItem is
// just the host; the section handles its own taps and pops
// the menu route with the regular `s:<slug>` value.
items.add(
PopupMenuItem<String>(
enabled: false,
padding: EdgeInsets.zero,
child: SealedAreaSection(
areas: ws.sealedAreas,
namesVisible: WorkspacePrefs.sealedNamesVisible.value,
activeSlug: ws.activeSealed?.slug,
),
),
);
}
return items;
}
/// 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;
}
if (value.startsWith(_pSealed)) {
final slug = value.substring(_pSealed.length);
SealedArea? area;
for (final a in ws.sealedAreas) {
if (a.slug == slug) area = a;
}
if (area == null) return;
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(target.name))),
);
}
WorkspacePrefs.recordRecentContext(value);
final r = await ws.switchToSealed(target);
messenger.hideCurrentSnackBar();
if (!r.ok) {
messenger.showSnackBar(
SnackBar(
content: SelectableText(l.workspaceSealedSwitchFailed(r.error)),
duration: const Duration(seconds: 8),
),
);
} else if (r.started) {
messenger.showSnackBar(
SnackBar(content: Text(l.workspaceSealedStarted(target.name))),
);
}
}
}
/// Trailing checkmark slot fixed width so rows with and without
/// the mark keep their text aligned.
class _ActiveCheck extends StatelessWidget {
final bool active;
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. /// widget test can pump both privacy modes directly.
/// ///
/// [namesVisible] = the operator's Settings choice. When false the /// [namesVisible] = the operator's Settings choice. When false the
/// block renders one aggregated row (lock + count); a deliberate /// block renders one aggregated row (lock + count) with a why-line
/// tap expands the named rows for THIS menu opening only nothing /// (the aggregation is a deliberate confidentiality decision and
/// is persisted from the reveal. Rows select via /// 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 /// `Navigator.pop(context, 's:<slug>')`, which hands the value to
/// the enclosing PopupMenuButton exactly like a regular item. /// the enclosing PopupMenuButton exactly like a regular item.
class SealedAreaSection extends StatefulWidget { class SealedAreaSection extends StatefulWidget {
final List<SealedArea> areas; final List<SealedArea> areas;
final bool namesVisible; final bool namesVisible;
/// Slug of the area Studio is connected to, or null its row
/// carries the active checkmark.
final String? activeSlug;
const SealedAreaSection({ const SealedAreaSection({
super.key, super.key,
required this.areas, required this.areas,
required this.namesVisible, required this.namesVisible,
this.activeSlug,
}); });
@override @override
@ -332,61 +487,102 @@ class _SealedAreaSectionState extends State<SealedAreaSection> {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
if (!widget.namesVisible && !_revealed) { if (!widget.namesVisible && !_revealed) {
return InkWell( return Column(
onTap: () => setState(() => _revealed = true), crossAxisAlignment: CrossAxisAlignment.start,
child: Padding( mainAxisSize: MainAxisSize.min,
padding: const EdgeInsets.symmetric( children: [
horizontal: 16, InkWell(
vertical: 10, onTap: () => setState(() => _revealed = true),
), child: Padding(
// Two lines instead of one row: popup menus cap their padding: const EdgeInsets.symmetric(
// width, and action texts must never be cut off horizontal: 16,
// (usertest finding class). Count on top, the reveal vertical: 10,
// action fully readable beneath it. ),
child: Column( // Two lines instead of one row: popup menus cap their
crossAxisAlignment: CrossAxisAlignment.start, // width, and action texts must never be cut off
mainAxisSize: MainAxisSize.min, // (usertest finding class). Count on top, the reveal
children: [ // action fully readable beneath it.
Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Row(
Icons.lock_outline, mainAxisSize: MainAxisSize.min,
size: 13, children: [
color: theme.colorScheme.onSurfaceVariant, Icon(
Icons.lock_outline,
size: 13,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: ChainSpace.sm),
Text(
l.workspaceSealedAggregate(widget.areas.length),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
),
],
), ),
const SizedBox(width: ChainSpace.sm), const SizedBox(height: 2),
Text( Padding(
l.workspaceSealedAggregate(widget.areas.length), padding: const EdgeInsets.only(left: 21),
style: theme.textTheme.bodyMedium?.copyWith( child: Row(
color: theme.colorScheme.onSurface, mainAxisSize: MainAxisSize.min,
children: [
Text(
l.workspaceSealedRevealAction,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
Icon(
Icons.expand_more,
size: 14,
color: theme.colorScheme.primary,
),
],
), ),
), ),
], ],
), ),
const SizedBox(height: 2), ),
Padding( ),
padding: const EdgeInsets.only(left: 21), // The why-line: aggregation is not a glitch but a
child: Row( // confidentiality choice say so where it happens, and
mainAxisSize: MainAxisSize.min, // point at the Settings toggle for machines where the
children: [ // listing is fine.
Text( Padding(
l.workspaceSealedRevealAction, 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( style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
), ),
Icon( ),
Icons.expand_more,
size: 14,
color: theme.colorScheme.primary,
),
],
), ),
), ],
], ),
), ),
), ],
); );
} }
return Column( 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 @override
Future<List<SavedFlow>> listFlows() => _async('listFlows', () => const []); 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 @override
Future<List<ProjectRef>> listProjects() => Future<List<ProjectRef>> listProjects() =>
_async('listProjects', () => const []); _async('listProjects', () => projects);
@override @override
Stream<AuditEvent> streamEvents({ Stream<AuditEvent> streamEvents({

View file

@ -1,10 +1,13 @@
// Workspace switcher stage-1 contract: the control renders the // Workspace anchor contract: the sidebar's ONE switcher control
// active selection, lists "All projects" plus every registry // renders the active selection, lists "All projects" plus every
// project (colour dot, shield for protected), and switching // registry project (colour dot, shield for protected, checkmark on
// updates the shared Workspace notifier. // 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/material.dart';
import 'package:flutter_test/flutter_test.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/hub.dart';
import 'package:chain_studio/data/sealed_areas.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/l10n/app_localizations.dart';
import 'package:chain_studio/widgets/chain_workspace_switcher.dart'; import 'package:chain_studio/widgets/chain_workspace_switcher.dart';
import 'support/fake_hub.dart';
Widget _host() { Widget _host() {
return const MaterialApp( return const MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates, localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold( home: Scaffold(
appBar: PreferredSize( body: Align(
preferredSize: Size.fromHeight(kToolbarHeight), alignment: Alignment.topLeft,
child: Material(child: ChainWorkspaceSwitcher()), 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() { 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(() { setUp(() {
// Seed the singleton without a hub: tests drive the notifier SharedPreferences.setMockInitialValues({});
// directly through its test hook. fakeHub = installFakeHub();
Workspace.instance.debugSeed(projects: [_general, _clientA], active: ''); addTearDown(() => SealedAreaService.debugSetInstance(null));
seed();
}); });
tearDown(() { tearDown(() {
WorkspacePrefs.sealedNamesVisible.value = false; WorkspacePrefs.sealedNamesVisible.value = false;
WorkspacePrefs.recentContexts.value = const [];
}); });
testWidgets('sealed areas stay aggregated until deliberately revealed', ( testWidgets('sealed areas stay aggregated until deliberately revealed', (
tester, tester,
) async { ) async {
Workspace.instance.debugSeed( seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]);
projects: [_general],
active: '',
sealed: [_sealedGrid, _sealedLab],
);
await tester.pumpWidget(_host()); await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceSwitcher)); await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// No names on a casual glance only the aggregate row. // No names on a casual glance only the aggregate row.
expect(find.text('grid'), findsNothing); expect(find.text('grid'), findsNothing);
@ -67,17 +99,30 @@ void main() {
expect(find.text('lab'), findsOneWidget); 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', ( testWidgets('the Settings toggle restores the direct listing', (
tester, tester,
) async { ) async {
WorkspacePrefs.sealedNamesVisible.value = true; WorkspacePrefs.sealedNamesVisible.value = true;
Workspace.instance.debugSeed( seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]);
projects: [_general],
active: '',
sealed: [_sealedGrid, _sealedLab],
);
await tester.pumpWidget(_host()); await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceSwitcher)); await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('grid'), findsOneWidget); expect(find.text('grid'), findsOneWidget);
expect(find.text('lab'), findsOneWidget); expect(find.text('lab'), findsOneWidget);
@ -95,7 +140,7 @@ void main() {
tester, tester,
) async { ) async {
await tester.pumpWidget(_host()); await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceSwitcher)); await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('All projects'), findsWidgets); expect(find.text('All projects'), findsWidgets);
@ -105,21 +150,38 @@ void main() {
expect(find.byIcon(Icons.shield_outlined), findsOneWidget); 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', ( testWidgets('selecting a project updates the workspace and the label', (
tester, tester,
) async { ) async {
await tester.pumpWidget(_host()); await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceSwitcher)); await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Client A').last); await tester.tap(find.text('Client A').last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(Workspace.instance.activeSlug, 'client-a'); expect(Workspace.instance.activeSlug, 'client-a');
expect(Workspace.instance.active?.isProtected, isTrue); expect(Workspace.instance.active?.isProtected, isTrue);
// The closed control now shows the active project (label + // The closed control now shows the active project.
// shield marker for protected).
expect(find.text('Client A'), findsOneWidget); 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', () { 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', ( testWidgets('lists sealed areas with lock + running/stopped status', (
tester, tester,
) async { ) async {
Workspace.instance.debugSeed( seed(
projects: [_general], projects: [_general],
active: '',
sealed: const [ sealed: const [
SealedArea( SealedArea(
slug: 'grid', slug: 'grid',
@ -153,7 +214,7 @@ void main() {
], ],
); );
await tester.pumpWidget(_host()); await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceSwitcher)); await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Sealed areas appear under the sealed header, aggregated by // Sealed areas appear under the sealed header, aggregated by
@ -169,13 +230,31 @@ void main() {
expect(find.text('stopped'), findsOneWidget); 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, tester,
) async { ) 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], projects: [_general],
active: '',
sealed: const [],
activeSealed: const SealedArea( activeSealed: const SealedArea(
slug: 'grid', slug: 'grid',
name: 'Grid', name: 'Grid',
@ -186,12 +265,24 @@ void main() {
); );
await tester.pumpWidget(_host()); await tester.pumpWidget(_host());
expect(Workspace.instance.inSealedArea, isTrue); 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.text('Grid'), findsOneWidget);
expect(find.byIcon(Icons.lock_outline), findsWidgets); 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 // Sealed-area confidentiality (usertest security finding): the
// switcher must not disclose sealed-area names often client // switcher must not disclose sealed-area names often client
// identity on a casual glance. Aggregated row by default, // identity on a casual glance. Aggregated row by default,