From 55a932612fb8cd769224fee9772a23b97a5b98b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leynier=20Guti=C3=A9rrez=20Gonz=C3=A1lez?= Date: Tue, 6 Oct 2026 03:04:15 -0600 Subject: [PATCH] feat: add the inbox page to the desktop app --- lib/src/design_system/icons/alera_icons.dart | 1 + .../presentation/alera_app_menu_scope.dart | 8 + .../presentation/app_menu_actions.dart | 4 + .../infra/lifecycle_app_foreground.dart | 18 +- .../application/automations_navigation.dart | 7 + .../application/automations_navigation.g.dart | 2 +- .../inbox/application/inbox_navigation.dart | 74 ++++ .../inbox/application/inbox_navigation.g.dart | 62 +++ .../application/inbox_notifications.dart | 101 +++++ .../application/inbox_notifications.g.dart | 67 +++ .../inbox/application/inbox_providers.dart | 54 +++ .../inbox/application/inbox_providers.g.dart | 393 ++++++++++++++++++ .../inbox/domain/inbox_error_messages.dart | 38 ++ .../features/inbox/domain/inbox_models.dart | 301 ++++++++++++++ lib/src/features/inbox/infra/inbox_watch.dart | 68 +++ .../inbox/infra/runtime_inbox_repository.dart | 114 +++++ .../presentation/inbox_attention_control.dart | 51 +++ .../presentation/inbox_composer_dialog.dart | 243 +++++++++++ .../inbox/presentation/inbox_labels.dart | 51 +++ .../inbox/presentation/inbox_page.dart | 201 +++++++++ .../presentation/inbox_page_continuation.dart | 83 ++++ .../presentation/inbox_thread_detail.dart | 381 +++++++++++++++++ .../inbox/presentation/inbox_thread_list.dart | 246 +++++++++++ .../application/run_board_navigation.dart | 4 + .../application/run_board_navigation.g.dart | 2 +- .../shell/application/shell_overlay_page.dart | 26 ++ .../application/shell_overlay_page.g.dart | 56 +++ .../shell/presentation/alera_shell_page.dart | 8 +- .../presentation/alera_shell_page_body.dart | 61 +-- .../alera_shell_page_coordinators.dart | 1 + .../presentation/shell_overlay_page_view.dart | 32 ++ .../terminal_host_client_models.dart | 1 + .../terminal_host/terminal_host_protocol.dart | 1 + .../project_workbench_agent_rows.dart | 27 +- .../project_workbench_sidebar.dart | 1 + test/support/inbox_test_client.dart | 138 ++++++ test/unit/inbox_domain_test.dart | 56 +++ test/unit/inbox_navigation_test.dart | 75 ++++ test/unit/inbox_notification_focus_test.dart | 104 +++++ test/unit/runtime_inbox_repository_test.dart | 185 +++++++++ ..._shell_page_sidebar_titles_test_cases.dart | 86 ++++ test/widget/alera_shell_page_test.dart | 6 + test/widget/app_menu_button_test.dart | 17 + test/widget/inbox_acknowledgement_test.dart | 111 +++++ test/widget/inbox_page_test.dart | 243 +++++++++++ test/widget/inbox_paging_test.dart | 114 +++++ 46 files changed, 3856 insertions(+), 67 deletions(-) create mode 100644 lib/src/features/inbox/application/inbox_navigation.dart create mode 100644 lib/src/features/inbox/application/inbox_navigation.g.dart create mode 100644 lib/src/features/inbox/application/inbox_notifications.dart create mode 100644 lib/src/features/inbox/application/inbox_notifications.g.dart create mode 100644 lib/src/features/inbox/application/inbox_providers.dart create mode 100644 lib/src/features/inbox/application/inbox_providers.g.dart create mode 100644 lib/src/features/inbox/domain/inbox_error_messages.dart create mode 100644 lib/src/features/inbox/domain/inbox_models.dart create mode 100644 lib/src/features/inbox/infra/inbox_watch.dart create mode 100644 lib/src/features/inbox/infra/runtime_inbox_repository.dart create mode 100644 lib/src/features/inbox/presentation/inbox_attention_control.dart create mode 100644 lib/src/features/inbox/presentation/inbox_composer_dialog.dart create mode 100644 lib/src/features/inbox/presentation/inbox_labels.dart create mode 100644 lib/src/features/inbox/presentation/inbox_page.dart create mode 100644 lib/src/features/inbox/presentation/inbox_page_continuation.dart create mode 100644 lib/src/features/inbox/presentation/inbox_thread_detail.dart create mode 100644 lib/src/features/inbox/presentation/inbox_thread_list.dart create mode 100644 lib/src/features/shell/application/shell_overlay_page.dart create mode 100644 lib/src/features/shell/application/shell_overlay_page.g.dart create mode 100644 lib/src/features/shell/presentation/shell_overlay_page_view.dart create mode 100644 test/support/inbox_test_client.dart create mode 100644 test/unit/inbox_domain_test.dart create mode 100644 test/unit/inbox_navigation_test.dart create mode 100644 test/unit/inbox_notification_focus_test.dart create mode 100644 test/unit/runtime_inbox_repository_test.dart create mode 100644 test/widget/inbox_acknowledgement_test.dart create mode 100644 test/widget/inbox_page_test.dart create mode 100644 test/widget/inbox_paging_test.dart diff --git a/lib/src/design_system/icons/alera_icons.dart b/lib/src/design_system/icons/alera_icons.dart index a76c1218a..fbce5642d 100644 --- a/lib/src/design_system/icons/alera_icons.dart +++ b/lib/src/design_system/icons/alera_icons.dart @@ -202,6 +202,7 @@ abstract final class const AleraIcons._() { // Prompt composer: write/send a message into the active terminal. static const IconData composer = LucideIcons.messageSquarePlus; static const IconData comment = LucideIcons.messageSquare; + static const IconData inbox = LucideIcons.inbox; static const IconData ai = LucideIcons.sparkles; static const IconData plan = LucideIcons.lightbulb; static const IconData planActive = LucideIcons.lightbulb600; diff --git a/lib/src/features/app_menu/presentation/alera_app_menu_scope.dart b/lib/src/features/app_menu/presentation/alera_app_menu_scope.dart index e8c45d5ea..0b7a83966 100644 --- a/lib/src/features/app_menu/presentation/alera_app_menu_scope.dart +++ b/lib/src/features/app_menu/presentation/alera_app_menu_scope.dart @@ -70,6 +70,7 @@ enum _AppMenuAction { openRunBoard, openSettings, openAutomations, + openInbox, reviewExecutionPlans, checkForUpdates, undo, @@ -137,6 +138,7 @@ class _AleraAppMenuButtonState extends ConsumerState { value: .openAutomations, label: 'Automations', ), + AleraDropdownEntry<_AppMenuAction>(value: .openInbox, label: 'Inbox'), AleraDropdownEntry<_AppMenuAction>( value: .reviewExecutionPlans, label: 'Execution Plans', @@ -181,6 +183,8 @@ class _AleraAppMenuButtonState extends ConsumerState { await openAppMenuSettings(context); case _AppMenuAction.openAutomations: openAutomationsPage(ref); + case _AppMenuAction.openInbox: + openInboxFromAppMenu(ref); case _AppMenuAction.reviewExecutionPlans: await showRunPolicyReviewDialog(context); case _AppMenuAction.checkForUpdates: @@ -261,6 +265,10 @@ class const _MacOsPlatformMenuBar({required final Widget child}) label: 'Run Board', onSelected: () => openRunBoardFromAppMenu(ref), ), + PlatformMenuItem( + label: 'Inbox', + onSelected: () => openInboxFromAppMenu(ref), + ), PlatformMenuItem( label: 'Execution Plans', onSelected: () { diff --git a/lib/src/features/app_menu/presentation/app_menu_actions.dart b/lib/src/features/app_menu/presentation/app_menu_actions.dart index f8389b1fc..d0734ed3e 100644 --- a/lib/src/features/app_menu/presentation/app_menu_actions.dart +++ b/lib/src/features/app_menu/presentation/app_menu_actions.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:alera/src/features/inbox/application/inbox_navigation.dart'; import 'package:alera/src/features/orchestration/application/run_board_navigation.dart'; import 'package:alera/src/app/providers.dart'; @@ -19,6 +20,9 @@ typedef AppMenuPackageInfoLoader = Future Function(); void openRunBoardFromAppMenu(WidgetRef ref) => ref.read(runBoardNavigationProvider.notifier).open(); +void openInboxFromAppMenu(WidgetRef ref) => + ref.read(inboxNavigationProvider.notifier).open(); + Future openAppMenuSettings(BuildContext context) { return openSettingsDialog(context); } diff --git a/lib/src/features/app_window/infra/lifecycle_app_foreground.dart b/lib/src/features/app_window/infra/lifecycle_app_foreground.dart index f4853f5a3..c42a6c1da 100644 --- a/lib/src/features/app_window/infra/lifecycle_app_foreground.dart +++ b/lib/src/features/app_window/infra/lifecycle_app_foreground.dart @@ -4,10 +4,19 @@ import 'package:alera/src/features/app_window/domain/app_foreground.dart'; import 'package:flutter/widgets.dart'; /// [AppForeground] backed by the Flutter app lifecycle. -class LifecycleAppForeground() implements AppForeground { +/// +/// With [requireFocus], an unfocused window (`inactive` on desktop) counts as +/// background too: for acknowledging something the user must actually be +/// looking at, rather than for parking work. +class LifecycleAppForeground({final bool requireFocus = false}) + implements AppForeground { this { try { _listener = AppLifecycleListener(onStateChange: _apply); + final initial = WidgetsBinding.instance.lifecycleState; + if (requireFocus && initial != null) { + _isForeground = _isForegroundState(initial, requireFocus: true); + } } catch (_) { // No widgets binding, so there is no lifecycle to observe: a unit test, // or anything constructed before `runApp`. Reporting a permanent @@ -35,7 +44,7 @@ class LifecycleAppForeground() implements AppForeground { } void _apply(AppLifecycleState state) { - final next = _isForegroundState(state); + final next = _isForegroundState(state, requireFocus: requireFocus); if (next == _isForeground) { return; } @@ -48,9 +57,10 @@ class LifecycleAppForeground() implements AppForeground { /// every time the user reads something in another app. Parking there would stop /// updating state the user is about to look back at, so only states where the /// window is actually gone from view count as background. -bool _isForegroundState(AppLifecycleState state) { +bool _isForegroundState(AppLifecycleState state, {required bool requireFocus}) { return switch (state) { - AppLifecycleState.resumed || AppLifecycleState.inactive => true, + AppLifecycleState.resumed => true, + AppLifecycleState.inactive => !requireFocus, AppLifecycleState.hidden || AppLifecycleState.paused || AppLifecycleState.detached => false, diff --git a/lib/src/features/automations/application/automations_navigation.dart b/lib/src/features/automations/application/automations_navigation.dart index 7fd7419d1..ad0b37695 100644 --- a/lib/src/features/automations/application/automations_navigation.dart +++ b/lib/src/features/automations/application/automations_navigation.dart @@ -1,4 +1,5 @@ import 'package:alera/src/features/automations/domain/automation_catalog_query.dart'; +import 'package:alera/src/features/inbox/application/inbox_navigation.dart'; import 'package:alera/src/features/orchestration/application/run_board_navigation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -51,6 +52,9 @@ class AutomationsNavigation extends _$AutomationsNavigation { /// The Run Board and Automations share the shell's page slot. void open({AutomationScope? scope}) { ref.read(runBoardNavigationProvider.notifier).close(); + if (ref.read(inboxNavigationProvider).visible) { + ref.read(inboxNavigationProvider.notifier).close(); + } state = state.copyWith( visible: true, scope: scope, @@ -82,6 +86,9 @@ class AutomationsNavigation extends _$AutomationsNavigation { AutomationScope? scope, }) { ref.read(runBoardNavigationProvider.notifier).close(); + if (ref.read(inboxNavigationProvider).visible) { + ref.read(inboxNavigationProvider.notifier).close(); + } state = state.copyWith( visible: true, scope: scope, diff --git a/lib/src/features/automations/application/automations_navigation.g.dart b/lib/src/features/automations/application/automations_navigation.g.dart index b0a8c96fc..ec16900a6 100644 --- a/lib/src/features/automations/application/automations_navigation.g.dart +++ b/lib/src/features/automations/application/automations_navigation.g.dart @@ -42,7 +42,7 @@ final class AutomationsNavigationProvider } String _$automationsNavigationHash() => - r'81df095a14a36fbd040e7dc50a1bad7262036de3'; + r'408bc9aae86d5a30ad032c6ee5a16f66b5be1634'; abstract class _$AutomationsNavigation extends $Notifier { AutomationsLocation build(); diff --git a/lib/src/features/inbox/application/inbox_navigation.dart b/lib/src/features/inbox/application/inbox_navigation.dart new file mode 100644 index 000000000..4574bda33 --- /dev/null +++ b/lib/src/features/inbox/application/inbox_navigation.dart @@ -0,0 +1,74 @@ +import 'package:alera/src/features/automations/application/automations_navigation.dart'; +import 'package:alera/src/features/inbox/domain/inbox_models.dart'; +import 'package:alera/src/features/orchestration/application/run_board_navigation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'inbox_navigation.g.dart'; + +/// A request to open the composer, optionally with a recipient chosen. +class const InboxComposeRequest({final String? targetHandle}); + +class const InboxLocation({ + final bool visible = false, + final String? inboxFilter, + final InboxQuestionStatus? statusFilter, + final String? selectedThreadId, + final InboxComposeRequest? compose, + final int composeSequence = 0, +}) { + InboxLocation copyWith({ + bool? visible, + String? Function()? inboxFilter, + InboxQuestionStatus? Function()? statusFilter, + String? Function()? selectedThreadId, + InboxComposeRequest? Function()? compose, + int? composeSequence, + }) => InboxLocation( + visible: visible ?? this.visible, + inboxFilter: inboxFilter == null ? this.inboxFilter : inboxFilter(), + statusFilter: statusFilter == null ? this.statusFilter : statusFilter(), + selectedThreadId: selectedThreadId == null + ? this.selectedThreadId + : selectedThreadId(), + compose: compose == null ? this.compose : compose(), + composeSequence: composeSequence ?? this.composeSequence, + ); +} + +// Retain only navigation, never runtime snapshots or subscriptions. +@Riverpod(keepAlive: true) +class InboxNavigation extends _$InboxNavigation { + @override + InboxLocation build() => const InboxLocation(); + + /// The Run Board, Automations and the inbox share the shell's page slot. + void open() { + ref.read(runBoardNavigationProvider.notifier).close(); + ref.read(automationsNavigationProvider.notifier).close(); + state = state.copyWith(visible: true); + } + + void close() => state = state.copyWith(visible: false); + + void selectThread(String? threadId) => + state = state.copyWith(selectedThreadId: () => threadId); + + void filterInbox(String? inbox) => state = state.copyWith( + inboxFilter: () => inbox, + selectedThreadId: () => null, + ); + + void filterStatus(InboxQuestionStatus? status) => + state = state.copyWith(statusFilter: () => status); + + /// Opens the page and asks it to show the composer once mounted. + void compose({String? targetHandle}) { + open(); + state = state.copyWith( + compose: () => InboxComposeRequest(targetHandle: targetHandle), + composeSequence: state.composeSequence + 1, + ); + } + + void consumeCompose() => state = state.copyWith(compose: () => null); +} diff --git a/lib/src/features/inbox/application/inbox_navigation.g.dart b/lib/src/features/inbox/application/inbox_navigation.g.dart new file mode 100644 index 000000000..3cddbbbf8 --- /dev/null +++ b/lib/src/features/inbox/application/inbox_navigation.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'inbox_navigation.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(InboxNavigation) +final inboxNavigationProvider = InboxNavigationProvider._(); + +final class InboxNavigationProvider + extends $NotifierProvider { + InboxNavigationProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'inboxNavigationProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$inboxNavigationHash(); + + @$internal + @override + InboxNavigation create() => InboxNavigation(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(InboxLocation value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$inboxNavigationHash() => r'3a80dd5cb66c2b547301abf4e3dd18b1367a1822'; + +abstract class _$InboxNavigation extends $Notifier { + InboxLocation build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + InboxLocation, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/lib/src/features/inbox/application/inbox_notifications.dart b/lib/src/features/inbox/application/inbox_notifications.dart new file mode 100644 index 000000000..1c8609dae --- /dev/null +++ b/lib/src/features/inbox/application/inbox_notifications.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:alera/src/app/providers.dart'; +import 'package:alera/src/features/agent_status/application/agent_status_notifications.dart'; +import 'package:alera/src/features/inbox/application/inbox_navigation.dart'; +import 'package:alera/src/features/inbox/application/inbox_providers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'inbox_notifications.g.dart'; + +const String _inboxNotificationKind = 'inboxReply'; + +// A single id, so a newer inbox notification replaces the previous one +// instead of stacking one per reply. +const int _inboxNotificationId = 0x494E4258; + +/// Decides when a growing unread count deserves a system notification. +class InboxReplyNotificationTracker { + int? _lastUnread; + + /// Returns how many replies are new, or 0. The first value seen is a + /// baseline: replies already waiting when the app starts are not news. + int observe(int unread, {required bool inboxVisible}) { + final previous = _lastUnread; + _lastUnread = unread; + if (previous == null || inboxVisible || unread <= previous) return 0; + return unread - previous; + } +} + +String encodeInboxNotificationPayload() => + jsonEncode({'kind': _inboxNotificationKind}); + +bool isInboxNotificationPayload(String payload) { + try { + final decoded = jsonDecode(payload); + return decoded is Map && decoded['kind'] == _inboxNotificationKind; + } on FormatException { + return false; + } +} + +AgentStatusNotification inboxReplyNotification( + int newReplies, +) => AgentStatusNotification( + id: _inboxNotificationId, + title: 'New Inbox Reply', + body: newReplies == 1 + ? 'An agent answered a question. Open the inbox to read it.' + : 'Agents answered $newReplies questions. Open the inbox to read them.', + payload: encodeInboxNotificationPayload(), +); + +/// Shows a system notification when replies arrive while the inbox page is +/// hidden. The body never contains the reply text. The shell watches it for +/// its whole life, so it does not need `keepAlive`, which would forbid +/// listening to the auto-disposed unread count. +@riverpod +void inboxReplyNotificationCoordinator(Ref ref) { + final presenter = ref.watch(agentStatusNotificationPresenterProvider); + final windowActivator = ref.watch( + agentStatusNotificationWindowActivatorProvider, + ); + final tracker = InboxReplyNotificationTracker(); + Future? initializing; + + Future ensureInitialized() => initializing ??= presenter.initialize( + onSelected: (payload) { + if (!isInboxNotificationPayload(payload)) return; + unawaited( + windowActivator.showAndFocus().then((_) { + if (ref.mounted) ref.read(inboxNavigationProvider.notifier).open(); + }), + ); + }, + ); + + ref.listen(inboxUnreadReplyCountProvider, (_, unread) { + if (unread == null) return; + final fresh = tracker.observe( + unread, + // The page being open is not enough: a hidden or unfocused window + // means nobody is reading it. + inboxVisible: + ref.read(inboxNavigationProvider).visible && + ref.read(inboxWindowFocusProvider).isForeground, + ); + // Replies are agent answers, so they follow the agent notifications toggle. + final enabled = ref + .read(settingsControllerProvider) + .agents + .agentStatusNotificationsEnabled; + if (fresh == 0 || !enabled) return; + unawaited( + ensureInitialized().then( + (_) => presenter.show(inboxReplyNotification(fresh)), + ), + ); + }, fireImmediately: true); +} diff --git a/lib/src/features/inbox/application/inbox_notifications.g.dart b/lib/src/features/inbox/application/inbox_notifications.g.dart new file mode 100644 index 000000000..83982feac --- /dev/null +++ b/lib/src/features/inbox/application/inbox_notifications.g.dart @@ -0,0 +1,67 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'inbox_notifications.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Shows a system notification when replies arrive while the inbox page is +/// hidden. The body never contains the reply text. The shell watches it for +/// its whole life, so it does not need `keepAlive`, which would forbid +/// listening to the auto-disposed unread count. + +@ProviderFor(inboxReplyNotificationCoordinator) +final inboxReplyNotificationCoordinatorProvider = + InboxReplyNotificationCoordinatorProvider._(); + +/// Shows a system notification when replies arrive while the inbox page is +/// hidden. The body never contains the reply text. The shell watches it for +/// its whole life, so it does not need `keepAlive`, which would forbid +/// listening to the auto-disposed unread count. + +final class InboxReplyNotificationCoordinatorProvider + extends $FunctionalProvider + with $Provider { + /// Shows a system notification when replies arrive while the inbox page is + /// hidden. The body never contains the reply text. The shell watches it for + /// its whole life, so it does not need `keepAlive`, which would forbid + /// listening to the auto-disposed unread count. + InboxReplyNotificationCoordinatorProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'inboxReplyNotificationCoordinatorProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => + _$inboxReplyNotificationCoordinatorHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + void create(Ref ref) { + return inboxReplyNotificationCoordinator(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$inboxReplyNotificationCoordinatorHash() => + r'4880ddbe38a2d795346ad8df086504cb3597e64d'; diff --git a/lib/src/features/inbox/application/inbox_providers.dart b/lib/src/features/inbox/application/inbox_providers.dart new file mode 100644 index 000000000..52cd55566 --- /dev/null +++ b/lib/src/features/inbox/application/inbox_providers.dart @@ -0,0 +1,54 @@ +import 'package:alera/src/features/app_window/domain/app_foreground.dart'; +import 'package:alera/src/features/app_window/infra/lifecycle_app_foreground.dart'; +import 'package:alera/src/features/inbox/domain/inbox_models.dart'; +import 'package:alera/src/features/inbox/infra/runtime_inbox_repository.dart'; +import 'package:alera/src/shared/infra/runtime/runtime_host_providers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'inbox_providers.g.dart'; + +@riverpod +RuntimeInboxRepository inboxRepository(Ref ref) => RuntimeInboxRepository( + ref.watch(runtimeHostClientProvider), + ref.watch(runtimeChangeCoalescerProvider), +); + +// Recovery is event-driven, including unsupported or disconnected hosts. +Duration? _noInboxRetry(int retryCount, Object error) => null; + +@Riverpod(retry: _noInboxRetry) +Stream inboxSummary(Ref ref) => + ref.watch(inboxRepositoryProvider).watchSummary(); + +@Riverpod(retry: _noInboxRetry) +Stream inboxThreads( + Ref ref, { + String? inbox, + InboxQuestionStatus? status, +}) => ref + .watch(inboxRepositoryProvider) + .watchThreads(InboxThreadQuery(inbox: inbox, status: status)); + +@Riverpod(retry: _noInboxRetry) +Stream inboxThreadDetail(Ref ref, String threadId) => + ref.watch(inboxRepositoryProvider).watchThread(threadId); + +@Riverpod(retry: _noInboxRetry) +Future> inboxTargets(Ref ref) => + ref.watch(inboxRepositoryProvider).readTargets(); + +/// Unread replies across every inbox, or null when the host cannot answer. +@riverpod +int? inboxUnreadReplyCount(Ref ref) { + final summary = ref.watch(inboxSummaryProvider); + return summary.hasError ? null : summary.value?.unreadReplyCount; +} + +/// Whether the user can see and act on the app window: visible and focused. +/// Reading a reply and suppressing its notification both depend on it. +@Riverpod(keepAlive: true) +AppForeground inboxWindowFocus(Ref ref) { + final focus = LifecycleAppForeground(requireFocus: true); + ref.onDispose(focus.dispose); + return focus; +} diff --git a/lib/src/features/inbox/application/inbox_providers.g.dart b/lib/src/features/inbox/application/inbox_providers.g.dart new file mode 100644 index 000000000..8139c3217 --- /dev/null +++ b/lib/src/features/inbox/application/inbox_providers.g.dart @@ -0,0 +1,393 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'inbox_providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(inboxRepository) +final inboxRepositoryProvider = InboxRepositoryProvider._(); + +final class InboxRepositoryProvider + extends + $FunctionalProvider< + RuntimeInboxRepository, + RuntimeInboxRepository, + RuntimeInboxRepository + > + with $Provider { + InboxRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'inboxRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$inboxRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + RuntimeInboxRepository create(Ref ref) { + return inboxRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(RuntimeInboxRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$inboxRepositoryHash() => r'30fc85752a901adf8da06d556bab1ef40fc17c42'; + +@ProviderFor(inboxSummary) +final inboxSummaryProvider = InboxSummaryProvider._(); + +final class InboxSummaryProvider + extends + $FunctionalProvider< + AsyncValue, + InboxSummary, + Stream + > + with $FutureModifier, $StreamProvider { + InboxSummaryProvider._() + : super( + from: null, + argument: null, + retry: _noInboxRetry, + name: r'inboxSummaryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$inboxSummaryHash(); + + @$internal + @override + $StreamProviderElement $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream create(Ref ref) { + return inboxSummary(ref); + } +} + +String _$inboxSummaryHash() => r'dcf28a9318cfc51e1ea030d197f590ef547697f0'; + +@ProviderFor(inboxThreads) +final inboxThreadsProvider = InboxThreadsFamily._(); + +final class InboxThreadsProvider + extends + $FunctionalProvider< + AsyncValue, + InboxThreadPage, + Stream + > + with $FutureModifier, $StreamProvider { + InboxThreadsProvider._({ + required InboxThreadsFamily super.from, + required ({String? inbox, InboxQuestionStatus? status}) super.argument, + }) : super( + retry: _noInboxRetry, + name: r'inboxThreadsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$inboxThreadsHash(); + + @override + String toString() { + return r'inboxThreadsProvider' + '' + '$argument'; + } + + @$internal + @override + $StreamProviderElement $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream create(Ref ref) { + final argument = + this.argument as ({String? inbox, InboxQuestionStatus? status}); + return inboxThreads(ref, inbox: argument.inbox, status: argument.status); + } + + @override + bool operator ==(Object other) { + return other is InboxThreadsProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$inboxThreadsHash() => r'9af8f042ccb739c3ad0f921f0a3dd466a30bb721'; + +final class InboxThreadsFamily extends $Family + with + $FunctionalFamilyOverride< + Stream, + ({String? inbox, InboxQuestionStatus? status}) + > { + InboxThreadsFamily._() + : super( + retry: _noInboxRetry, + name: r'inboxThreadsProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + InboxThreadsProvider call({String? inbox, InboxQuestionStatus? status}) => + InboxThreadsProvider._( + argument: (inbox: inbox, status: status), + from: this, + ); + + @override + String toString() => r'inboxThreadsProvider'; +} + +@ProviderFor(inboxThreadDetail) +final inboxThreadDetailProvider = InboxThreadDetailFamily._(); + +final class InboxThreadDetailProvider + extends + $FunctionalProvider< + AsyncValue, + InboxThreadDetail, + Stream + > + with + $FutureModifier, + $StreamProvider { + InboxThreadDetailProvider._({ + required InboxThreadDetailFamily super.from, + required String super.argument, + }) : super( + retry: _noInboxRetry, + name: r'inboxThreadDetailProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$inboxThreadDetailHash(); + + @override + String toString() { + return r'inboxThreadDetailProvider' + '' + '($argument)'; + } + + @$internal + @override + $StreamProviderElement $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream create(Ref ref) { + final argument = this.argument as String; + return inboxThreadDetail(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is InboxThreadDetailProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$inboxThreadDetailHash() => r'578be79b2ac1fa7725098c26a3c3d34fe75d1433'; + +final class InboxThreadDetailFamily extends $Family + with $FunctionalFamilyOverride, String> { + InboxThreadDetailFamily._() + : super( + retry: _noInboxRetry, + name: r'inboxThreadDetailProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + InboxThreadDetailProvider call(String threadId) => + InboxThreadDetailProvider._(argument: threadId, from: this); + + @override + String toString() => r'inboxThreadDetailProvider'; +} + +@ProviderFor(inboxTargets) +final inboxTargetsProvider = InboxTargetsProvider._(); + +final class InboxTargetsProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + InboxTargetsProvider._() + : super( + from: null, + argument: null, + retry: _noInboxRetry, + name: r'inboxTargetsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$inboxTargetsHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return inboxTargets(ref); + } +} + +String _$inboxTargetsHash() => r'e80f269782defa5b6311965ab6ce77dcba308fc2'; + +/// Unread replies across every inbox, or null when the host cannot answer. + +@ProviderFor(inboxUnreadReplyCount) +final inboxUnreadReplyCountProvider = InboxUnreadReplyCountProvider._(); + +/// Unread replies across every inbox, or null when the host cannot answer. + +final class InboxUnreadReplyCountProvider + extends $FunctionalProvider + with $Provider { + /// Unread replies across every inbox, or null when the host cannot answer. + InboxUnreadReplyCountProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'inboxUnreadReplyCountProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$inboxUnreadReplyCountHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + int? create(Ref ref) { + return inboxUnreadReplyCount(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(int? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$inboxUnreadReplyCountHash() => + r'47a6c99c6e33fdb0098eb04dcbb7b588ab6bf568'; + +/// Whether the user can see and act on the app window: visible and focused. +/// Reading a reply and suppressing its notification both depend on it. + +@ProviderFor(inboxWindowFocus) +final inboxWindowFocusProvider = InboxWindowFocusProvider._(); + +/// Whether the user can see and act on the app window: visible and focused. +/// Reading a reply and suppressing its notification both depend on it. + +final class InboxWindowFocusProvider + extends $FunctionalProvider + with $Provider { + /// Whether the user can see and act on the app window: visible and focused. + /// Reading a reply and suppressing its notification both depend on it. + InboxWindowFocusProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'inboxWindowFocusProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$inboxWindowFocusHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + AppForeground create(Ref ref) { + return inboxWindowFocus(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AppForeground value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$inboxWindowFocusHash() => r'3516b717804000b78cbce74a4cc60161f86c36f6'; diff --git a/lib/src/features/inbox/domain/inbox_error_messages.dart b/lib/src/features/inbox/domain/inbox_error_messages.dart new file mode 100644 index 000000000..2847b4eb2 --- /dev/null +++ b/lib/src/features/inbox/domain/inbox_error_messages.dart @@ -0,0 +1,38 @@ +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_client_models.dart'; +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart'; + +class InboxUpdateRequired implements Exception { + const InboxUpdateRequired(); + + @override + String toString() => 'Update the runtime to use the inbox.'; +} + +/// Sentence-case text for an inbox failure, keyed by the host's error code. +String inboxErrorMessage(Object error) { + if (error is InboxUpdateRequired) return error.toString(); + if (error is TerminalHostConflictException) { + return switch (error.code) { + 'inbox_pending_limit' => 'This agent already has 20 questions waiting. Wait for it to read them or cancel some.', + 'inbox_ambiguous_recipient' => + 'More than one agent matches. Choose a specific terminal.', + 'inbox_no_recipient' => 'No running agent matches.', + 'inbox_unknown_recipient' => + 'That terminal is no longer known to the runtime.', + 'inbox_thread_mismatch' => + 'A follow-up must go to the same agent and inbox as the question.', + 'inbox_not_cancellable' => + 'The agent already received this question, so it cannot be cancelled.', + 'inbox_question_not_found' || + 'inbox_thread_not_found' => 'This question no longer exists.', + 'inbox_invalid_address' => 'Use an inbox address like ext:user, with lowercase letters, digits, dots, underscores or hyphens.', + 'inbox_invalid_expiry' => 'Choose an expiry between 1 minute and 7 days.', + 'message_too_large' => 'The question is too long.', + _ => error.message, + }; + } + if (error is TerminalHostConnectionClosedException) { + return 'The Alera runtime is not reachable.'; + } + return error.toString(); +} diff --git a/lib/src/features/inbox/domain/inbox_models.dart b/lib/src/features/inbox/domain/inbox_models.dart new file mode 100644 index 000000000..8acfaada1 --- /dev/null +++ b/lib/src/features/inbox/domain/inbox_models.dart @@ -0,0 +1,301 @@ +/// Read models for the inbox: questions sent to agents from an `ext:` +/// address (the CLI, this app, a paired phone) and the replies they got. +library; + +/// Inbox used by the desktop and mobile apps, so both show one conversation. +const String inboxUserAddress = 'ext:user'; + +enum InboxQuestionStatus { + pending('pending', 'Pending'), + received('received', 'Received'), + delivered('delivered', 'Delivered'), + expired('expired', 'Expired'), + answered('answered', 'Answered'), + cancelled('cancelled', 'Cancelled'); + + InboxQuestionStatus(this.key, this.label); + + final String key; + final String label; + + static InboxQuestionStatus parse(Object? value) => values.firstWhere( + (status) => status.key == value, + orElse: () => InboxQuestionStatus.pending, + ); + + /// Still waiting for the agent to read or answer. + bool get open => this == pending || this == received || this == delivered; +} + +enum InboxMessageKind { + question, + reply, + message; + + static InboxMessageKind parse(Object? value) => values.firstWhere( + (kind) => kind.name == value, + orElse: () => InboxMessageKind.message, + ); +} + +/// How a question reaches the agent right now. +enum InboxDeliveryMode { + /// Pasted when the agent finishes its turn. + paste, + + /// The agent coordinates a run and reads questions with `check`. + check, + + /// No running agent reports its turns, so nothing will be pasted. + unavailable; + + static InboxDeliveryMode parse(Object? value) => values.firstWhere( + (mode) => mode.name == value, + orElse: () => InboxDeliveryMode.unavailable, + ); +} + +String _string(Object? value) => value is String ? value : ''; +String? _optionalString(Object? value) => + value is String && value.trim().isNotEmpty ? value : null; +int _int(Object? value) => value is num ? value.toInt() : 0; +Map _map(Object? value) => + value is Map ? Map.from(value) : const {}; +List> _maps(Object? value) => value is List + ? List>.unmodifiable( + value.whereType>().map(Map.from), + ) + : const >[]; + +/// Host timestamps are UTC `YYYY-MM-DD HH:MM:SS` (SQLite) or RFC 3339. +DateTime? parseInboxTimestamp(Object? value) { + final raw = _optionalString(value); + if (raw == null) return null; + final normalized = raw.contains('T') ? raw : '${raw.replaceFirst(' ', 'T')}Z'; + return DateTime.tryParse(normalized)?.toUtc(); +} + +/// Where a question was asked from, recorded by the host from the connection. +class const InboxOrigin({ + required final String surface, + final String? deviceName, +}) { + factory InboxOrigin.fromJson(Map json) => InboxOrigin( + surface: _optionalString(json['surface']) ?? 'cli', + deviceName: _optionalString(json['deviceName']), + ); + + String get label => switch (surface) { + 'desktop' => 'Alera desktop', + 'mobile' => + deviceName == null ? 'Alera mobile' : 'Alera mobile, $deviceName', + _ => 'CLI', + }; +} + +/// How the recipient looked when the question was asked. +class const InboxTargetSnapshot({ + final String? agent, + final String? tabTitle, + final String? workspaceName, +}) { + factory InboxTargetSnapshot.fromJson(Map json) => + InboxTargetSnapshot( + agent: _optionalString(json['agent']), + tabTitle: _optionalString(json['tabTitle']), + workspaceName: _optionalString(json['workspaceName']), + ); +} + +class const InboxSummaryEntry({ + required final String inbox, + required final int threadCount, + required final int pendingCount, + required final int awaitingReplyCount, + required final int unreadReplyCount, + final DateTime? lastActivityAt, +}) { + factory InboxSummaryEntry.fromJson(Map json) => + InboxSummaryEntry( + inbox: _string(json['inbox']), + threadCount: _int(json['threadCount']), + pendingCount: _int(json['pendingCount']), + awaitingReplyCount: _int(json['awaitingReplyCount']), + unreadReplyCount: _int(json['unreadReplyCount']), + lastActivityAt: parseInboxTimestamp(json['lastActivityAt']), + ); +} + +class const InboxSummary({ + required final List inboxes, + required final int revision, +}) { + factory InboxSummary.fromJson(Map json) => InboxSummary( + inboxes: List.unmodifiable( + _maps(json['items']).map(InboxSummaryEntry.fromJson), + ), + revision: _int(json['revision']), + ); + + int get unreadReplyCount => + inboxes.fold(0, (total, entry) => total + entry.unreadReplyCount); +} + +class const InboxThread({ + required final String threadId, + required final String inbox, + required final String recipient, + final String? workspaceId, + required final String subject, + final DateTime? createdAt, + final DateTime? lastActivityAt, + required final int lastSequence, + required final InboxQuestionStatus status, + required final int questionCount, + required final int replyCount, + required final int unreadReplyCount, + final InboxOrigin? origin, + final InboxTargetSnapshot target = const InboxTargetSnapshot(), +}) { + factory InboxThread.fromJson(Map json) => InboxThread( + threadId: _string(json['threadId']), + inbox: _string(json['inbox']), + recipient: _string(json['recipient']), + workspaceId: _optionalString(json['workspaceId']), + subject: _string(json['subject']), + createdAt: parseInboxTimestamp(json['createdAt']), + lastActivityAt: parseInboxTimestamp(json['lastActivityAt']), + lastSequence: _int(json['lastSequence']), + status: InboxQuestionStatus.parse(json['status']), + questionCount: _int(json['questionCount']), + replyCount: _int(json['replyCount']), + unreadReplyCount: _int(json['unreadReplyCount']), + origin: json['origin'] is Map + ? InboxOrigin.fromJson(_map(json['origin'])) + : null, + target: InboxTargetSnapshot.fromJson(_map(json['target'])), + ); +} + +class const InboxThreadPage({ + required final List items, + final int? nextBefore, + required final int revision, +}) { + factory InboxThreadPage.fromJson(Map json) => + InboxThreadPage( + items: List.unmodifiable( + _maps(json['items']).map(InboxThread.fromJson), + ), + nextBefore: json['nextBefore'] is num + ? (json['nextBefore']! as num).toInt() + : null, + revision: _int(json['revision']), + ); +} + +/// One row of the thread: an orchestration message seen from the inbox. +class const InboxMessage({ + required final String id, + required final InboxMessageKind kind, + final InboxQuestionStatus? status, + required final String from, + required final String to, + required final String subject, + required final String body, + required final bool read, + required final int sequence, + final DateTime? createdAt, + final DateTime? deliveredAt, + final DateTime? expiresAt, + final String? replyToId, +}) { + factory InboxMessage.fromJson(Map json) { + final message = _map(json['message']); + final status = json['status']; + return InboxMessage( + id: _string(message['id']), + kind: InboxMessageKind.parse(json['kind']), + status: status == null ? null : InboxQuestionStatus.parse(status), + from: _string(message['from_handle']), + to: _string(message['to_handle']), + subject: _string(message['subject']), + body: _string(message['body']), + read: message['read'] == true, + sequence: _int(message['sequence']), + createdAt: parseInboxTimestamp(message['created_at']), + deliveredAt: parseInboxTimestamp(message['delivered_at']), + expiresAt: parseInboxTimestamp(message['expires_at']), + replyToId: _optionalString(message['reply_to_id']), + ); + } +} + +/// A terminal that can be asked, as the host sees it now. +class const InboxRecipient({ + required final String handle, + required final bool sessionLive, + final String? workspaceId, + final String? tabId, + final String? agent, + final String? presence, + required final InboxDeliveryMode deliveryMode, + final String? tabTitle, +}) { + factory InboxRecipient.fromJson(Map json) => InboxRecipient( + handle: _string(json['handle']), + sessionLive: json['sessionLive'] == true, + workspaceId: _optionalString(json['workspaceId']), + tabId: _optionalString(json['tabId']), + agent: _optionalString(json['agent']), + presence: _optionalString(json['presence']), + deliveryMode: InboxDeliveryMode.parse(json['deliveryMode']), + tabTitle: _optionalString(json['tabTitle']), + ); +} + +class const InboxThreadDetail({ + required final InboxThread thread, + required final List messages, + required final InboxRecipient recipient, + required final int revision, +}) { + factory InboxThreadDetail.fromJson(Map json) => + InboxThreadDetail( + thread: InboxThread.fromJson(_map(json['thread'])), + messages: List.unmodifiable( + _maps(json['messages']).map(InboxMessage.fromJson), + ), + recipient: InboxRecipient.fromJson(_map(json['recipient'])), + revision: _int(json['revision']), + ); + + /// The question a follow-up or a cancellation acts on. + InboxMessage? get latestQuestion => messages + .where((message) => message.kind == InboxMessageKind.question) + .lastOrNull; +} + +List parseInboxTargets(Map json) => + List.unmodifiable( + _maps(json['items']).map(InboxRecipient.fromJson), + ); + +/// A new question, or a follow-up when [threadId] is set. +class const InboxAskRequest({ + required final String body, + final String? inbox, + final String? to, + final String? threadId, + final String? subject, + final Duration? expiresIn, +}) { + Map toJson() => { + 'body': body, + 'inbox': ?inbox, + 'to': ?to, + 'threadId': ?threadId, + if (subject != null && subject!.trim().isNotEmpty) 'subject': subject, + if (expiresIn != null) 'expiresInMs': expiresIn!.inMilliseconds, + }; +} diff --git a/lib/src/features/inbox/infra/inbox_watch.dart b/lib/src/features/inbox/infra/inbox_watch.dart new file mode 100644 index 000000000..891d49ddf --- /dev/null +++ b/lib/src/features/inbox/infra/inbox_watch.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_client_models.dart'; +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart'; +import 'package:alera/src/shared/infra/runtime/runtime_change_coalescer.dart'; + +const String inboxChangedEvent = 'inboxChanged'; + +/// Re-reads on every inbox revision and on host reconnects. Errors reach the +/// UI; recovery is event-driven, so an old host never causes a retry loop. +Stream watchInbox({ + required RuntimeHostClient client, + required RuntimeChangeCoalescer coalescer, + required String key, + required Future Function() read, +}) { + final owner = Object(); + late final StreamController controller; + StreamSubscription? subscription; + var disposed = false; + var connectionGeneration = 0; + + Future refresh() async { + if (disposed) return; + final generation = connectionGeneration; + try { + final value = await read(); + if (!disposed && generation == connectionGeneration) { + controller.add(value); + } + } on Object catch (error, stack) { + if (!disposed && generation == connectionGeneration) { + controller.addError(error, stack); + } + } + } + + void schedule() => coalescer.schedule(key, owner, refresh); + + controller = StreamController( + onListen: () { + subscription = client.runtimeEvents.listen( + (event) { + if (event.name == aleraRuntimeHostDisconnectedEvent) { + connectionGeneration++; + controller.addError(const TerminalHostConnectionClosedException()); + return; + } + if (event.name == inboxChangedEvent || + event.name == aleraRuntimeHostConnectedEvent) { + schedule(); + } + }, + onError: (Object error, StackTrace stack) { + if (!disposed) controller.addError(error, stack); + }, + ); + schedule(); + unawaited(coalescer.flush(key)); + }, + onCancel: () async { + disposed = true; + coalescer.cancel(key, owner); + await subscription?.cancel(); + }, + ); + return controller.stream; +} diff --git a/lib/src/features/inbox/infra/runtime_inbox_repository.dart b/lib/src/features/inbox/infra/runtime_inbox_repository.dart new file mode 100644 index 000000000..65e2f8060 --- /dev/null +++ b/lib/src/features/inbox/infra/runtime_inbox_repository.dart @@ -0,0 +1,114 @@ +import 'package:alera/src/features/inbox/domain/inbox_error_messages.dart'; +import 'package:alera/src/features/inbox/domain/inbox_models.dart'; +import 'package:alera/src/features/inbox/infra/inbox_watch.dart'; +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart'; +import 'package:alera/src/shared/infra/runtime/runtime_change_coalescer.dart'; + +/// Filters of the thread list. A null [inbox] lists every inbox. +class const InboxThreadQuery({ + final String? inbox, + final InboxQuestionStatus? status, + final int limit = 100, + + /// Continues a listing from the previous page's `nextBefore`. + final int? before, +}) { + Map toJson() => { + 'inbox': ?inbox, + 'status': ?status?.key, + 'limit': limit, + 'before': ?before, + }; + + String get cacheKey => '${inbox ?? '*'}:${status?.key ?? '*'}:$limit'; +} + +/// The desktop is a local runtime client, so every `inbox.*` verb is allowed. +class RuntimeInboxRepository { + RuntimeInboxRepository(this._client, this._coalescer); + + final RuntimeHostClient _client; + final RuntimeChangeCoalescer _coalescer; + + Future supported() async { + final client = _client; + return client is RuntimeHostCapabilityClient && + await (client as RuntimeHostCapabilityClient).supportsRuntimeCapability( + aleraRuntimeHostInboxCapability, + ); + } + + Future> _request( + String type, [ + Map payload = const {}, + ]) async { + if (!await supported()) throw const InboxUpdateRequired(); + final response = await _client.runtimeRequest(type, payload); + return response is Map + ? Map.from(response) + : const {}; + } + + Future readSummary() async => + InboxSummary.fromJson(await _request('inbox.summary')); + + Future readThreads([ + InboxThreadQuery query = const InboxThreadQuery(), + ]) async => + InboxThreadPage.fromJson(await _request('inbox.threads', query.toJson())); + + Future readThread( + String threadId, { + bool markRead = false, + }) async => InboxThreadDetail.fromJson( + await _request('inbox.thread', { + 'threadId': threadId, + 'markRead': markRead, + }), + ); + + Future> readTargets() async => + parseInboxTargets(await _request('inbox.targets')); + + /// Returns the new question's thread id. + Future ask(InboxAskRequest request) async { + final response = await _request('inbox.ask', request.toJson()); + return response['threadId'] as String? ?? ''; + } + + Future cancel(String questionId) => + _request('inbox.cancel', {'questionId': questionId}); + + Future markRead(String threadId) => + _request('inbox.markRead', {'threadId': threadId}); + + Future purge(String inbox) async { + final response = await _request('inbox.purge', { + 'inbox': inbox, + }); + return (response['deleted'] as num?)?.toInt() ?? 0; + } + + Stream watchSummary() => watchInbox( + client: _client, + coalescer: _coalescer, + key: 'inbox-summary', + read: readSummary, + ); + + Stream watchThreads(InboxThreadQuery query) => watchInbox( + client: _client, + coalescer: _coalescer, + key: 'inbox-threads:${query.cacheKey}', + read: () => readThreads(query), + ); + + /// Loading never acknowledges: a refresh can arrive while the window is + /// hidden. The detail view marks replies read once the user can see them. + Stream watchThread(String threadId) => watchInbox( + client: _client, + coalescer: _coalescer, + key: 'inbox-thread:$threadId', + read: () => readThread(threadId), + ); +} diff --git a/lib/src/features/inbox/presentation/inbox_attention_control.dart b/lib/src/features/inbox/presentation/inbox_attention_control.dart new file mode 100644 index 000000000..83c31ecff --- /dev/null +++ b/lib/src/features/inbox/presentation/inbox_attention_control.dart @@ -0,0 +1,51 @@ +import 'package:alera/src/app/theme/alera_tokens.dart'; +import 'package:alera/src/design_system/icons/alera_icons.dart'; +import 'package:alera/src/features/inbox/application/inbox_navigation.dart'; +import 'package:alera/src/features/inbox/application/inbox_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Status bar entry to the inbox with the count of unread replies. Hidden +/// when the runtime cannot answer inbox requests. +class InboxAttentionControl extends ConsumerWidget { + const InboxAttentionControl({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final count = ref.watch(inboxUnreadReplyCountProvider); + if (count == null) return const SizedBox.shrink(); + final label = count == 0 + ? 'Open Inbox' + : 'Open Inbox · $count Unread ${count == 1 ? 'Reply' : 'Replies'}'; + return Tooltip( + message: label, + child: TextButton( + onPressed: () => ref.read(inboxNavigationProvider.notifier).open(), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: AleraTokens.space8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Semantics( + label: label, + excludeSemantics: true, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(AleraIcons.inbox, size: AleraTokens.iconSm), + const SizedBox(width: AleraTokens.space4), + Text( + count > 999 ? '999+' : '$count', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: count > 0 + ? AleraTokens.accent + : AleraTokens.foregroundMuted, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/features/inbox/presentation/inbox_composer_dialog.dart b/lib/src/features/inbox/presentation/inbox_composer_dialog.dart new file mode 100644 index 000000000..31eccf431 --- /dev/null +++ b/lib/src/features/inbox/presentation/inbox_composer_dialog.dart @@ -0,0 +1,243 @@ +import 'dart:async'; + +import 'package:alera/src/app/theme/alera_tokens.dart'; +import 'package:alera/src/design_system/feedback/alera_inline_notice.dart'; +import 'package:alera/src/design_system/forms/alera_dropdown_field.dart'; +import 'package:alera/src/design_system/icons/alera_icons.dart'; +import 'package:alera/src/design_system/forms/alera_text_field.dart'; +import 'package:alera/src/design_system/layout/alera_dialog.dart'; +import 'package:alera/src/design_system/layout/alera_dialog_header.dart'; +import 'package:alera/src/features/inbox/application/inbox_navigation.dart'; +import 'package:alera/src/features/inbox/application/inbox_providers.dart'; +import 'package:alera/src/features/inbox/domain/inbox_error_messages.dart'; +import 'package:alera/src/features/inbox/domain/inbox_models.dart'; +import 'package:alera/src/features/inbox/presentation/inbox_labels.dart'; +import 'package:alera/src/features/workbench/application/workbench_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +const List inboxExpiryChoices = [ + Duration(hours: 1), + Duration(hours: 5), + Duration(days: 1), + Duration(days: 7), +]; + +/// Defaults to the runtime's own default so an unchanged choice sends nothing. +const Duration inboxDefaultExpiry = Duration(hours: 5); + +String _expiryLabel(Duration value) => value.inDays > 0 + ? '${value.inDays} Day${value.inDays == 1 ? '' : 's'}' + : '${value.inHours} Hour${value.inHours == 1 ? '' : 's'}'; + +Future showInboxComposerDialog( + BuildContext context, { + String? targetHandle, +}) => showDialog( + context: context, + builder: (_) => InboxComposerDialog(initialTarget: targetHandle), +); + +/// Asks a running agent a new question from the shared `ext:user` inbox. +class InboxComposerDialog extends ConsumerStatefulWidget { + const InboxComposerDialog({super.key, this.initialTarget}); + + final String? initialTarget; + + @override + ConsumerState createState() => + _InboxComposerDialogState(); +} + +class _InboxComposerDialogState extends ConsumerState { + final _inbox = TextEditingController(text: inboxUserAddress); + final _subject = TextEditingController(); + final _body = TextEditingController(); + String? _target; + Duration _expiry = inboxDefaultExpiry; + bool _sending = false; + String? _error; + + @override + void initState() { + super.initState(); + _target = widget.initialTarget; + } + + @override + void dispose() { + _inbox.dispose(); + _subject.dispose(); + _body.dispose(); + super.dispose(); + } + + bool get _canSend => + !_sending && _target != null && _body.text.trim().isNotEmpty; + + Future _send() async { + if (!_canSend) return; + setState(() { + _sending = true; + _error = null; + }); + try { + final threadId = await ref + .read(inboxRepositoryProvider) + .ask( + InboxAskRequest( + body: _body.text.trim(), + inbox: _inbox.text.trim().isEmpty ? null : _inbox.text.trim(), + to: _target, + subject: _subject.text, + expiresIn: _expiry == inboxDefaultExpiry ? null : _expiry, + ), + ); + if (!mounted) return; + final navigation = ref.read(inboxNavigationProvider.notifier); + navigation.filterInbox(null); + navigation.selectThread(threadId.isEmpty ? null : threadId); + Navigator.of(context).pop(); + } on Object catch (error) { + if (!mounted) return; + setState(() { + _sending = false; + _error = inboxErrorMessage(error); + }); + } + } + + @override + Widget build(BuildContext context) { + final targets = ref.watch(inboxTargetsProvider); + final workspaceNames = ref.watch( + workbenchControllerProvider.select( + (state) => { + for (final workspaces in state.workspacesByProject.values) + for (final workspace in workspaces) workspace.id: workspace.name, + }, + ), + ); + final recipients = targets.value ?? const []; + final sorted = [...recipients] + ..sort( + (a, b) => _targetLabel( + a, + workspaceNames, + ).compareTo(_targetLabel(b, workspaceNames)), + ); + return AleraDialog( + maxWidth: AleraTokens.dialogWideWidth, + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + AleraDialogHeader( + title: 'Ask Agent', + onClose: () => Navigator.of(context).pop(), + ), + Padding( + padding: const EdgeInsets.all(AleraTokens.space16), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + spacing: AleraTokens.space12, + children: [ + if (targets.hasError) + AleraInlineNotice( + tone: .warning, + message: inboxErrorMessage(targets.error!), + ) + else if (targets.hasValue && recipients.isEmpty) + const AleraInlineNotice( + message: 'No running agent reports its turns right now. Start an agent in a workspace to ask it.', + ), + AleraDropdownField( + key: const ValueKey('inboxComposerTarget'), + labelText: 'Agent', + hintText: targets.isLoading + ? 'Loading agents...' + : 'Choose an agent', + value: recipients.any((item) => item.handle == _target) + ? _target + : null, + filterable: sorted.length > 6, + entries: >[ + for (final recipient in sorted) + AleraDropdownFieldEntry( + value: recipient.handle, + label: _targetLabel(recipient, workspaceNames), + ), + ], + onChanged: (handle) => setState(() => _target = handle), + ), + AleraTextField( + key: const ValueKey('inboxComposerBody'), + controller: _body, + labelText: 'Question', + hintText: 'What do you want to ask?', + minLines: 4, + maxLines: 10, + autofocus: true, + onChanged: (_) => setState(() {}), + onCommandEnter: () => unawaited(_send()), + ), + AleraTextField( + controller: _subject, + labelText: 'Subject', + hintText: 'Defaults to the first line of the question', + ), + Row( + spacing: AleraTokens.space12, + children: [ + Expanded( + child: AleraTextField( + controller: _inbox, + labelText: 'Inbox', + ), + ), + Expanded( + child: AleraDropdownField( + labelText: 'Expires If Not Delivered', + value: _expiry, + entries: >[ + for (final choice in inboxExpiryChoices) + AleraDropdownFieldEntry( + value: choice, + label: _expiryLabel(choice), + ), + ], + onChanged: (value) => setState(() => _expiry = value), + ), + ), + ], + ), + if (_error != null) + AleraInlineNotice(tone: .error, message: _error!), + Align( + alignment: AlignmentDirectional.centerEnd, + child: FilledButton.icon( + key: const ValueKey('inboxComposerSend'), + onPressed: _canSend ? () => unawaited(_send()) : null, + icon: const Icon(AleraIcons.send, size: AleraTokens.iconMd), + label: Text(_sending ? 'Asking...' : 'Ask'), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +String _targetLabel(InboxRecipient recipient, Map workspaces) { + final workspace = + workspaces[recipient.workspaceId] ?? recipient.workspaceId ?? 'Unknown'; + final title = recipient.tabTitle?.trim(); + final agent = inboxAgentLabel(recipient.agent); + return title == null || title.isEmpty + ? '$workspace / $agent' + : '$workspace / $agent: $title'; +} diff --git a/lib/src/features/inbox/presentation/inbox_labels.dart b/lib/src/features/inbox/presentation/inbox_labels.dart new file mode 100644 index 000000000..dd731b1bc --- /dev/null +++ b/lib/src/features/inbox/presentation/inbox_labels.dart @@ -0,0 +1,51 @@ +import 'package:alera/src/design_system/badges/alera_badge.dart'; +import 'package:alera/src/features/inbox/domain/inbox_models.dart'; +import 'package:alera/src/features/pull_requests/domain/comment_relative_time.dart'; + +AleraBadgeTone inboxStatusTone(InboxQuestionStatus status) => switch (status) { + .pending => AleraBadgeTone.neutral, + .received || .delivered => AleraBadgeTone.info, + .answered => AleraBadgeTone.success, + .expired => AleraBadgeTone.attention, + .cancelled => AleraBadgeTone.done, +}; + +/// Explains a delivery mode that will not paste the question by itself. +String? inboxDeliveryHint(InboxDeliveryMode mode) => switch (mode) { + .paste => null, + .check => 'This agent coordinates a run and reads questions when it checks its messages.', + .unavailable => 'This agent does not report its turns, so the question waits until it does or expires.', +}; + +String inboxTimeLabel(DateTime? value, DateTime now) { + if (value == null) return ''; + final local = value.toLocal(); + return commentRelativeTimeLabel(local, now) ?? + '${local.year}-${_two(local.month)}-${_two(local.day)}'; +} + +/// How long a pending question can still wait for the agent. +String? inboxExpiryLabel(DateTime? expiresAt, DateTime now) { + if (expiresAt == null) return null; + final left = expiresAt.difference(now); + if (left <= Duration.zero) return 'Expires now'; + if (left < const Duration(hours: 1)) return 'Expires in ${left.inMinutes}m'; + if (left < const Duration(days: 1)) return 'Expires in ${left.inHours}h'; + return 'Expires in ${left.inDays}d'; +} + +String inboxAgentLabel(String? agent) => switch (agent) { + null || '' => 'Agent', + 'codex' => 'Codex', + 'claude' => 'Claude', + 'copilot' => 'GitHub Copilot', + 'cursor' => 'Cursor', + 'agy' => 'Antigravity', + 'opencode' || 'opencode2' => 'OpenCode', + 'amp' => 'Amp', + 'grok' => 'Grok Build', + 'pi' => 'Pi', + final other => other, +}; + +String _two(int value) => value.toString().padLeft(2, '0'); diff --git a/lib/src/features/inbox/presentation/inbox_page.dart b/lib/src/features/inbox/presentation/inbox_page.dart new file mode 100644 index 000000000..ff1c734b1 --- /dev/null +++ b/lib/src/features/inbox/presentation/inbox_page.dart @@ -0,0 +1,201 @@ +import 'dart:async'; + +import 'package:alera/src/app/theme/alera_tokens.dart'; +import 'package:alera/src/design_system/feedback/alera_empty_state.dart'; +import 'package:alera/src/design_system/feedback/alera_inline_notice.dart'; +import 'package:alera/src/design_system/icons/alera_icons.dart'; +import 'package:alera/src/design_system/layout/alera_confirm_dialog.dart'; +import 'package:alera/src/design_system/layout/alera_master_detail.dart'; +import 'package:alera/src/features/app_menu/presentation/alera_app_menu_scope.dart'; +import 'package:alera/src/features/inbox/application/inbox_navigation.dart'; +import 'package:alera/src/features/inbox/application/inbox_providers.dart'; +import 'package:alera/src/features/inbox/domain/inbox_error_messages.dart'; +import 'package:alera/src/features/inbox/presentation/inbox_composer_dialog.dart'; +import 'package:alera/src/features/inbox/presentation/inbox_thread_detail.dart'; +import 'package:alera/src/features/inbox/presentation/inbox_thread_list.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Questions asked to agents from outside their terminals, and the replies. +/// Opened like the Run Board: the workbench stays mounted underneath. +class InboxPage extends ConsumerStatefulWidget { + const InboxPage({super.key, this.onReturnToWorkspace}); + + final VoidCallback? onReturnToWorkspace; + + @override + ConsumerState createState() => _InboxPageState(); +} + +class _InboxPageState extends ConsumerState { + int _handledCompose = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _maybeCompose()); + } + + void _maybeCompose() { + if (!mounted) return; + final location = ref.read(inboxNavigationProvider); + final request = location.compose; + if (request == null || location.composeSequence == _handledCompose) return; + _handledCompose = location.composeSequence; + ref.read(inboxNavigationProvider.notifier).consumeCompose(); + unawaited( + showInboxComposerDialog(context, targetHandle: request.targetHandle), + ); + } + + Future _purge(String inbox) async { + final confirmed = await showDialog( + context: context, + builder: (_) => AleraConfirmDialog( + title: 'Purge Inbox?', + message: + 'Every question and reply of $inbox is deleted from this runtime, on every device.', + confirmLabel: 'Purge', + destructive: true, + ), + ); + if (confirmed != true || !mounted) return; + try { + await ref.read(inboxRepositoryProvider).purge(inbox); + ref.read(inboxNavigationProvider.notifier).filterInbox(null); + } on Object catch (error) { + if (!mounted) return; + ScaffoldMessenger.maybeOf(context) + ?.showSnackBar(SnackBar(content: Text(inboxErrorMessage(error)))); + } + } + + @override + Widget build(BuildContext context) { + ref.listen( + inboxNavigationProvider.select((value) => value.composeSequence), + (_, _) => + WidgetsBinding.instance.addPostFrameCallback((_) => _maybeCompose()), + ); + final location = ref.watch(inboxNavigationProvider); + final navigation = ref.read(inboxNavigationProvider.notifier); + final summary = ref.watch(inboxSummaryProvider); + final updateRequired = summary.error is InboxUpdateRequired; + final selected = location.selectedThreadId; + const master = InboxThreadListPane(); + final detail = selected == null + ? const AleraEmptyState( + icon: AleraIcons.inbox, + title: 'Select A Question', + message: 'Read replies, follow up with the same agent, or cancel a question it has not received yet.', + ) + : InboxThreadDetailView( + key: ValueKey(selected), + threadId: selected, + ); + final purgeTarget = location.inboxFilter; + return FocusTraversalGroup( + child: Padding( + padding: const EdgeInsets.all(AleraTokens.space12), + child: Column( + crossAxisAlignment: .stretch, + children: [ + Wrap( + spacing: AleraTokens.space12, + runSpacing: AleraTokens.space8, + crossAxisAlignment: .center, + children: [ + const AleraAppMenuButton(), + Text('Inbox', style: Theme.of(context).textTheme.titleLarge), + FilledButton.icon( + key: const ValueKey('inboxNewQuestion'), + onPressed: updateRequired + ? null + : () => unawaited(showInboxComposerDialog(context)), + icon: const Icon(AleraIcons.add, size: AleraTokens.iconMd), + label: const Text('New Question'), + ), + TextButton.icon( + onPressed: widget.onReturnToWorkspace ?? navigation.close, + icon: const Icon(AleraIcons.back), + label: const Text('Return To Workspace'), + ), + if (purgeTarget != null) + TextButton.icon( + key: const ValueKey('inboxPurge'), + onPressed: () => unawaited(_purge(purgeTarget)), + icon: const Icon(AleraIcons.delete), + label: const Text('Purge Inbox'), + ), + IconButton( + tooltip: 'Refresh Inbox', + icon: const Icon(AleraIcons.refresh), + onPressed: () { + ref.invalidate(inboxSummaryProvider); + ref.invalidate(inboxThreadsProvider); + if (selected != null) { + ref.invalidate(inboxThreadDetailProvider(selected)); + } + }, + ), + ], + ), + const SizedBox(height: AleraTokens.space8), + if (updateRequired) + const Expanded( + child: AleraEmptyState( + icon: AleraIcons.inbox, + title: 'Inbox Unavailable', + message: 'Update the runtime to use the inbox.', + ), + ) + else ...[ + Text( + 'Agents receive a question when they finish their turn and answer with alera orchestration reply. Undelivered questions expire after 5 hours by default; history is kept for 7 days.', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: AleraTokens.foregroundMuted), + ), + if (summary.hasError) ...[ + const SizedBox(height: AleraTokens.space8), + AleraInlineNotice( + tone: .warning, + message: inboxErrorMessage(summary.error!), + ), + ], + const SizedBox(height: AleraTokens.space8), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final scale = MediaQuery.textScalerOf(context).scale(1); + if (constraints.maxWidth < + AleraTokens.wideContentBreakpoint * scale) { + if (selected == null) return master; + return Column( + crossAxisAlignment: .start, + children: [ + TextButton.icon( + onPressed: () => navigation.selectThread(null), + icon: const Icon(AleraIcons.back), + label: const Text('Back To Questions'), + ), + Expanded(child: detail), + ], + ); + } + return AleraMasterDetail( + masterTitle: 'Questions', + masterWidth: AleraTokens.sidebarDefaultWidth, + masterMaxWidth: AleraTokens.masterDetailMaxWidth, + master: master, + detail: detail, + ); + }, + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/src/features/inbox/presentation/inbox_page_continuation.dart b/lib/src/features/inbox/presentation/inbox_page_continuation.dart new file mode 100644 index 000000000..349b0dfc7 --- /dev/null +++ b/lib/src/features/inbox/presentation/inbox_page_continuation.dart @@ -0,0 +1,83 @@ +import 'package:alera/src/app/theme/alera_tokens.dart'; +import 'package:flutter/material.dart'; + +/// Pages loaded after the first one. The first page comes from a watched +/// provider that refreshes on runtime events; any refresh or filter change +/// drops the extra pages, so a list never mixes stale and fresh rows. +class InboxPageContinuation { + InboxPageContinuation(this._keyOf); + + final String Function(T item) _keyOf; + final List _items = []; + Object? _firstPage; + String? _filter; + int? _cursor; + bool _continued = false; + int _generation = 0; + bool loading = false; + Object? error; + + /// Called on every build with the current filter and first page. + void sync(String filter, Object? firstPage) { + if (filter == _filter && identical(firstPage, _firstPage)) return; + _filter = filter; + _firstPage = firstPage; + _items.clear(); + _cursor = null; + _continued = false; + _generation++; + loading = false; + error = null; + } + + /// The first page followed by the extra pages, without duplicates. + List merge(List firstPage) { + final seen = {}; + return [ + for (final item in [...firstPage, ..._items]) + if (seen.add(_keyOf(item))) item, + ]; + } + + /// Where the next page starts, or null when there is nothing more. + int? cursorAfter(int? firstPageCursor) => + _continued ? _cursor : firstPageCursor; + + int get generation => _generation; + + /// Ignores a page that was requested before a refresh or filter change. + void append(int generation, List items, int? nextCursor) { + if (generation != _generation) return; + _items.addAll(items); + _cursor = nextCursor; + _continued = true; + } +} + +class const InboxLoadMoreButton({ + super.key, + required final bool loading, + required final VoidCallback onPressed, + final String? error, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(AleraTokens.space8), + child: Column( + spacing: AleraTokens.space4, + children: [ + if (error != null) + Text( + error!, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: AleraTokens.error), + ), + TextButton( + key: const ValueKey('inboxLoadMore'), + onPressed: loading ? null : onPressed, + child: Text(loading ? 'Loading...' : 'Load More'), + ), + ], + ), + ); +} diff --git a/lib/src/features/inbox/presentation/inbox_thread_detail.dart b/lib/src/features/inbox/presentation/inbox_thread_detail.dart new file mode 100644 index 000000000..85d7e0bb6 --- /dev/null +++ b/lib/src/features/inbox/presentation/inbox_thread_detail.dart @@ -0,0 +1,381 @@ +import 'dart:async'; + +import 'package:alera/src/app/theme/alera_tokens.dart'; +import 'package:alera/src/design_system/badges/alera_badge.dart'; +import 'package:alera/src/design_system/feedback/alera_empty_state.dart'; +import 'package:alera/src/design_system/feedback/alera_inline_notice.dart'; +import 'package:alera/src/design_system/forms/alera_text_field.dart'; +import 'package:alera/src/design_system/icons/alera_icons.dart'; +import 'package:alera/src/features/inbox/application/inbox_providers.dart'; +import 'package:alera/src/features/inbox/domain/inbox_error_messages.dart'; +import 'package:alera/src/features/inbox/domain/inbox_models.dart'; +import 'package:alera/src/features/inbox/presentation/inbox_labels.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// One conversation: every question and reply, plus a follow-up composer. +/// +/// Replies are marked read only while this view is shown and the window is +/// visible and focused, and again when focus returns. Marking read reaches +/// every device, so a refresh in a hidden window must not do it. +class InboxThreadDetailView extends ConsumerStatefulWidget { + const InboxThreadDetailView({super.key, required this.threadId, this.now}); + + final String threadId; + + /// Fixed clock for tests; defaults to the wall clock. + final DateTime? now; + + @override + ConsumerState createState() => + _InboxThreadDetailViewState(); +} + +class _InboxThreadDetailViewState extends ConsumerState { + StreamSubscription? _focusChanges; + int? _acknowledgedRevision; + + @override + void initState() { + super.initState(); + _focusChanges = ref + .read(inboxWindowFocusProvider) + .changes + .listen((focused) => focused ? _acknowledge() : null); + ref.listenManual( + inboxThreadDetailProvider(widget.threadId), + (_, _) => + WidgetsBinding.instance.addPostFrameCallback((_) => _acknowledge()), + fireImmediately: true, + ); + } + + @override + void dispose() { + unawaited(_focusChanges?.cancel()); + super.dispose(); + } + + void _acknowledge() { + // Post-frame and focus callbacks can land after dispose; `ref` is unusable then. + if (!mounted) return; + final detail = ref.read(inboxThreadDetailProvider(widget.threadId)).value; + if (detail == null || + detail.thread.unreadReplyCount == 0 || + detail.revision == _acknowledgedRevision || + !ref.read(inboxWindowFocusProvider).isForeground) { + return; + } + _acknowledgedRevision = detail.revision; + unawaited( + ref + .read(inboxRepositoryProvider) + .markRead(widget.threadId) + .catchError((Object _) => _acknowledgedRevision = null), + ); + } + + @override + Widget build(BuildContext context) { + final threadId = widget.threadId; + final now = widget.now; + final detail = ref.watch(inboxThreadDetailProvider(threadId)); + if (detail.hasError && !detail.hasValue) { + return AleraEmptyState( + icon: AleraIcons.warning, + title: 'Question Unavailable', + message: inboxErrorMessage(detail.error!), + ); + } + final value = detail.value; + if (value == null) { + return const AleraEmptyState(loading: true, message: 'Loading thread...'); + } + final clock = now ?? DateTime.now(); + final thread = value.thread; + final hint = thread.status.open + ? inboxDeliveryHint(value.recipient.deliveryMode) + : null; + return Column( + crossAxisAlignment: .stretch, + children: [ + _ThreadHeader(detail: value), + if (hint != null) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AleraTokens.space12, + vertical: AleraTokens.space4, + ), + child: AleraInlineNotice(tone: .warning, message: hint), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.all(AleraTokens.space12), + children: [ + for (final message in value.messages) + InboxMessageCard( + message: message, + inbox: thread.inbox, + now: clock, + ), + ], + ), + ), + const Divider(height: 1), + InboxFollowUpComposer( + key: ValueKey('followUp:$threadId'), + threadId: threadId, + enabled: value.recipient.sessionLive, + ), + ], + ); + } +} + +class _ThreadHeader extends ConsumerWidget { + const _ThreadHeader({required this.detail}); + + final InboxThreadDetail detail; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final thread = detail.thread; + final question = detail.latestQuestion; + final target = thread.target; + final muted = theme.textTheme.bodySmall?.copyWith( + color: AleraTokens.foregroundMuted, + ); + return Padding( + padding: const EdgeInsets.fromLTRB( + AleraTokens.space12, + AleraTokens.space12, + AleraTokens.space12, + AleraTokens.space4, + ), + child: Column( + crossAxisAlignment: .start, + spacing: AleraTokens.space4, + children: [ + Row( + children: [ + Expanded( + child: Text(thread.subject, style: theme.textTheme.titleMedium), + ), + AleraBadge( + label: thread.status.label, + tone: inboxStatusTone(thread.status), + ), + if (question?.status == InboxQuestionStatus.pending) + TextButton( + key: const ValueKey('inboxCancelQuestion'), + onPressed: () => unawaited( + _run(context, () async { + await ref + .read(inboxRepositoryProvider) + .cancel(question!.id); + }), + ), + child: const Text('Cancel Question'), + ), + if (thread.unreadReplyCount > 0) + TextButton( + onPressed: () => unawaited( + _run( + context, + () => ref + .read(inboxRepositoryProvider) + .markRead(thread.threadId), + ), + ), + child: const Text('Mark Read'), + ), + ], + ), + Text( + [ + 'To ${inboxAgentLabel(target.agent ?? detail.recipient.agent)}', + ?target.tabTitle, + ?target.workspaceName, + 'from ${thread.inbox}', + if (thread.origin case final origin?) 'via ${origin.label}', + ].join(' · '), + style: muted, + ), + ], + ), + ); + } +} + +Future _run(BuildContext context, Future Function() action) async { + try { + await action(); + } on Object catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.maybeOf(context) + ?.showSnackBar(SnackBar(content: Text(inboxErrorMessage(error)))); + } +} + +class const InboxMessageCard({ + super.key, + required final InboxMessage message, + required final String inbox, + required final DateTime now, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final outgoing = message.kind == InboxMessageKind.question; + final muted = theme.textTheme.bodySmall?.copyWith( + color: AleraTokens.foregroundMuted, + ); + final status = message.status; + final expiry = status == InboxQuestionStatus.pending + ? inboxExpiryLabel(message.expiresAt, now) + : null; + final heading = switch (message.kind) { + .question => 'Question from $inbox', + .reply => 'Reply', + .message => 'Message', + }; + return Align( + alignment: outgoing + ? AlignmentDirectional.centerEnd + : AlignmentDirectional.centerStart, + child: Container( + margin: const EdgeInsets.only(bottom: AleraTokens.space8), + padding: const EdgeInsets.all(AleraTokens.space12), + constraints: const BoxConstraints( + maxWidth: AleraTokens.chatBubbleMaxWidth, + ), + decoration: BoxDecoration( + color: outgoing ? AleraTokens.surface : AleraTokens.surfaceElevated, + borderRadius: BorderRadius.circular(AleraTokens.radiusMd), + border: Border.all(color: AleraTokens.border), + ), + child: Column( + crossAxisAlignment: .start, + spacing: AleraTokens.space6, + children: [ + Row( + spacing: AleraTokens.space6, + children: [ + Text(heading, style: theme.textTheme.labelMedium), + if (status != null) + AleraBadge( + label: status.label, + tone: inboxStatusTone(status), + ), + if (!outgoing && !message.read) + const AleraBadge(label: 'New', tone: AleraBadgeTone.accent), + const Spacer(), + Text( + [inboxTimeLabel(message.createdAt, now), ?expiry].join(' · '), + style: muted, + ), + ], + ), + SelectableText(message.body, style: theme.textTheme.bodyMedium), + ], + ), + ), + ); + } +} + +/// Continues the thread with the same agent and inbox. +class InboxFollowUpComposer extends ConsumerStatefulWidget { + const InboxFollowUpComposer({ + super.key, + required this.threadId, + required this.enabled, + }); + + final String threadId; + + /// False once the agent's terminal is gone; a follow-up would be refused. + final bool enabled; + + @override + ConsumerState createState() => + _InboxFollowUpComposerState(); +} + +class _InboxFollowUpComposerState extends ConsumerState { + final _body = TextEditingController(); + bool _sending = false; + String? _error; + + @override + void dispose() { + _body.dispose(); + super.dispose(); + } + + Future _send() async { + final body = _body.text.trim(); + if (body.isEmpty || _sending || !widget.enabled) return; + setState(() { + _sending = true; + _error = null; + }); + try { + await ref + .read(inboxRepositoryProvider) + .ask(InboxAskRequest(body: body, threadId: widget.threadId)); + _body.clear(); + if (mounted) setState(() => _sending = false); + } on Object catch (error) { + if (!mounted) return; + setState(() { + _sending = false; + _error = inboxErrorMessage(error); + }); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(AleraTokens.space12), + child: Column( + crossAxisAlignment: .stretch, + spacing: AleraTokens.space8, + children: [ + if (!widget.enabled) + const AleraInlineNotice( + message: 'This agent\'s terminal is no longer running. Ask another agent from New Question.', + ), + if (_error != null) AleraInlineNotice(tone: .error, message: _error!), + Row( + crossAxisAlignment: .end, + spacing: AleraTokens.space8, + children: [ + Expanded( + child: AleraTextField( + key: const ValueKey('inboxFollowUpBody'), + controller: _body, + hintText: 'Ask a follow-up question', + minLines: 1, + maxLines: 6, + enabled: widget.enabled && !_sending, + onCommandEnter: () => unawaited(_send()), + ), + ), + FilledButton.icon( + key: const ValueKey('inboxFollowUpSend'), + onPressed: widget.enabled && !_sending + ? () => unawaited(_send()) + : null, + icon: const Icon(AleraIcons.send, size: AleraTokens.iconMd), + label: const Text('Send Follow-Up'), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/src/features/inbox/presentation/inbox_thread_list.dart b/lib/src/features/inbox/presentation/inbox_thread_list.dart new file mode 100644 index 000000000..44417bb4e --- /dev/null +++ b/lib/src/features/inbox/presentation/inbox_thread_list.dart @@ -0,0 +1,246 @@ +import 'package:alera/src/app/theme/alera_tokens.dart'; +import 'package:alera/src/design_system/badges/alera_badge.dart'; +import 'package:alera/src/design_system/feedback/alera_empty_state.dart'; +import 'package:alera/src/design_system/forms/alera_dropdown_field.dart'; +import 'package:alera/src/design_system/icons/alera_icons.dart'; +import 'package:alera/src/design_system/surfaces/alera_active_rail.dart'; +import 'package:alera/src/features/inbox/application/inbox_navigation.dart'; +import 'package:alera/src/features/inbox/application/inbox_providers.dart'; +import 'package:alera/src/features/inbox/domain/inbox_error_messages.dart'; +import 'package:alera/src/features/inbox/domain/inbox_models.dart'; +import 'package:alera/src/features/inbox/infra/runtime_inbox_repository.dart'; +import 'package:alera/src/features/inbox/presentation/inbox_labels.dart'; +import 'package:alera/src/features/inbox/presentation/inbox_page_continuation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Filters plus the threads that match them, in the host's order: most +/// recent thread activity first. +class InboxThreadListPane extends ConsumerStatefulWidget { + const InboxThreadListPane({super.key, this.now}); + + /// Fixed clock for tests; defaults to the wall clock. + final DateTime? now; + + @override + ConsumerState createState() => + _InboxThreadListPaneState(); +} + +class _InboxThreadListPaneState extends ConsumerState { + final _more = InboxPageContinuation((thread) => thread.threadId); + + Future _loadMore(InboxLocation location, int before) async { + final generation = _more.generation; + setState(() { + _more.loading = true; + _more.error = null; + }); + try { + final page = await ref + .read(inboxRepositoryProvider) + .readThreads( + InboxThreadQuery( + inbox: location.inboxFilter, + status: location.statusFilter, + before: before, + ), + ); + if (!mounted) return; + setState(() => _more.append(generation, page.items, page.nextBefore)); + } on Object catch (error) { + if (mounted) setState(() => _more.error = error); + } finally { + if (mounted) setState(() => _more.loading = false); + } + } + + @override + Widget build(BuildContext context) { + final now = widget.now; + final location = ref.watch(inboxNavigationProvider); + final navigation = ref.read(inboxNavigationProvider.notifier); + final summary = ref.watch(inboxSummaryProvider).value; + final threads = ref.watch( + inboxThreadsProvider( + inbox: location.inboxFilter, + status: location.statusFilter, + ), + ); + final inboxes = { + ...?summary?.inboxes.map((entry) => entry.inbox), + ?location.inboxFilter, + }.toList()..sort(); + _more.sync( + '${location.inboxFilter}|${location.statusFilter?.key}', + threads.value, + ); + final items = _more.merge(threads.value?.items ?? const []); + final cursor = _more.cursorAfter(threads.value?.nextBefore); + return Column( + crossAxisAlignment: .stretch, + children: [ + Padding( + padding: const EdgeInsets.all(AleraTokens.space8), + child: Row( + spacing: AleraTokens.space8, + children: [ + Expanded( + child: AleraDropdownField( + key: const ValueKey('inboxFilterInbox'), + labelText: 'Inbox', + value: location.inboxFilter, + entries: >[ + const AleraDropdownFieldEntry( + value: null, + label: 'All Inboxes', + ), + for (final inbox in inboxes) + AleraDropdownFieldEntry( + value: inbox, + label: inbox, + ), + ], + onChanged: navigation.filterInbox, + ), + ), + Expanded( + child: AleraDropdownField( + key: const ValueKey('inboxFilterStatus'), + labelText: 'Status', + value: location.statusFilter, + entries: >[ + const AleraDropdownFieldEntry( + value: null, + label: 'Any Status', + ), + for (final status in InboxQuestionStatus.values) + AleraDropdownFieldEntry( + value: status, + label: status.label, + ), + ], + onChanged: navigation.filterStatus, + ), + ), + ], + ), + ), + Expanded( + child: threads.hasError + ? AleraEmptyState( + icon: AleraIcons.warning, + title: 'Inbox Unavailable', + message: inboxErrorMessage(threads.error!), + ) + : threads.isLoading && !threads.hasValue + ? const AleraEmptyState( + loading: true, + message: 'Loading questions...', + ) + : items.isEmpty + ? const AleraEmptyState( + icon: AleraIcons.inbox, + title: 'No Questions', + message: 'Questions asked from this app, a paired phone or alera inbox appear here with their replies.', + ) + : ListView.builder( + itemCount: items.length + (cursor == null ? 0 : 1), + itemBuilder: (context, index) => index == items.length + ? InboxLoadMoreButton( + loading: _more.loading, + error: _more.error == null + ? null + : inboxErrorMessage(_more.error!), + onPressed: () => _loadMore(location, cursor!), + ) + : InboxThreadTile( + thread: items[index], + selected: + items[index].threadId == + location.selectedThreadId, + now: now ?? DateTime.now(), + onTap: () => + navigation.selectThread(items[index].threadId), + ), + ), + ), + ], + ); + } +} + +class const InboxThreadTile({ + super.key, + required final InboxThread thread, + required final bool selected, + required final DateTime now, + required final VoidCallback onTap, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final muted = theme.textTheme.bodySmall?.copyWith( + color: AleraTokens.foregroundMuted, + ); + final unread = thread.unreadReplyCount; + final target = thread.target; + final who = [ + inboxAgentLabel(target.agent), + ?target.tabTitle, + ?target.workspaceName, + ].join(' · '); + return AleraActiveRail( + active: selected, + child: ListTile( + selected: selected, + selectedTileColor: AleraActiveRail.selectedColor, + onTap: onTap, + dense: true, + title: Row( + children: [ + Expanded( + child: Text( + thread.subject, + maxLines: 1, + overflow: .ellipsis, + style: unread > 0 + ? theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ) + : null, + ), + ), + const SizedBox(width: AleraTokens.space6), + AleraBadge( + label: thread.status.label, + tone: inboxStatusTone(thread.status), + ), + ], + ), + subtitle: Column( + crossAxisAlignment: .start, + children: [ + Text(who, maxLines: 1, overflow: .ellipsis, style: muted), + Text( + '${thread.inbox} · ${inboxTimeLabel(thread.lastActivityAt, now)}', + maxLines: 1, + overflow: .ellipsis, + style: muted, + ), + ], + ), + trailing: unread == 0 + ? null + : Semantics( + label: '$unread unread ${unread == 1 ? 'reply' : 'replies'}', + excludeSemantics: true, + child: AleraBadge( + label: unread > 99 ? '99+' : '$unread', + tone: AleraBadgeTone.accent, + ), + ), + ), + ); + } +} diff --git a/lib/src/features/orchestration/application/run_board_navigation.dart b/lib/src/features/orchestration/application/run_board_navigation.dart index f74cd0cac..bb39ca4d0 100644 --- a/lib/src/features/orchestration/application/run_board_navigation.dart +++ b/lib/src/features/orchestration/application/run_board_navigation.dart @@ -1,4 +1,5 @@ import 'package:alera/src/features/automations/application/automations_navigation.dart'; +import 'package:alera/src/features/inbox/application/inbox_navigation.dart'; import 'package:alera/src/features/orchestration/domain/run_board_location.dart'; import 'package:alera/src/features/orchestration/domain/run_board_snapshot.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -14,6 +15,9 @@ class RunBoardNavigation extends _$RunBoardNavigation { if (ref.read(automationsNavigationProvider).visible) { ref.read(automationsNavigationProvider.notifier).close(); } + if (ref.read(inboxNavigationProvider).visible) { + ref.read(inboxNavigationProvider.notifier).close(); + } state = state.copyWith(visible: true); } diff --git a/lib/src/features/orchestration/application/run_board_navigation.g.dart b/lib/src/features/orchestration/application/run_board_navigation.g.dart index 7c0b2ad97..2849fe074 100644 --- a/lib/src/features/orchestration/application/run_board_navigation.g.dart +++ b/lib/src/features/orchestration/application/run_board_navigation.g.dart @@ -42,7 +42,7 @@ final class RunBoardNavigationProvider } String _$runBoardNavigationHash() => - r'310abd85cdf2731456e3738d7adc98aa3cd05e35'; + r'8cd61053ab133db952778db2b7ebea398b5836d3'; abstract class _$RunBoardNavigation extends $Notifier { RunBoardLocation build(); diff --git a/lib/src/features/shell/application/shell_overlay_page.dart b/lib/src/features/shell/application/shell_overlay_page.dart new file mode 100644 index 000000000..95685eb51 --- /dev/null +++ b/lib/src/features/shell/application/shell_overlay_page.dart @@ -0,0 +1,26 @@ +import 'package:alera/src/features/automations/application/automations_navigation.dart'; +import 'package:alera/src/features/inbox/application/inbox_navigation.dart'; +import 'package:alera/src/features/orchestration/application/run_board_navigation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'shell_overlay_page.g.dart'; + +/// The full-width page that replaces the workbench, if any. +enum ShellOverlayPage { none, runBoard, automations, inbox } + +@riverpod +ShellOverlayPage shellOverlayPage(Ref ref) { + if (ref.watch(runBoardNavigationProvider.select((value) => value.visible))) { + return ShellOverlayPage.runBoard; + } + if (ref.watch( + automationsNavigationProvider.select((value) => value.visible), + )) { + return ShellOverlayPage.automations; + } + if (ref.watch(inboxNavigationProvider.select((value) => value.visible))) { + return ShellOverlayPage.inbox; + } + return ShellOverlayPage.none; +} diff --git a/lib/src/features/shell/application/shell_overlay_page.g.dart b/lib/src/features/shell/application/shell_overlay_page.g.dart new file mode 100644 index 000000000..6074da71b --- /dev/null +++ b/lib/src/features/shell/application/shell_overlay_page.g.dart @@ -0,0 +1,56 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'shell_overlay_page.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(shellOverlayPage) +final shellOverlayPageProvider = ShellOverlayPageProvider._(); + +final class ShellOverlayPageProvider + extends + $FunctionalProvider< + ShellOverlayPage, + ShellOverlayPage, + ShellOverlayPage + > + with $Provider { + ShellOverlayPageProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'shellOverlayPageProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$shellOverlayPageHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + ShellOverlayPage create(Ref ref) { + return shellOverlayPage(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ShellOverlayPage value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$shellOverlayPageHash() => r'c1e264715aeb9fa3da2b105e7514c0c8614ea819'; diff --git a/lib/src/features/shell/presentation/alera_shell_page.dart b/lib/src/features/shell/presentation/alera_shell_page.dart index 6b1b83c62..4388a2e41 100644 --- a/lib/src/features/shell/presentation/alera_shell_page.dart +++ b/lib/src/features/shell/presentation/alera_shell_page.dart @@ -1,11 +1,11 @@ import 'dart:async'; -import 'package:alera/src/features/automations/application/automations_navigation.dart'; import 'package:alera/src/features/automations/presentation/automation_attention_control.dart'; -import 'package:alera/src/features/automations/presentation/automations_page.dart'; -import 'package:alera/src/features/orchestration/application/run_board_navigation.dart'; -import 'package:alera/src/features/orchestration/presentation/run_board_page.dart'; +import 'package:alera/src/features/inbox/application/inbox_notifications.dart'; +import 'package:alera/src/features/inbox/presentation/inbox_attention_control.dart'; import 'package:alera/src/features/orchestration/presentation/run_board_attention_control.dart'; +import 'package:alera/src/features/shell/application/shell_overlay_page.dart'; +import 'package:alera/src/features/shell/presentation/shell_overlay_page_view.dart'; import 'package:alera/src/app/providers.dart'; import 'package:alera/src/app/theme/alera_tokens.dart'; diff --git a/lib/src/features/shell/presentation/alera_shell_page_body.dart b/lib/src/features/shell/presentation/alera_shell_page_body.dart index 1cc9327eb..a7090ebae 100644 --- a/lib/src/features/shell/presentation/alera_shell_page_body.dart +++ b/lib/src/features/shell/presentation/alera_shell_page_body.dart @@ -5,10 +5,6 @@ class _AleraShellPageBodyState extends ConsumerState<_AleraShellPageBody> { final WorkbenchTabCompletionAcknowledgements _completionAcknowledgements = WorkbenchTabCompletionAcknowledgements(); - void _returnFromRunBoard() { - ref.read(runBoardNavigationProvider.notifier).close(); - } - @override void initState() { super.initState(); @@ -19,34 +15,14 @@ class _AleraShellPageBodyState extends ConsumerState<_AleraShellPageBody> { @override Widget build(BuildContext context) { - ref.listen( - runBoardNavigationProvider.select((location) => location.visible), - (wasVisible, visible) { - if (wasVisible != true || visible) return; - // All exit routes restore focus after the retained panes become visible. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || ref.read(runBoardNavigationProvider).visible) return; - KeyboardCommandDispatcher( - ref: ref, - context: context, - ).focusActivePane(); - }); - }, - ); - ref.listen( - automationsNavigationProvider.select((location) => location.visible), - (wasVisible, visible) { - if (wasVisible != true || visible) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - if (ref.read(automationsNavigationProvider).visible) return; - KeyboardCommandDispatcher( - ref: ref, - context: context, - ).focusActivePane(); - }); - }, - ); + ref.listen(shellOverlayPageProvider, (previous, next) { + if (previous == null || previous == .none || next != .none) return; + // All exit routes restore focus after the retained panes become visible. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || ref.read(shellOverlayPageProvider) != .none) return; + KeyboardCommandDispatcher(ref: ref, context: context).focusActivePane(); + }); + }); _watchShellCoordinators(ref); final shell = ref.watch( workbenchControllerProvider.select((state) { @@ -102,12 +78,7 @@ class _AleraShellPageBodyState extends ConsumerState<_AleraShellPageBody> { workspace: workspace, prefs: shell.viewPrefs, ); - final showRunBoard = ref.watch( - runBoardNavigationProvider.select((location) => location.visible), - ); - final showAutomations = ref.watch( - automationsNavigationProvider.select((location) => location.visible), - ); + final showOverlay = ref.watch(shellOverlayPageProvider) != .none; final content = AleraAppMenuScope( child: Scaffold( @@ -121,7 +92,7 @@ class _AleraShellPageBodyState extends ConsumerState<_AleraShellPageBody> { fit: StackFit.expand, children: [ Visibility( - visible: !showRunBoard && !showAutomations, + visible: !showOverlay, maintainState: true, child: Row( crossAxisAlignment: .stretch, @@ -412,14 +383,7 @@ class _AleraShellPageBodyState extends ConsumerState<_AleraShellPageBody> { ], ), ), - if (showRunBoard) - RunBoardPage(onReturnToWorkspace: _returnFromRunBoard) - else if (showAutomations) - AutomationsPage( - onReturnToWorkspace: () => ref - .read(automationsNavigationProvider.notifier) - .close(), - ), + if (showOverlay) const ShellOverlayPageView(), ], ), ), @@ -429,6 +393,7 @@ class _AleraShellPageBodyState extends ConsumerState<_AleraShellPageBody> { children: [ RunBoardAttentionControl(), AutomationAttentionControl(), + InboxAttentionControl(), VoiceStatusBarControl(), ResourceStatusBarControl(), KeepAliveStatusBarControl(), @@ -444,7 +409,7 @@ class _AleraShellPageBodyState extends ConsumerState<_AleraShellPageBody> { ), ); return WorkspaceTerminalRefresh( - workspaceId: showRunBoard || showAutomations ? null : workspace?.id, + workspaceId: showOverlay ? null : workspace?.id, ready: shell.bootstrapped && shell.sleepSnapshotReady && diff --git a/lib/src/features/shell/presentation/alera_shell_page_coordinators.dart b/lib/src/features/shell/presentation/alera_shell_page_coordinators.dart index e94e35f73..802f5c59a 100644 --- a/lib/src/features/shell/presentation/alera_shell_page_coordinators.dart +++ b/lib/src/features/shell/presentation/alera_shell_page_coordinators.dart @@ -5,6 +5,7 @@ void _watchShellCoordinators(WidgetRef ref) { ref.watch(terminalHostWarmupCoordinatorProvider); ref.watch(runtimeAgentStatusSyncProvider); ref.watch(agentStatusNotificationCoordinatorProvider); + ref.watch(inboxReplyNotificationCoordinatorProvider); ref.watch(workspacePullRequestMonitorControllerProvider.notifier); ref.watch(workspacePullRequestFailureNotificationCoordinatorProvider); ref.watch(workspaceFocusRequestCoordinatorProvider); diff --git a/lib/src/features/shell/presentation/shell_overlay_page_view.dart b/lib/src/features/shell/presentation/shell_overlay_page_view.dart new file mode 100644 index 000000000..5172ef03c --- /dev/null +++ b/lib/src/features/shell/presentation/shell_overlay_page_view.dart @@ -0,0 +1,32 @@ +import 'package:alera/src/features/automations/application/automations_navigation.dart'; +import 'package:alera/src/features/automations/presentation/automations_page.dart'; +import 'package:alera/src/features/inbox/application/inbox_navigation.dart'; +import 'package:alera/src/features/inbox/presentation/inbox_page.dart'; +import 'package:alera/src/features/orchestration/application/run_board_navigation.dart'; +import 'package:alera/src/features/orchestration/presentation/run_board_page.dart'; +import 'package:alera/src/features/shell/application/shell_overlay_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Shows the Run Board, Automations or the inbox over the retained workbench. +class ShellOverlayPageView extends ConsumerWidget { + const ShellOverlayPageView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) => + switch (ref.watch(shellOverlayPageProvider)) { + ShellOverlayPage.none => const SizedBox.shrink(), + ShellOverlayPage.runBoard => RunBoardPage( + onReturnToWorkspace: () => + ref.read(runBoardNavigationProvider.notifier).close(), + ), + ShellOverlayPage.automations => AutomationsPage( + onReturnToWorkspace: () => + ref.read(automationsNavigationProvider.notifier).close(), + ), + ShellOverlayPage.inbox => InboxPage( + onReturnToWorkspace: () => + ref.read(inboxNavigationProvider.notifier).close(), + ), + }; +} diff --git a/lib/src/features/workbench/infra/terminal_host/terminal_host_client_models.dart b/lib/src/features/workbench/infra/terminal_host/terminal_host_client_models.dart index e4d4f7a75..84163e29d 100644 --- a/lib/src/features/workbench/infra/terminal_host/terminal_host_client_models.dart +++ b/lib/src/features/workbench/infra/terminal_host/terminal_host_client_models.dart @@ -377,6 +377,7 @@ const Set runtimeHostEventNames = { 'codexThreadChanged', 'codexServerChanged', 'orchestrationBoardChanged', + 'inboxChanged', 'workflowCatalogChanged', 'voice.utterance', 'voice.session', diff --git a/lib/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart b/lib/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart index ab3da9813..a1ca99de5 100644 --- a/lib/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart +++ b/lib/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart @@ -95,6 +95,7 @@ const String aleraRuntimeHostRemoteProcessCapability = 'remoteProcessV1'; const String aleraRuntimeHostProjectHostsCapability = 'projectHostsV1'; const String aleraRuntimeHostOrchestrationCapability = 'orchestration'; const String aleraRuntimeHostRunBoardCapability = 'orchestrationRunBoardV1'; +const String aleraRuntimeHostInboxCapability = 'inboxV1'; const String aleraRuntimeHostWorkflowPlansCapability = 'workflowReviewedPlansV1'; const String aleraRuntimeHostWorkflowLifecycleCapability = diff --git a/lib/src/features/workbench/presentation/project_workbench_agent_rows.dart b/lib/src/features/workbench/presentation/project_workbench_agent_rows.dart index eb34057c8..d6e7fa8ca 100644 --- a/lib/src/features/workbench/presentation/project_workbench_agent_rows.dart +++ b/lib/src/features/workbench/presentation/project_workbench_agent_rows.dart @@ -130,9 +130,7 @@ class _AgentRunRowState extends ConsumerState<_AgentRunRow> { BuildContext context, Offset globalPosition, ) async { - if (ref.read(agentTitleAvailableProvider).value != true) { - return; - } + final titleAvailable = ref.read(agentTitleAvailableProvider).value == true; final overlay = Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox; final selected = await showMenu( @@ -142,17 +140,28 @@ class _AgentRunRowState extends ConsumerState<_AgentRunRow> { Offset.zero & overlay.size, ), items: >[ - AleraDropdownEntry( - value: 'generateTitle', - label: agentTitleActionLabel(widget.tab.payload), - enabled: !isAgentTitleGenerating(widget.tab.payload), - leading: const Icon(AleraIcons.ai, size: 16), + const AleraDropdownEntry( + value: 'askAgent', + label: 'Ask Agent', + leading: Icon(AleraIcons.inbox, size: 16), ), + if (titleAvailable) + AleraDropdownEntry( + value: 'generateTitle', + label: agentTitleActionLabel(widget.tab.payload), + enabled: !isAgentTitleGenerating(widget.tab.payload), + leading: const Icon(AleraIcons.ai, size: 16), + ), ], ); - if (selected != 'generateTitle' || !context.mounted) { + if (!context.mounted) return; + if (selected == 'askAgent') { + ref + .read(inboxNavigationProvider.notifier) + .compose(targetHandle: widget.tab.terminalSessionId); return; } + if (selected != 'generateTitle') return; try { await ref.read(agentTitleServiceProvider).generate(widget.tab); } on Object catch (error) { diff --git a/lib/src/features/workbench/presentation/project_workbench_sidebar.dart b/lib/src/features/workbench/presentation/project_workbench_sidebar.dart index b7f0e76fa..0c1658980 100644 --- a/lib/src/features/workbench/presentation/project_workbench_sidebar.dart +++ b/lib/src/features/workbench/presentation/project_workbench_sidebar.dart @@ -8,6 +8,7 @@ import 'package:alera/src/features/workbench/domain/workspace_panel.dart'; import 'package:alera/src/app/providers.dart'; import 'package:alera/src/features/automations/application/automations_navigation.dart'; +import 'package:alera/src/features/inbox/application/inbox_navigation.dart'; import 'package:alera/src/features/automations/domain/automation_catalog_query.dart'; import 'package:alera/src/features/ai_assist/application/agent_title_providers.dart'; import 'package:alera/src/features/ai_assist/application/agent_title_service.dart'; diff --git a/test/support/inbox_test_client.dart b/test/support/inbox_test_client.dart new file mode 100644 index 000000000..8a05b88a8 --- /dev/null +++ b/test/support/inbox_test_client.dart @@ -0,0 +1,138 @@ +import 'dart:async'; + +import 'package:alera/src/features/inbox/application/inbox_providers.dart'; +import 'package:alera/src/features/inbox/infra/runtime_inbox_repository.dart'; +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart'; +import 'package:alera/src/shared/infra/runtime/runtime_change_coalescer.dart'; +import 'package:flutter_riverpod/misc.dart'; + +/// Runtime client double that answers `inbox.*` from canned payloads. +class InboxTestClient + implements RuntimeHostClient, RuntimeHostCapabilityClient { + final StreamController events = + StreamController.broadcast(); + final List<(String, Map)> calls = + <(String, Map)>[]; + final Map)> handlers = + )>{}; + bool supported = true; + + void respond(String type, Object? payload) => handlers[type] = (_) => payload; + + void emit(String name) => events.add(RuntimeHostEvent(name, const {})); + + List> callsTo(String type) => >[ + for (final (name, payload) in calls) + if (name == type) payload, + ]; + + @override + Stream get runtimeEvents => events.stream; + + @override + Future supportsRuntimeCapability(String capability) async => + capability == aleraRuntimeHostInboxCapability && supported; + + @override + Future runtimeRequest( + String type, [ + Map payload = const {}, + Duration? timeout, + ]) async { + calls.add((type, payload)); + final handler = handlers[type]; + if (handler == null) return const {}; + return handler(payload); + } +} + +/// Real repository over the test client, so payload parsing is exercised. +List inboxClientOverrides(InboxTestClient client) => [ + inboxRepositoryProvider.overrideWith((ref) { + final coalescer = RuntimeChangeCoalescer( + debounce: const Duration(milliseconds: 1), + maxDelay: const Duration(milliseconds: 50), + ); + ref.onDispose(coalescer.dispose); + return RuntimeInboxRepository(client, coalescer); + }), +]; + +Map inboxThreadJson({ + String threadId = 'msg_q1', + String status = 'delivered', + String subject = 'Migration risk', + int unread = 0, + String inbox = 'ext:user', +}) => { + 'threadId': threadId, + 'inbox': inbox, + 'recipient': 'term-1', + 'workspaceId': 'ws-1', + 'subject': subject, + 'createdAt': '2026-10-06 10:00:00', + 'lastActivityAt': '2026-10-06 10:05:00', + 'lastSequence': 12, + 'status': status, + 'questionCount': 1, + 'replyCount': unread, + 'unreadReplyCount': unread, + 'origin': {'surface': 'mobile', 'deviceName': 'Pixel'}, + 'target': { + 'agent': 'claude', + 'tabTitle': 'Fix Login', + 'workspaceName': 'Auth', + }, +}; + +Map inboxMessageJson({ + required String kind, + required String id, + required String body, + String? status, + bool read = false, + String from = 'ext:user', + String to = 'term-1', + String? expiresAt, +}) => { + 'kind': kind, + 'status': ?status, + 'message': { + 'id': id, + 'from_handle': from, + 'to_handle': to, + 'subject': 'Migration risk', + 'body': body, + 'read': read, + 'sequence': id == 'msg_q1' ? 10 : 12, + 'created_at': '2026-10-06 10:00:00', + 'expires_at': ?expiresAt, + }, +}; + +Map inboxDetailJson({ + String status = 'delivered', + List>? messages, + String deliveryMode = 'paste', + bool sessionLive = true, +}) => { + 'thread': inboxThreadJson(status: status), + 'messages': + messages ?? + >[ + inboxMessageJson( + kind: 'question', + id: 'msg_q1', + body: 'What could break?', + status: status, + ), + ], + 'recipient': { + 'handle': 'term-1', + 'sessionLive': sessionLive, + 'workspaceId': 'ws-1', + 'agent': 'claude', + 'deliveryMode': deliveryMode, + }, + 'revision': 3, +}; diff --git a/test/unit/inbox_domain_test.dart b/test/unit/inbox_domain_test.dart new file mode 100644 index 000000000..82baadcb8 --- /dev/null +++ b/test/unit/inbox_domain_test.dart @@ -0,0 +1,56 @@ +import 'package:alera/src/features/inbox/domain/inbox_error_messages.dart'; +import 'package:alera/src/features/inbox/domain/inbox_models.dart'; +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_client_models.dart'; +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('unknown values fall back to safe defaults', () { + expect(InboxMessageKind.parse('future'), InboxMessageKind.message); + expect(InboxQuestionStatus.parse(null), InboxQuestionStatus.pending); + expect(InboxDeliveryMode.parse(7), InboxDeliveryMode.unavailable); + expect(parseInboxTimestamp(''), isNull); + expect( + parseInboxTimestamp('2026-10-06T10:00:00Z'), + DateTime.utc(2026, 10, 6, 10), + ); + expect(const InboxOrigin(surface: 'desktop').label, 'Alera desktop'); + expect(const InboxOrigin(surface: 'mobile').label, 'Alera mobile'); + expect(const InboxOrigin(surface: 'cli').label, 'CLI'); + expect(InboxQuestionStatus.delivered.open, isTrue); + expect(InboxQuestionStatus.answered.open, isFalse); + }); + + test('every host error code has a sentence-case message', () { + for (final code in [ + 'inbox_pending_limit', + 'inbox_ambiguous_recipient', + 'inbox_no_recipient', + 'inbox_unknown_recipient', + 'inbox_thread_mismatch', + 'inbox_not_cancellable', + 'inbox_question_not_found', + 'inbox_thread_not_found', + 'inbox_invalid_address', + 'inbox_invalid_expiry', + 'message_too_large', + ]) { + final message = inboxErrorMessage( + TerminalHostConflictException(code: code, message: code), + ); + expect(message, isNot(code)); + expect(message, endsWith('.')); + } + expect( + inboxErrorMessage(const TerminalHostConnectionClosedException()), + 'The Alera runtime is not reachable.', + ); + expect(inboxErrorMessage(StateError('Boom')), contains('Boom')); + // A runtime const instance, so the constructor line is covered. + // ignore: prefer_const_constructors + expect( + InboxUpdateRequired().toString(), + 'Update the runtime to use the inbox.', + ); + }); +} diff --git a/test/unit/inbox_navigation_test.dart b/test/unit/inbox_navigation_test.dart new file mode 100644 index 000000000..04302c8cd --- /dev/null +++ b/test/unit/inbox_navigation_test.dart @@ -0,0 +1,75 @@ +import 'package:alera/src/features/automations/application/automations_navigation.dart'; +import 'package:alera/src/features/inbox/application/inbox_navigation.dart'; +import 'package:alera/src/features/inbox/application/inbox_notifications.dart'; +import 'package:alera/src/features/inbox/domain/inbox_models.dart'; +import 'package:alera/src/features/orchestration/application/run_board_navigation.dart'; +import 'package:alera/src/features/shell/application/shell_overlay_page.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('the inbox shares the page slot with the Run Board and Automations', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final inbox = container.read(inboxNavigationProvider.notifier); + container.read(runBoardNavigationProvider.notifier).open(); + expect(container.read(shellOverlayPageProvider), ShellOverlayPage.runBoard); + + inbox.open(); + expect(container.read(runBoardNavigationProvider).visible, isFalse); + expect(container.read(shellOverlayPageProvider), ShellOverlayPage.inbox); + + container.read(automationsNavigationProvider.notifier).open(); + expect(container.read(inboxNavigationProvider).visible, isFalse); + expect( + container.read(shellOverlayPageProvider), + ShellOverlayPage.automations, + ); + + inbox.open(); + container.read(runBoardNavigationProvider.notifier).open(); + expect(container.read(inboxNavigationProvider).visible, isFalse); + + container.read(runBoardNavigationProvider.notifier).close(); + expect(container.read(shellOverlayPageProvider), ShellOverlayPage.none); + }); + + test('compose opens the page with a preselected recipient once', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final inbox = container.read(inboxNavigationProvider.notifier); + inbox.compose(targetHandle: 'term-1'); + final location = container.read(inboxNavigationProvider); + expect(location.visible, isTrue); + expect(location.compose?.targetHandle, 'term-1'); + expect(location.composeSequence, 1); + inbox.consumeCompose(); + expect(container.read(inboxNavigationProvider).compose, isNull); + inbox.selectThread('msg_q1'); + inbox.filterStatus(InboxQuestionStatus.pending); + inbox.filterInbox('ext:ci'); + final filtered = container.read(inboxNavigationProvider); + expect(filtered.selectedThreadId, isNull); + expect(filtered.statusFilter, InboxQuestionStatus.pending); + expect(filtered.inboxFilter, 'ext:ci'); + }); + + test('only growth of unread replies while the inbox is hidden notifies', () { + final tracker = InboxReplyNotificationTracker(); + expect(tracker.observe(4, inboxVisible: false), 0); + expect(tracker.observe(6, inboxVisible: false), 2); + expect(tracker.observe(6, inboxVisible: false), 0); + expect(tracker.observe(7, inboxVisible: true), 0); + expect(tracker.observe(3, inboxVisible: false), 0); + expect(tracker.observe(4, inboxVisible: false), 1); + }); + + test('notifications carry no reply text and are recognizable', () { + final notification = inboxReplyNotification(2); + expect(notification.title, 'New Inbox Reply'); + expect(notification.body, contains('2 questions')); + expect(isInboxNotificationPayload(notification.payload), isTrue); + expect(isInboxNotificationPayload('{"terminalSessionId":"x"}'), isFalse); + expect(isInboxNotificationPayload('not json'), isFalse); + }); +} diff --git a/test/unit/inbox_notification_focus_test.dart b/test/unit/inbox_notification_focus_test.dart new file mode 100644 index 000000000..0ac5bf311 --- /dev/null +++ b/test/unit/inbox_notification_focus_test.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import 'package:alera/src/app/providers.dart'; +import 'package:alera/src/features/agent_status/application/agent_status_notification_activation_service.dart'; +import 'package:alera/src/features/agent_status/application/agent_status_notifications.dart'; +import 'package:alera/src/features/app_window/domain/app_foreground.dart'; +import 'package:alera/src/features/inbox/application/inbox_navigation.dart'; +import 'package:alera/src/features/inbox/application/inbox_notifications.dart'; +import 'package:alera/src/features/inbox/application/inbox_providers.dart'; +import 'package:alera/src/features/settings/domain/alera_settings.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/inbox_test_client.dart'; + +class _WindowFocus implements AppForeground { + _WindowFocus(this._focused); + + bool _focused; + final StreamController _changes = StreamController.broadcast(); + + set focused(bool value) { + _focused = value; + _changes.add(value); + } + + @override + bool get isForeground => _focused; + + @override + Stream get changes => _changes.stream; + + @override + void dispose() => unawaited(_changes.close()); +} + +class _Presenter implements AgentStatusNotificationPresenter { + final List shown = []; + + @override + Future initialize({ + required AgentStatusNotificationSelectionHandler onSelected, + }) async {} + + @override + Future show(AgentStatusNotification notification) async => + shown.add(notification); +} + +class _Activator implements AgentNotificationWindowActivator { + @override + Future showAndFocus() async {} +} + +void main() { + test( + 'a reply notifies while the inbox is open in an unfocused window', + () async { + var unread = 1; + final client = InboxTestClient(); + client.handlers['inbox.summary'] = (_) => { + 'items': [ + {'inbox': 'ext:user', 'unreadReplyCount': unread}, + ], + }; + final focus = _WindowFocus(false); + final presenter = _Presenter(); + final container = ProviderContainer( + overrides: [ + ...inboxClientOverrides(client), + inboxWindowFocusProvider.overrideWithValue(focus), + agentStatusNotificationPresenterProvider.overrideWithValue(presenter), + agentStatusNotificationWindowActivatorProvider.overrideWithValue( + _Activator(), + ), + settingsControllerProvider.overrideWithValue( + AleraSettings.defaults.copyWith( + agents: AleraSettings.defaults.agents.copyWith( + agentStatusNotificationsEnabled: true, + ), + ), + ), + ], + ); + addTearDown(container.dispose); + addTearDown(client.events.close); + container.read(inboxNavigationProvider.notifier).open(); + // The shell watches it; a provider nobody listens to is paused. + container.listen(inboxReplyNotificationCoordinatorProvider, (_, _) {}); + await Future.delayed(const Duration(milliseconds: 30)); + + unread = 2; + client.emit('inboxChanged'); + await Future.delayed(const Duration(milliseconds: 30)); + expect(presenter.shown, hasLength(1)); + + focus.focused = true; + unread = 3; + client.emit('inboxChanged'); + await Future.delayed(const Duration(milliseconds: 30)); + expect(presenter.shown, hasLength(1)); + }, + ); +} diff --git a/test/unit/runtime_inbox_repository_test.dart b/test/unit/runtime_inbox_repository_test.dart new file mode 100644 index 000000000..f741c16b3 --- /dev/null +++ b/test/unit/runtime_inbox_repository_test.dart @@ -0,0 +1,185 @@ +import 'package:alera/src/features/inbox/domain/inbox_error_messages.dart'; +import 'package:alera/src/features/inbox/domain/inbox_models.dart'; +import 'package:alera/src/features/inbox/infra/runtime_inbox_repository.dart'; +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_client_models.dart'; +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart'; +import 'package:alera/src/shared/infra/runtime/runtime_change_coalescer.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/inbox_test_client.dart'; + +void main() { + late InboxTestClient client; + late RuntimeChangeCoalescer coalescer; + late RuntimeInboxRepository repository; + + setUp(() { + client = InboxTestClient(); + coalescer = RuntimeChangeCoalescer( + debounce: const Duration(milliseconds: 1), + maxDelay: const Duration(milliseconds: 50), + ); + repository = RuntimeInboxRepository(client, coalescer); + }); + tearDown(() async { + coalescer.dispose(); + await client.events.close(); + }); + + test('summary, threads and a thread parse the host payloads', () async { + client.respond('inbox.summary', { + 'kind': 'inboxes', + 'items': [ + { + 'inbox': 'ext:user', + 'threadCount': 2, + 'pendingCount': 1, + 'awaitingReplyCount': 0, + 'unreadReplyCount': 3, + 'lastActivityAt': '2026-10-06 10:05:00', + }, + {'inbox': 'ext:ci', 'unreadReplyCount': 2}, + ], + 'revision': 7, + }); + final summary = await repository.readSummary(); + expect(summary.unreadReplyCount, 5); + expect(summary.revision, 7); + expect( + summary.inboxes.first.lastActivityAt, + DateTime.utc(2026, 10, 6, 10, 5), + ); + + client.respond('inbox.threads', { + 'items': [inboxThreadJson(status: 'answered', unread: 1)], + 'nextBefore': 4, + 'revision': 7, + }); + final page = await repository.readThreads( + const InboxThreadQuery( + inbox: 'ext:user', + status: InboxQuestionStatus.answered, + ), + ); + expect(client.calls.last.$2, { + 'inbox': 'ext:user', + 'status': 'answered', + 'limit': 100, + }); + final thread = page.items.single; + expect(thread.status, InboxQuestionStatus.answered); + expect(thread.origin?.label, 'Alera mobile, Pixel'); + expect(thread.target.tabTitle, 'Fix Login'); + expect(page.nextBefore, 4); + + client.respond( + 'inbox.thread', + inboxDetailJson( + status: 'pending', + deliveryMode: 'check', + messages: [ + inboxMessageJson( + kind: 'question', + id: 'msg_q1', + body: 'Q', + status: 'pending', + expiresAt: '2026-10-06 15:00:00', + ), + inboxMessageJson( + kind: 'reply', + id: 'msg_r1', + body: 'A', + from: 'term-1', + to: 'ext:user', + ), + ], + ), + ); + final detail = await repository.readThread('msg_q1', markRead: true); + expect(client.calls.last.$2, {'threadId': 'msg_q1', 'markRead': true}); + expect(detail.recipient.deliveryMode, InboxDeliveryMode.check); + expect(detail.latestQuestion?.id, 'msg_q1'); + expect(detail.messages.last.kind, InboxMessageKind.reply); + expect(detail.messages.first.expiresAt, DateTime.utc(2026, 10, 6, 15)); + }); + + test('ask sends only what the user chose', () async { + client.respond('inbox.ask', {'threadId': 'msg_q9'}); + final threadId = await repository.ask( + const InboxAskRequest( + body: 'Status?', + inbox: 'ext:user', + to: 'term-1', + subject: ' ', + expiresIn: Duration(hours: 1), + ), + ); + expect(threadId, 'msg_q9'); + expect(client.calls.single.$2, { + 'body': 'Status?', + 'inbox': 'ext:user', + 'to': 'term-1', + 'expiresInMs': 3600000, + }); + await repository.ask( + const InboxAskRequest(body: 'And?', threadId: 'msg_q9'), + ); + expect(client.calls.last.$2, {'body': 'And?', 'threadId': 'msg_q9'}); + }); + + test('an old host fails closed before any request', () async { + client.supported = false; + await expectLater( + repository.readSummary(), + throwsA(isA()), + ); + expect(client.calls, isEmpty); + expect( + inboxErrorMessage(const InboxUpdateRequired()), + 'Update the runtime to use the inbox.', + ); + }); + + test('watchers refresh on inboxChanged and report disconnects', () async { + var revision = 1; + client.handlers['inbox.summary'] = (_) => { + 'items': const [], + 'revision': revision++, + }; + final values = []; + final subscription = repository.watchSummary().listen( + (summary) => values.add(summary.revision), + onError: values.add, + ); + await pumpEventQueue(); + await Future.delayed(const Duration(milliseconds: 20)); + client.emit('inboxChanged'); + await Future.delayed(const Duration(milliseconds: 20)); + client.emit('workspacesChanged'); + await Future.delayed(const Duration(milliseconds: 20)); + client.emit(aleraRuntimeHostDisconnectedEvent); + await pumpEventQueue(); + await subscription.cancel(); + expect(values.take(2), [1, 2]); + expect(values.last, isA()); + expect(values, hasLength(3)); + }); + + test('host error codes become sentence-case messages', () { + expect( + inboxErrorMessage( + const TerminalHostConflictException( + code: 'inbox_pending_limit', + message: 'limit', + ), + ), + startsWith('This agent already has 20 questions waiting'), + ); + expect( + inboxErrorMessage( + const TerminalHostConflictException(code: 'other', message: 'Raw.'), + ), + 'Raw.', + ); + }); +} diff --git a/test/widget/alera_shell_page_sidebar_titles_test_cases.dart b/test/widget/alera_shell_page_sidebar_titles_test_cases.dart index cb1911cf3..c50a042da 100644 --- a/test/widget/alera_shell_page_sidebar_titles_test_cases.dart +++ b/test/widget/alera_shell_page_sidebar_titles_test_cases.dart @@ -99,4 +99,90 @@ void _registerAleraShellSidebarTitleTests() { ); expect((entry.leading! as Icon).size, 16); }); + + testWidgets('Ask Agent opens the inbox composer for that terminal', ( + tester, + ) async { + final inbox = InboxTestClient() + ..respond('inbox.summary', const {'items': []}) + ..respond('inbox.threads', const {'items': []}) + ..respond('inbox.targets', { + 'items': [ + { + 'handle': 'session-7', + 'sessionLive': true, + 'workspaceId': 'workspace-1', + 'agent': 'codex', + 'deliveryMode': 'paste', + 'tabTitle': 'Terminal 2', + }, + ], + }); + await _pumpShell( + tester, + state: _stackedWorkbenchState().copyWith( + tabsByWorkspace: >{ + 'workspace-1': [ + WorkspaceTabRecord( + id: 'tab-1', + workspaceId: 'workspace-1', + title: 'Terminal 1', + createdAt: DateTime.utc(2026, 5, 22), + updatedAt: DateTime.utc(2026, 5, 22), + ), + WorkspaceTabRecord( + id: 'tab-2', + workspaceId: 'workspace-1', + title: 'Terminal 2', + createdAt: DateTime.utc(2026, 5, 22), + updatedAt: DateTime.utc(2026, 5, 22), + payload: const { + 'terminalSessionId': 'session-7', + }, + ), + ], + }, + ), + agentStatuses: { + 'tab-1': _agentStatusEntry( + terminalSessionId: 'tab-1', + workspaceId: 'workspace-1', + tabId: 'tab-1', + state: .waiting, + lastAssistantMessage: 'Other agent', + ), + 'session-7': _agentStatusEntry( + terminalSessionId: 'session-7', + workspaceId: 'workspace-1', + tabId: 'tab-2', + state: .waiting, + lastAssistantMessage: 'Ready to continue', + ), + }, + inboxClient: inbox, + ); + await tester.tap(find.byTooltip('Show Agent Runs')); + await tester.pumpAndSettle(); + await tester.tapAt( + tester.getCenter( + find.descendant( + of: find.byType(ProjectWorkbenchSidebar), + matching: find.text('Ready to continue'), + ), + ), + buttons: kSecondaryMouseButton, + ); + await tester.pumpAndSettle(); + expect(find.text('Regenerate Title'), findsNothing); + await tester.tap(find.text('Ask Agent')); + await tester.pumpAndSettle(); + expect(find.byType(InboxComposerDialog), findsOneWidget); + expect( + tester + .widget(find.byType(InboxComposerDialog)) + .initialTarget, + 'session-7', + ); + expect(find.textContaining('Terminal 2'), findsWidgets); + }); } diff --git a/test/widget/alera_shell_page_test.dart b/test/widget/alera_shell_page_test.dart index 26a0f6fc0..00a3db33b 100644 --- a/test/widget/alera_shell_page_test.dart +++ b/test/widget/alera_shell_page_test.dart @@ -6,6 +6,7 @@ import 'package:alera/src/features/orchestration/application/run_board_providers import 'package:alera/src/features/orchestration/presentation/run_board_page.dart'; import 'package:alera/src/features/workbench/presentation/workspace_editor_surface.dart'; +import '../support/inbox_test_client.dart'; import '../support/run_board_fixtures.dart'; import '../support/run_board_widget_harness.dart'; @@ -49,6 +50,7 @@ import 'package:alera/src/features/workbench/domain/workspace_hand_on_result.dar import 'package:alera/src/features/workbench/domain/workspace_storage_impact.dart'; import 'package:alera/src/features/workbench/presentation/terminal_runtime.dart'; import 'package:alera/src/features/workbench/presentation/widgets/agent_run_spinner_scope.dart'; +import 'package:alera/src/features/inbox/presentation/inbox_composer_dialog.dart'; import 'package:alera/src/features/workbench/presentation/project_workbench_sidebar.dart'; import 'package:alera/src/features/workbench/presentation/widgets/workspace_agent_compact_summary.dart'; import 'package:alera/src/features/workbench/presentation/workspace_panel_view.dart'; @@ -112,6 +114,7 @@ Future<_ShellPumpHarness> _pumpShell( RuntimeProjectHostsClient? projectHostsClient, Stream? workspaceFocusRequests, AgentNotificationWindowActivator? windowActivator, + InboxTestClient? inboxClient, }) async { final shellController = controller ?? _ShellTestWorkbenchController(state); final runtime = terminalRuntime ?? _FakeTerminalRuntime(); @@ -127,6 +130,9 @@ Future<_ShellPumpHarness> _pumpShell( overrides: [ if (boardRepository != null) runBoardRepositoryProvider.overrideWithValue(boardRepository), + ...inboxClientOverrides( + inboxClient ?? (InboxTestClient()..supported = false), + ), aleraDatabaseProvider.overrideWith((ref) async => db), workbenchControllerProvider.overrideWith(() => shellController), agentProfilesProvider.overrideWith(() => _ShellAgentProfiles()), diff --git a/test/widget/app_menu_button_test.dart b/test/widget/app_menu_button_test.dart index b3beed1f8..ea3af0faa 100644 --- a/test/widget/app_menu_button_test.dart +++ b/test/widget/app_menu_button_test.dart @@ -1,6 +1,7 @@ import 'package:alera/src/app/theme/alera_dark_theme.dart'; import 'package:alera/src/core/build_flavor.dart'; import 'package:alera/src/features/app_menu/presentation/alera_app_menu_scope.dart'; +import 'package:alera/src/features/inbox/application/inbox_navigation.dart'; import 'package:alera/src/features/orchestration/application/run_board_navigation.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -37,6 +38,22 @@ void main() { }); } + testWidgets('opens the inbox and closes the Run Board', (tester) async { + await _withPlatform(.linux, () async { + await _pumpButton(tester); + final container = ProviderScope.containerOf( + tester.element(find.byType(AleraAppMenuButton)), + ); + container.read(runBoardNavigationProvider.notifier).open(); + await tester.tap(find.byTooltip('Application Menu')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Inbox')); + await tester.pumpAndSettle(); + expect(container.read(inboxNavigationProvider).visible, isTrue); + expect(container.read(runBoardNavigationProvider).visible, isFalse); + }); + }); + testWidgets('stays out of the window on macOS', (tester) async { await _withPlatform(.macOS, () async { await _pumpButton(tester); diff --git a/test/widget/inbox_acknowledgement_test.dart b/test/widget/inbox_acknowledgement_test.dart new file mode 100644 index 000000000..738e88c34 --- /dev/null +++ b/test/widget/inbox_acknowledgement_test.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import 'package:alera/src/app/theme/alera_dark_theme.dart'; +import 'package:alera/src/features/app_window/domain/app_foreground.dart'; +import 'package:alera/src/features/inbox/application/inbox_providers.dart'; +import 'package:alera/src/features/inbox/presentation/inbox_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/inbox_test_client.dart'; + +class _WindowFocus implements AppForeground { + _WindowFocus(this._focused); + + bool _focused; + final StreamController _changes = StreamController.broadcast(); + + set focused(bool value) { + _focused = value; + _changes.add(value); + } + + @override + bool get isForeground => _focused; + + @override + Stream get changes => _changes.stream; + + @override + void dispose() => unawaited(_changes.close()); +} + +InboxTestClient _client({int unread = 1}) => InboxTestClient() + ..respond('inbox.summary', { + 'items': [ + {'inbox': 'ext:user', 'threadCount': 1, 'unreadReplyCount': unread}, + ], + }) + ..respond('inbox.threads', { + 'items': [inboxThreadJson(unread: unread)], + }) + ..respond('inbox.thread', { + ...inboxDetailJson(), + 'thread': inboxThreadJson(unread: unread), + }); + +Future _pumpPage( + WidgetTester tester, + InboxTestClient client, + _WindowFocus focus, +) async { + tester.view.physicalSize = const Size(1600, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = ProviderContainer( + overrides: [ + ...inboxClientOverrides(client), + inboxWindowFocusProvider.overrideWithValue(focus), + ], + ); + addTearDown(container.dispose); + addTearDown(client.events.close); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: buildAleraDarkTheme(), + home: const Scaffold(body: InboxPage()), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Migration risk')); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('a thread refreshed in an unfocused window stays unread', ( + tester, + ) async { + final client = _client(); + final focus = _WindowFocus(false); + await _pumpPage(tester, client, focus); + expect(client.callsTo('inbox.thread').last['markRead'], isFalse); + client.emit('inboxChanged'); + await tester.pumpAndSettle(); + expect(client.callsTo('inbox.markRead'), isEmpty); + + focus.focused = true; + await tester.pumpAndSettle(); + expect(client.callsTo('inbox.markRead'), [ + {'threadId': 'msg_q1'}, + ]); + // The same unread state is not acknowledged twice. + focus.focused = false; + focus.focused = true; + await tester.pumpAndSettle(); + expect(client.callsTo('inbox.markRead'), hasLength(1)); + }); + + testWidgets('a thread opened in a focused window is acknowledged', ( + tester, + ) async { + final client = _client(); + await _pumpPage(tester, client, _WindowFocus(true)); + expect(client.callsTo('inbox.markRead'), [ + {'threadId': 'msg_q1'}, + ]); + }); +} diff --git a/test/widget/inbox_page_test.dart b/test/widget/inbox_page_test.dart new file mode 100644 index 000000000..334736d9d --- /dev/null +++ b/test/widget/inbox_page_test.dart @@ -0,0 +1,243 @@ +import 'package:alera/src/app/theme/alera_dark_theme.dart'; +import 'package:alera/src/features/inbox/application/inbox_navigation.dart'; +import 'package:alera/src/features/inbox/presentation/inbox_attention_control.dart'; +import 'package:alera/src/features/inbox/presentation/inbox_composer_dialog.dart'; +import 'package:alera/src/features/inbox/presentation/inbox_page.dart'; +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/inbox_test_client.dart'; + +InboxTestClient _client() => InboxTestClient() + ..respond('inbox.summary', { + 'items': [ + {'inbox': 'ext:user', 'threadCount': 2, 'unreadReplyCount': 1}, + {'inbox': 'ext:ci', 'threadCount': 1, 'unreadReplyCount': 0}, + ], + 'revision': 3, + }) + ..respond('inbox.threads', { + 'items': [ + inboxThreadJson(status: 'answered', unread: 1), + inboxThreadJson( + threadId: 'msg_q2', + status: 'pending', + subject: 'Rollback plan', + ), + ], + 'revision': 3, + }) + ..respond('inbox.targets', { + 'items': [ + { + 'handle': 'term-1', + 'sessionLive': true, + 'workspaceId': 'ws-1', + 'agent': 'claude', + 'deliveryMode': 'paste', + 'tabTitle': 'Fix Login', + }, + ], + }); + +Future _pumpPage( + WidgetTester tester, + InboxTestClient client, { + Widget child = const InboxPage(), +}) async { + tester.view.physicalSize = const Size(1600, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = ProviderContainer(overrides: inboxClientOverrides(client)); + addTearDown(container.dispose); + addTearDown(client.events.close); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: buildAleraDarkTheme(), + home: Scaffold(body: child), + ), + ), + ); + await tester.pumpAndSettle(); + return container; +} + +void main() { + testWidgets('selecting a thread shows its messages', (tester) async { + final client = _client() + ..respond( + 'inbox.thread', + inboxDetailJson( + status: 'answered', + messages: [ + inboxMessageJson( + kind: 'question', + id: 'msg_q1', + body: 'What could break?', + status: 'answered', + ), + inboxMessageJson( + kind: 'reply', + id: 'msg_r1', + body: 'The session cache.', + from: 'term-1', + to: 'ext:user', + ), + ], + ), + ); + await _pumpPage(tester, client); + expect(find.text('Migration risk'), findsOneWidget); + expect(find.text('Rollback plan'), findsOneWidget); + expect(find.text('Select A Question'), findsOneWidget); + + await tester.tap(find.text('Migration risk')); + await tester.pumpAndSettle(); + expect(find.text('The session cache.'), findsOneWidget); + expect(find.text('Question from ext:user'), findsOneWidget); + expect(find.textContaining('via Alera mobile, Pixel'), findsOneWidget); + expect(client.callsTo('inbox.thread').last, { + 'threadId': 'msg_q1', + 'markRead': false, + }); + expect(find.text('Cancel Question'), findsNothing); + + await tester.enterText( + find.byKey(const ValueKey('inboxFollowUpBody')), + 'And the rollback?', + ); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('inboxFollowUpSend'))); + await tester.pumpAndSettle(); + expect(client.callsTo('inbox.ask').single, { + 'body': 'And the rollback?', + 'threadId': 'msg_q1', + }); + }); + + testWidgets('a pending question can be cancelled and explains delivery', ( + tester, + ) async { + final client = _client() + ..respond( + 'inbox.thread', + inboxDetailJson(status: 'pending', deliveryMode: 'unavailable'), + ); + await _pumpPage(tester, client); + await tester.tap(find.text('Rollback plan')); + await tester.pumpAndSettle(); + expect(find.textContaining('does not report its turns'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('inboxCancelQuestion'))); + await tester.pumpAndSettle(); + expect(client.callsTo('inbox.cancel').single, {'questionId': 'msg_q1'}); + }); + + testWidgets('the composer asks the chosen agent and reports host errors', ( + tester, + ) async { + final client = _client(); + var attempts = 0; + client.handlers['inbox.ask'] = (_) { + attempts++; + if (attempts == 1) { + throw const TerminalHostConflictException( + code: 'inbox_pending_limit', + message: 'limit', + ); + } + return {'threadId': 'msg_new'}; + }; + final container = await _pumpPage(tester, client); + await tester.tap(find.byKey(const ValueKey('inboxNewQuestion'))); + await tester.pumpAndSettle(); + expect(find.byType(InboxComposerDialog), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('inboxComposerTarget'))); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining('Claude: Fix Login').last); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('inboxComposerBody')), + 'Which tests cover login?', + ); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('inboxComposerSend'))); + await tester.pumpAndSettle(); + expect( + find.textContaining('already has 20 questions waiting'), + findsOneWidget, + ); + + await tester.tap(find.byKey(const ValueKey('inboxComposerSend'))); + await tester.pumpAndSettle(); + expect(find.byType(InboxComposerDialog), findsNothing); + expect(client.callsTo('inbox.ask').last, { + 'body': 'Which tests cover login?', + 'inbox': 'ext:user', + 'to': 'term-1', + }); + expect(container.read(inboxNavigationProvider).selectedThreadId, 'msg_new'); + }); + + testWidgets('purging the filtered inbox asks for confirmation', ( + tester, + ) async { + final client = _client(); + final container = await _pumpPage(tester, client); + expect(find.byKey(const ValueKey('inboxPurge')), findsNothing); + container.read(inboxNavigationProvider.notifier).filterInbox('ext:ci'); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('inboxPurge'))); + await tester.pumpAndSettle(); + expect(find.text('Purge Inbox?'), findsOneWidget); + await tester.tap(find.text('Purge')); + await tester.pumpAndSettle(); + expect(client.callsTo('inbox.purge').single, {'inbox': 'ext:ci'}); + expect(container.read(inboxNavigationProvider).inboxFilter, isNull); + }); + + testWidgets('an old runtime shows an update notice', (tester) async { + final client = _client()..supported = false; + await _pumpPage(tester, client); + expect(find.text('Update the runtime to use the inbox.'), findsOneWidget); + final button = tester.widget( + find.ancestor( + of: find.text('New Question'), + matching: find.byWidgetPredicate((widget) => widget is FilledButton), + ), + ); + expect(button.onPressed, isNull); + expect(client.calls, isEmpty); + }); + + testWidgets('the status bar control counts unread replies and opens', ( + tester, + ) async { + final client = _client(); + final container = await _pumpPage( + tester, + client, + child: const Align( + alignment: Alignment.bottomLeft, + child: InboxAttentionControl(), + ), + ); + expect(find.byTooltip('Open Inbox · 1 Unread Reply'), findsOneWidget); + await tester.tap(find.byType(TextButton)); + await tester.pump(); + expect(container.read(inboxNavigationProvider).visible, isTrue); + }); + + testWidgets('the status bar control hides on an old runtime', (tester) async { + await _pumpPage( + tester, + _client()..supported = false, + child: const InboxAttentionControl(), + ); + expect(find.byType(TextButton), findsNothing); + }); +} diff --git a/test/widget/inbox_paging_test.dart b/test/widget/inbox_paging_test.dart new file mode 100644 index 000000000..7f458ed41 --- /dev/null +++ b/test/widget/inbox_paging_test.dart @@ -0,0 +1,114 @@ +import 'package:alera/src/app/theme/alera_dark_theme.dart'; +import 'package:alera/src/features/inbox/application/inbox_navigation.dart'; +import 'package:alera/src/features/inbox/presentation/inbox_page.dart'; +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/inbox_test_client.dart'; + +Future _pump( + WidgetTester tester, + InboxTestClient client, +) async { + tester.view.physicalSize = const Size(1600, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = ProviderContainer(overrides: inboxClientOverrides(client)); + addTearDown(container.dispose); + addTearDown(client.events.close); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: buildAleraDarkTheme(), + home: const Scaffold(body: InboxPage()), + ), + ), + ); + await tester.pumpAndSettle(); + return container; +} + +void main() { + testWidgets('Load More merges the next page and a refresh starts over', ( + tester, + ) async { + final client = InboxTestClient() + ..respond('inbox.summary', const {'items': []}); + client.handlers['inbox.threads'] = (payload) => payload['before'] == 5 + ? { + 'items': [ + inboxThreadJson(subject: 'Newest'), + inboxThreadJson(threadId: 'msg_old', subject: 'Oldest'), + ], + 'nextBefore': null, + } + : { + 'items': [inboxThreadJson(subject: 'Newest')], + 'nextBefore': 5, + }; + final container = await _pump(tester, client); + expect(find.text('Newest'), findsOneWidget); + expect(find.text('Oldest'), findsNothing); + + await tester.tap(find.text('Load More')); + await tester.pumpAndSettle(); + expect(client.callsTo('inbox.threads').last['before'], 5); + expect(find.text('Newest'), findsOneWidget); + expect(find.text('Oldest'), findsOneWidget); + expect(find.text('Load More'), findsNothing); + + client.emit('inboxChanged'); + await tester.pumpAndSettle(); + expect(find.text('Oldest'), findsNothing); + expect(find.text('Load More'), findsOneWidget); + + await tester.tap(find.text('Load More')); + await tester.pumpAndSettle(); + expect(find.text('Oldest'), findsOneWidget); + container.read(inboxNavigationProvider.notifier).filterInbox('ext:ci'); + await tester.pumpAndSettle(); + expect(client.callsTo('inbox.threads').last, { + 'inbox': 'ext:ci', + 'limit': 100, + }); + expect(find.text('Oldest'), findsNothing); + }); + + testWidgets('a follow-up keeps the inbox of a CLI thread', (tester) async { + final client = InboxTestClient() + ..respond('inbox.summary', const {'items': []}) + ..respond('inbox.threads', { + 'items': [inboxThreadJson(inbox: 'ext:ci')], + }) + ..respond('inbox.thread', { + ...inboxDetailJson(), + 'thread': inboxThreadJson(inbox: 'ext:ci'), + }); + // Like the host: a follow-up must stay in the thread's own inbox. + client.handlers['inbox.ask'] = (payload) { + final inbox = payload['inbox']; + if (inbox != null && inbox != 'ext:ci') { + throw const TerminalHostConflictException( + code: 'inbox_thread_mismatch', + message: 'mismatch', + ); + } + return {'threadId': 'msg_q1'}; + }; + await _pump(tester, client); + await tester.tap(find.text('Migration risk')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('inboxFollowUpBody')), + 'And the rollback?', + ); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('inboxFollowUpSend'))); + await tester.pumpAndSettle(); + expect(client.callsTo('inbox.ask').single.containsKey('inbox'), isFalse); + expect(find.textContaining('same agent and inbox'), findsNothing); + }); +}