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

@ -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
? 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'),
),
)
// 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.
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,