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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/src/design_system/icons/alera_icons.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ enum _AppMenuAction {
openRunBoard,
openSettings,
openAutomations,
openInbox,
reviewExecutionPlans,
checkForUpdates,
undo,
Expand Down Expand Up @@ -137,6 +138,7 @@ class _AleraAppMenuButtonState extends ConsumerState<AleraAppMenuButton> {
value: .openAutomations,
label: 'Automations',
),
AleraDropdownEntry<_AppMenuAction>(value: .openInbox, label: 'Inbox'),
AleraDropdownEntry<_AppMenuAction>(
value: .reviewExecutionPlans,
label: 'Execution Plans',
Expand Down Expand Up @@ -181,6 +183,8 @@ class _AleraAppMenuButtonState extends ConsumerState<AleraAppMenuButton> {
await openAppMenuSettings(context);
case _AppMenuAction.openAutomations:
openAutomationsPage(ref);
case _AppMenuAction.openInbox:
openInboxFromAppMenu(ref);
case _AppMenuAction.reviewExecutionPlans:
await showRunPolicyReviewDialog(context);
case _AppMenuAction.checkForUpdates:
Expand Down Expand Up @@ -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: () {
Expand Down
4 changes: 4 additions & 0 deletions lib/src/features/app_menu/presentation/app_menu_actions.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,6 +20,9 @@ typedef AppMenuPackageInfoLoader = Future<PackageInfo> Function();
void openRunBoardFromAppMenu(WidgetRef ref) =>
ref.read(runBoardNavigationProvider.notifier).open();

void openInboxFromAppMenu(WidgetRef ref) =>
ref.read(inboxNavigationProvider.notifier).open();

Future<void> openAppMenuSettings(BuildContext context) {
return openSettingsDialog(context);
}
Expand Down
18 changes: 14 additions & 4 deletions lib/src/features/app_window/infra/lifecycle_app_foreground.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 74 additions & 0 deletions lib/src/features/inbox/application/inbox_navigation.dart
Original file line number Diff line number Diff line change
@@ -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);
}
62 changes: 62 additions & 0 deletions lib/src/features/inbox/application/inbox_navigation.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

101 changes: 101 additions & 0 deletions lib/src/features/inbox/application/inbox_notifications.dart
Original file line number Diff line number Diff line change
@@ -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(<String, String>{'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<void>? initializing;

Future<void> 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);
}
Loading
Loading