diff --git a/app/lib/providers/session_setup.dart b/app/lib/providers/session_setup.dart index 128889e1..1b4d5865 100644 --- a/app/lib/providers/session_setup.dart +++ b/app/lib/providers/session_setup.dart @@ -18,6 +18,91 @@ export '../services/sessions_service.dart' show SessionSetupAction; /// of being read as either finished or still going. enum SessionSetupPhase { running, done, failed, skipped, interrupted, unknown } +/// How long a successful setup confirmation remains above the agent surface. +const Duration kSessionSetupSuccessHold = Duration(seconds: 3); + +/// Ephemeral presentation state that must outlive an AgentPanel mount. +/// +/// The panel is intentionally absent while the context pane is full-width. +/// Keeping this state in the banner would resurrect dismissed results and +/// re-enable setup actions whenever the user returned to the agent. +class SessionSetupBannerUiState { + const SessionSetupBannerUiState({ + this.hiddenRunKeys = const {}, + this.expandedSessionId, + this.actingSessionIds = const {}, + }); + + final Set hiddenRunKeys; + final String? expandedSessionId; + final Set actingSessionIds; +} + +class SessionSetupBannerUiController + extends Notifier { + final Map _successTimers = {}; + + @override + SessionSetupBannerUiState build() { + ref.onDispose(() { + for (final timer in _successTimers.values) { + timer.cancel(); + } + }); + return const SessionSetupBannerUiState(); + } + + void hide(String runKey) { + _successTimers.remove(runKey)?.cancel(); + state = SessionSetupBannerUiState( + hiddenRunKeys: {...state.hiddenRunKeys, runKey}, + expandedSessionId: state.expandedSessionId, + actingSessionIds: state.actingSessionIds, + ); + } + + void toggleExpanded(String sessionId, String runKey) { + _successTimers.remove(runKey)?.cancel(); + state = SessionSetupBannerUiState( + hiddenRunKeys: state.hiddenRunKeys, + expandedSessionId: state.expandedSessionId == sessionId + ? null + : sessionId, + actingSessionIds: state.actingSessionIds, + ); + } + + void setActing(String sessionId, bool acting) { + final next = {...state.actingSessionIds}; + if (acting) { + next.add(sessionId); + } else { + next.remove(sessionId); + } + state = SessionSetupBannerUiState( + hiddenRunKeys: state.hiddenRunKeys, + expandedSessionId: state.expandedSessionId, + actingSessionIds: next, + ); + } + + void hideSuccessAfterDelay(String runKey) { + if (state.hiddenRunKeys.contains(runKey) || + _successTimers.containsKey(runKey)) { + return; + } + _successTimers[runKey] = Timer(kSessionSetupSuccessHold, () { + _successTimers.remove(runKey); + hide(runKey); + }); + } +} + +final sessionSetupBannerUiProvider = + NotifierProvider( + SessionSetupBannerUiController.new, + ); + SessionSetupPhase sessionSetupPhase(SessionSetup? setup) => switch (setup?.state) { 'running' => SessionSetupPhase.running, @@ -51,17 +136,16 @@ typedef SessionSetupResult = ({bool ok, String? error}); /// rendered row of a cross-project Recent list. Kept alive, every session id /// ever scrolled past — deleted ones included — would leave a provider behind, /// each re-running its scan of the list on every `session:updated`. -final sessionSetupProvider = Provider.autoDispose.family(( - ref, - sessionId, -) { - final state = ref.watch(freshSessionsStateProvider); - if (state == null) return null; - for (final s in state.sessions) { - if (s.id == sessionId) return s.setup; - } - return null; -}); +final sessionSetupProvider = Provider.autoDispose.family( + (ref, sessionId) { + final state = ref.watch(freshSessionsStateProvider); + if (state == null) return null; + for (final s in state.sessions) { + if (s.id == sessionId) return s.setup; + } + return null; + }, +); /// [sessionSetupProvider] for the session the workspace is showing. final activeSessionSetupProvider = Provider.autoDispose((ref) { diff --git a/app/lib/screens/workspace_shell.dart b/app/lib/screens/workspace_shell.dart index b5833edc..f129af34 100644 --- a/app/lib/screens/workspace_shell.dart +++ b/app/lib/screens/workspace_shell.dart @@ -59,7 +59,6 @@ import '../widgets/operational_error_toaster.dart'; import '../widgets/projects_drawer.dart'; import '../widgets/session_search_modal.dart'; import '../widgets/session_start_refusal.dart'; -import '../widgets/session_setup_banner.dart'; import '../design/widgets/pulsing_opacity.dart'; import '../widgets/resizable_pane.dart'; import '../widgets/workspace_tab_bar.dart'; @@ -1108,7 +1107,6 @@ class WorkspaceShellState extends ConsumerState const OperationalErrorToaster(), const AbBanner(), const AbHostBanner(), - const SessionSetupBanner(), Expanded( child: isMobile ? _buildMobile(surfaceChild) diff --git a/app/lib/widgets/agent_panel.dart b/app/lib/widgets/agent_panel.dart index cada2959..d92d74a3 100644 --- a/app/lib/widgets/agent_panel.dart +++ b/app/lib/widgets/agent_panel.dart @@ -47,6 +47,7 @@ import 'remote_host_chip.dart'; import 'session_agent_mark.dart'; import 'session_mode_control.dart'; import 'session_rename_dialog.dart'; +import 'session_setup_banner.dart'; import 'window_title_bar.dart'; import 'workspace_menu_button.dart'; @@ -112,6 +113,7 @@ class AgentPanel extends ConsumerWidget { ) else const AgentBar(), + const SessionSetupBanner(), Expanded( child: isChat && activeId != null // Keyed by session so switching sessions rebuilds the State — diff --git a/app/lib/widgets/session_setup_banner.dart b/app/lib/widgets/session_setup_banner.dart index 58473c9b..b135e8c0 100644 --- a/app/lib/widgets/session_setup_banner.dart +++ b/app/lib/widgets/session_setup_banner.dart @@ -68,23 +68,6 @@ class _SessionSetupBannerState extends ConsumerState { /// carried-over line would describe work that is no longer happening. String? _runKey; - /// Dismissal is per RUN, not per session: a rerun of a setup the user - /// dismissed is a new answer to the same question and has to be shown. - /// A set rather than one slot, keyed like [_expandedSessionId] and - /// [_actingSessionId]: this State survives a session switch, so a single - /// slot would un-dismiss whichever banner the user dismissed first. - final Set _dismissedRunKeys = {}; - - /// The log is expanded per session, so switching sessions collapses it - /// rather than opening a terminal for a workspace the user just left. - String? _expandedSessionId; - - /// The session a `session:setup` verb is in flight for, keyed like - /// [_expandedSessionId] rather than held as a bare bool: this State - /// survives a session switch, so a single flag would disable whichever - /// session happened to be on screen when a slow answer arrived. - String? _actingSessionId; - @override void dispose() { _tailTimer?.cancel(); @@ -96,6 +79,7 @@ class _SessionSetupBannerState extends ConsumerState { final sessionId = ref.watch(activeSessionIdProvider); final setup = ref.watch(activeSessionSetupProvider); final phase = sessionSetupPhase(setup); + final ui = ref.watch(sessionSetupBannerUiProvider); // `unknown` is a state this build cannot name — say nothing rather than // guess at either "still going" or "finished". if (sessionId == null || @@ -106,13 +90,19 @@ class _SessionSetupBannerState extends ConsumerState { } final runKey = '$sessionId|${setup.startedAt}'; - if (_dismissedRunKeys.contains(runKey)) { + if (ui.hiddenRunKeys.contains(runKey)) { _syncTail(null, null); return const SizedBox.shrink(); } + final expanded = ui.expandedSessionId == sessionId; + if (phase == SessionSetupPhase.done && !expanded) { + ref + .read(sessionSetupBannerUiProvider.notifier) + .hideSuccessAfterDelay(runKey); + } + final running = phase == SessionSetupPhase.running; - final expanded = _expandedSessionId == sessionId; final terminalId = setup.terminalId; // While the log is open the tail is on screen in full; sampling it twice // would only pay the formatter again for a line the user is already @@ -206,7 +196,11 @@ class _SessionSetupBannerState extends ConsumerState { AbButton( label: action.label, compact: true, - onTap: _actingSessionId == sessionId + onTap: + ref + .read(sessionSetupBannerUiProvider) + .actingSessionIds + .contains(sessionId) ? null : () => _act(sessionId, action.verb), ), @@ -215,8 +209,9 @@ class _SessionSetupBannerState extends ConsumerState { AbIconButton( icon: expanded ? AbIcons.chevronDown : AbIcons.chevronRight, tooltip: expanded ? 'Hide setup log' : 'View setup log', - onTap: () => - setState(() => _expandedSessionId = expanded ? null : sessionId), + onTap: () => ref + .read(sessionSetupBannerUiProvider.notifier) + .toggleExpanded(sessionId, runKey), ), // A run still going has nothing to dismiss to — the banner is the only // account of why the agent has not started yet. @@ -224,7 +219,8 @@ class _SessionSetupBannerState extends ConsumerState { AbIconButton( icon: AbIcons.close, tooltip: 'Dismiss', - onTap: () => setState(() => _dismissedRunKeys.add(runKey)), + onTap: () => + ref.read(sessionSetupBannerUiProvider.notifier).hide(runKey), ), ], ); @@ -290,7 +286,9 @@ class _SessionSetupBannerState extends ConsumerState { final container = ref.container; final entryId = container.read(selectedRegistrationIdProvider); if (entryId == null) return; - setState(() => _actingSessionId = sessionId); + container + .read(sessionSetupBannerUiProvider.notifier) + .setActing(sessionId, true); detached( 'SessionSetupBanner', 'session:setup ${verb.wire} failed', @@ -312,9 +310,9 @@ class _SessionSetupBannerState extends ConsumerState { if (container.read(activeSessionIdProvider) != sessionId) return; showAbSnackBar(context, '${_failureCopy(verb)} — ${result.error}'); } finally { - if (mounted && _actingSessionId == sessionId) { - setState(() => _actingSessionId = null); - } + container + .read(sessionSetupBannerUiProvider.notifier) + .setActing(sessionId, false); } }, ); diff --git a/app/test/widgets/session_setup_banner_test.dart b/app/test/widgets/session_setup_banner_test.dart index 4ba1645e..2760519f 100644 --- a/app/test/widgets/session_setup_banner_test.dart +++ b/app/test/widgets/session_setup_banner_test.dart @@ -13,6 +13,7 @@ import 'package:antgrid/models/session_entry.dart'; import 'package:antgrid/project/project_session.dart'; import 'package:antgrid/project/project_session_registry.dart'; import 'package:antgrid/providers/agent_transport.dart'; +import 'package:antgrid/providers/session_setup.dart'; import 'package:antgrid/providers/sessions.dart'; import 'package:antgrid/providers/value_controller.dart'; import 'package:antgrid/services/sessions_service.dart'; @@ -245,6 +246,35 @@ void main() { expect(find.byTooltip('View setup log'), findsOneWidget); expect(find.byType(AbProgressRule), findsNothing); }); + + testWidgets('a successful run clears after a short confirmation', ( + tester, + ) async { + await pumpBanner(tester, _setup('done', stepIndex: 3)); + + expect(find.text('Workspace ready'), findsOneWidget); + await tester.pump(kSessionSetupSuccessHold); + await tester.pump(); + + expect(find.byType(AbInlineBanner), findsNothing); + }); + + testWidgets('an open successful setup log stays until it is collapsed', ( + tester, + ) async { + await pumpBanner(tester, _setup('done', stepIndex: 3)); + await tester.tap(find.byTooltip('View setup log')); + await tester.pump(); + await tester.pump(kSessionSetupSuccessHold); + + expect(find.text('Workspace ready'), findsOneWidget); + await tester.tap(find.byTooltip('Hide setup log')); + await tester.pump(); + await tester.pump(kSessionSetupSuccessHold); + await tester.pump(); + + expect(find.byType(AbInlineBanner), findsNothing); + }); }); // Dismissal is keyed on the RUN, not the session: a rerun is a new answer to @@ -286,9 +316,8 @@ void main() { setup, extraOverrides: [ selectedRegistrationIdProvider.overrideWith((ref) => _projectId), - projectSessionProvider( - _projectId, - ).overrideWith((ref) async => session), + projectSessionProvider(_projectId) + .overrideWith((ref) async => session), ], ); return transport;