Skip to content

Commit 2bd07b4

Browse files
committed
feat: show agent conversations in the desktop inbox
1 parent 2877a96 commit 2bd07b4

18 files changed

Lines changed: 1223 additions & 26 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import 'package:alera/src/features/agent_status/application/agent_status_controller.dart';
2+
import 'package:alera/src/features/agent_status/presentation/agent_identity_icon.dart';
3+
import 'package:alera/src/features/workbench/application/workbench_controller.dart';
4+
import 'package:alera/src/features/workbench/domain/workspace_tab_record.dart';
5+
import 'package:flutter_riverpod/flutter_riverpod.dart';
6+
import 'package:riverpod_annotation/riverpod_annotation.dart';
7+
8+
part 'conversation_participant_labels.g.dart';
9+
10+
/// Readable names for terminal handles: the tab title and agent the sidebar
11+
/// knows. A handle missing from the sidebar keeps its raw id.
12+
@riverpod
13+
Map<String, String> conversationParticipantLabels(Ref ref) {
14+
final tabsByWorkspace = ref.watch(
15+
workbenchControllerProvider.select((state) => state.tabsByWorkspace),
16+
);
17+
final statuses = ref.watch(agentStatusControllerProvider);
18+
return <String, String>{
19+
for (final tabs in tabsByWorkspace.values)
20+
for (final tab in tabs)
21+
if (tab.kind == WorkspaceTabKind.terminal)
22+
tab.terminalSessionId: _label(
23+
tab.title.trim(),
24+
statuses[tab.terminalSessionId] == null
25+
? null
26+
: agentDisplayName(statuses[tab.terminalSessionId]!.agentType),
27+
),
28+
};
29+
}
30+
31+
/// Workspace names by id, for the conversation filter and rows.
32+
@riverpod
33+
Map<String, String> conversationWorkspaceNames(Ref ref) => ref.watch(
34+
workbenchControllerProvider.select(
35+
(state) => <String, String>{
36+
for (final workspaces in state.workspacesByProject.values)
37+
for (final workspace in workspaces) workspace.id: workspace.name,
38+
},
39+
),
40+
);
41+
42+
String participantLabel(Map<String, String> labels, String handle) =>
43+
labels[handle] ?? handle;
44+
45+
String _label(String title, String? agent) {
46+
if (agent == null) return title.isEmpty ? 'Terminal' : title;
47+
return title.isEmpty ? agent : '$title ($agent)';
48+
}

‎lib/src/features/inbox/application/conversation_participant_labels.g.dart‎

Lines changed: 120 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎lib/src/features/inbox/application/inbox_navigation.dart‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,14 @@ part 'inbox_navigation.g.dart';
88
/// A request to open the composer, optionally with a recipient chosen.
99
class const InboxComposeRequest({final String? targetHandle});
1010

