Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 95 additions & 11 deletions app/lib/providers/session_setup.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 <String>{},
this.expandedSessionId,
this.actingSessionIds = const <String>{},
});

final Set<String> hiddenRunKeys;
final String? expandedSessionId;
final Set<String> actingSessionIds;
}

class SessionSetupBannerUiController
extends Notifier<SessionSetupBannerUiState> {
final Map<String, Timer> _successTimers = <String, Timer>{};

@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, SessionSetupBannerUiState>(
SessionSetupBannerUiController.new,
);

SessionSetupPhase sessionSetupPhase(SessionSetup? setup) =>
switch (setup?.state) {
'running' => SessionSetupPhase.running,
Expand Down Expand Up @@ -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<SessionSetup?, String>((
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<SessionSetup?, String>(
(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<SessionSetup?>((ref) {
Expand Down
2 changes: 0 additions & 2 deletions app/lib/screens/workspace_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1108,7 +1107,6 @@ class WorkspaceShellState extends ConsumerState<WorkspaceShell>
const OperationalErrorToaster(),
const AbBanner(),
const AbHostBanner(),
const SessionSetupBanner(),
Expanded(
child: isMobile
? _buildMobile(surfaceChild)
Expand Down
2 changes: 2 additions & 0 deletions app/lib/widgets/agent_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 —
Expand Down
52 changes: 25 additions & 27 deletions app/lib/widgets/session_setup_banner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,23 +68,6 @@ class _SessionSetupBannerState extends ConsumerState<SessionSetupBanner> {
/// 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<String> _dismissedRunKeys = <String>{};

/// 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();
Expand All @@ -96,6 +79,7 @@ class _SessionSetupBannerState extends ConsumerState<SessionSetupBanner> {
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 ||
Expand All @@ -106,13 +90,19 @@ class _SessionSetupBannerState extends ConsumerState<SessionSetupBanner> {
}

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
Expand Down Expand Up @@ -206,7 +196,11 @@ class _SessionSetupBannerState extends ConsumerState<SessionSetupBanner> {
AbButton(
label: action.label,
compact: true,
onTap: _actingSessionId == sessionId
onTap:
ref
.read(sessionSetupBannerUiProvider)
.actingSessionIds
.contains(sessionId)
? null
: () => _act(sessionId, action.verb),
),
Expand All @@ -215,16 +209,18 @@ class _SessionSetupBannerState extends ConsumerState<SessionSetupBanner> {
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.
if (phase != SessionSetupPhase.running)
AbIconButton(
icon: AbIcons.close,
tooltip: 'Dismiss',
onTap: () => setState(() => _dismissedRunKeys.add(runKey)),
onTap: () =>
ref.read(sessionSetupBannerUiProvider.notifier).hide(runKey),
),
],
);
Expand Down Expand Up @@ -290,7 +286,9 @@ class _SessionSetupBannerState extends ConsumerState<SessionSetupBanner> {
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',
Expand All @@ -312,9 +310,9 @@ class _SessionSetupBannerState extends ConsumerState<SessionSetupBanner> {
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);
}
},
);
Expand Down
35 changes: 32 additions & 3 deletions app/test/widgets/session_setup_banner_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down