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

@ -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(