11+
/// Which half of the inbox page is shown.
12+
enum InboxView { questions, conversations }
13+
1114
class const InboxLocation({
1215
final bool visible = false,
16+
final InboxView view = InboxView.questions,
17+
final String? conversationWorkspace,
18+
final String? selectedConversationId,
1319
final String? inboxFilter,
1420
final InboxQuestionStatus? statusFilter,
1521
final String? selectedThreadId,
@@ -18,13 +24,23 @@ class const InboxLocation({
1824
}) {
1925
InboxLocation copyWith({
2026
bool? visible,
27+
InboxView? view,
28+
String? Function()? conversationWorkspace,
29+
String? Function()? selectedConversationId,
2130
String? Function()? inboxFilter,
2231
InboxQuestionStatus? Function()? statusFilter,
2332
String? Function()? selectedThreadId,
2433
InboxComposeRequest? Function()? compose,
2534
int? composeSequence,
2635
}) => InboxLocation(
2736
visible: visible ?? this.visible,
37+
view: view ?? this.view,
38+
conversationWorkspace: conversationWorkspace == null
39+
? this.conversationWorkspace
40+
: conversationWorkspace(),
41+
selectedConversationId: selectedConversationId == null
42+
? this.selectedConversationId
43+
: selectedConversationId(),
2844
inboxFilter: inboxFilter == null ? this.inboxFilter : inboxFilter(),
2945
statusFilter: statusFilter == null ? this.statusFilter : statusFilter(),
3046
selectedThreadId: selectedThreadId == null
@@ -50,8 +66,17 @@ class InboxNavigation extends _$InboxNavigation {
5066

5167
void close() => state = state.copyWith(visible: false);
5268

53-
void selectThread(String? threadId) =>
54-
state = state.copyWith(selectedThreadId: () => threadId);
69+
/// A thread is a question, so selecting one always shows Questions.
70+
void selectThread(String? threadId) => state = state.copyWith(
71+
view: InboxView.questions,
72+
selectedThreadId: () => threadId,
73+
);
74+
75+
/// Opens the page on Questions, where replies are read.
76+
void openQuestions() {
77+
open();
78+
state = state.copyWith(view: InboxView.questions);
79+
}
5580

5681
void filterInbox(String? inbox) => state = state.copyWith(
5782
inboxFilter: () => inbox,
@@ -63,12 +88,23 @@ class InboxNavigation extends _$InboxNavigation {
6388

6489
/// Opens the page and asks it to show the composer once mounted.
6590
void compose({String? targetHandle}) {
66-
open();
91+
openQuestions();
6792
state = state.copyWith(
6893
compose: () => InboxComposeRequest(targetHandle: targetHandle),
6994
composeSequence: state.composeSequence + 1,
7095
);
7196
}
7297

7398
void consumeCompose() => state = state.copyWith(compose: () => null);
99+
100+
void showView(InboxView view) => state = state.copyWith(view: view);
101+
102+
void selectConversation(String? threadId) =>
103+
state = state.copyWith(selectedConversationId: () => threadId);
104+
105+
void filterConversationWorkspace(String? workspaceId) =>
106+
state = state.copyWith(
107+
conversationWorkspace: () => workspaceId,
108+
selectedConversationId: () => null,
109+
);
74110
}

‎lib/src/features/inbox/application/inbox_navigation.g.dart‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎lib/src/features/inbox/application/inbox_notifications.dart‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ void inboxReplyNotificationCoordinator(Ref ref) {
7070
if (!isInboxNotificationPayload(payload)) return;
7171
unawaited(
7272
windowActivator.showAndFocus().then((_) {
73-
if (ref.mounted) ref.read(inboxNavigationProvider.notifier).open();
73+
if (ref.mounted) {
74+
ref.read(inboxNavigationProvider.notifier).openQuestions();
75+
}
7476
}),
7577
);
7678
},

‎lib/src/features/inbox/application/inbox_notifications.g.dart‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎lib/src/features/inbox/application/inbox_providers.dart‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:alera/src/features/app_window/domain/app_foreground.dart';
22
import 'package:alera/src/features/app_window/infra/lifecycle_app_foreground.dart';
3+
import 'package:alera/src/features/inbox/domain/conversation_models.dart';
34
import 'package:alera/src/features/inbox/domain/inbox_models.dart';
45
import 'package:alera/src/features/inbox/infra/runtime_inbox_repository.dart';
56
import 'package:alera/src/shared/infra/runtime/runtime_host_providers.dart';
@@ -52,3 +53,13 @@ AppForeground inboxWindowFocus(Ref ref) {
5253
ref.onDispose(focus.dispose);
5354
return focus;
5455
}
56+
57+
@Riverpod(retry: _noInboxRetry)
58+
Stream<ConversationPage> agentConversations(Ref ref, {String? workspaceId}) =>
59+
ref
60+
.watch(inboxRepositoryProvider)
61+
.watchConversations(workspaceId: workspaceId);
62+
63+
@Riverpod(retry: _noInboxRetry)
64+
Stream<ConversationDetail> agentConversation(Ref ref, String threadId) =>
65+
ref.watch(inboxRepositoryProvider).watchConversation(threadId);

0 commit comments

Comments
 (0)