// 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, 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'; import '../data/hub.dart' show ProjectRef; import '../data/sealed_areas.dart'; 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:` = shared project, /// `s:` = 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'; /// 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>? 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) { return ListenableBuilder( listenable: Workspace.instance, builder: (context, _) { final l = AppLocalizations.of(context)!; final theme = Theme.of(context); final ws = Workspace.instance; final label = workspaceContextLabel(l, ws); 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( 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), ), ); }, ); } } /// 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> workspaceMenuItems( BuildContext context, { required Workspace ws, required AppLocalizations l, required ThemeData theme, }) { final inSealed = ws.inSealedArea; final items = >[ PopupMenuItem( value: _kAll, child: Row( children: [ Icon( Icons.grid_view_outlined, size: 16, 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:` value. items.add( PopupMenuItem( 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 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( 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, ); } } /// Display name for a registry project. The hub's default project /// carries the fixed English name "General"; render it localized /// ("Allgemein") so no English label sits in the German menu. String _projectLabel(AppLocalizations l, ProjectRef p) => p.slug == 'general' ? l.workspaceDefaultProject : p.name; /// The project's registry colour as a small marking dot. Falls back /// to the theme's outline colour when unset. class _ProjectDot extends StatelessWidget { final String color; const _ProjectDot({required this.color}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( width: 10, height: 10, decoration: BoxDecoration( color: parseAreaColor(color) ?? theme.colorScheme.outline, shape: BoxShape.circle, ), ); } } /// Parse a `#rrggbb` hex colour, or null when unparseable. Color? parseAreaColor(String hex) { final h = hex.replaceFirst('#', ''); if (h.length != 6) return null; final v = int.tryParse(h, radix: 16); if (v == null) return null; return Color(0xFF000000 | v); } /// The sealed-areas block of the switcher menu. Public so the /// widget test can pump both privacy modes directly. /// /// [namesVisible] = the operator's Settings choice. When false the /// 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:')`, which hands the value to /// the enclosing PopupMenuButton exactly like a regular item. class SealedAreaSection extends StatefulWidget { final List 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 State createState() => _SealedAreaSectionState(); } class _SealedAreaSectionState extends State { bool _revealed = false; @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; if (!widget.namesVisible && !_revealed) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ InkWell( onTap: () => setState(() => _revealed = true), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 10, ), // Two lines instead of one row: popup menus cap their // width, and action texts must never be cut off // (usertest finding class). Count on top, the reveal // action fully readable beneath it. child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ 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(height: 2), Padding( padding: const EdgeInsets.only(left: 21), child: Row( 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, ), ], ), ), ], ), ), ), // 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( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final a in widget.areas) InkWell( onTap: () => Navigator.pop(context, '$_pSealed${a.slug}'), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 10, ), child: Row( children: [ _ProjectDot(color: a.color), const SizedBox(width: ChainSpace.sm), Icon( Icons.lock_outline, size: 13, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: 4), Flexible( child: Text( a.name, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface, ), ), ), const SizedBox(width: ChainSpace.sm), Tooltip( message: a.running ? l.workspaceSealedRunningHint : l.workspaceSealedStoppedHint, child: Text( a.running ? l.workspaceSealedRunning : l.workspaceSealedStopped, style: theme.textTheme.labelSmall?.copyWith( // Secondary but readable — onSurfaceVariant // fell below comfortable contrast at this // size (usertest finding). color: a.running ? ChainColors.success : theme.colorScheme.onSurface .withValues(alpha: 0.8), ), ), ), const Spacer(), _ActiveCheck(active: a.slug == widget.activeSlug), ], ), ), ), ], ); } }