mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
[codex] Add mobile agent activity viewer (#436)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,8 @@ PODS:
|
||||
- mobile_scanner (7.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -24,6 +26,7 @@ DEPENDENCIES:
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
|
||||
@@ -39,6 +42,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
mobile_scanner:
|
||||
:path: ".symlinks/plugins/mobile_scanner/darwin"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
url_launcher_ios:
|
||||
@@ -52,6 +57,7 @@ SPEC CHECKSUMS:
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import 'features/home/home_page.dart';
|
||||
import 'features/pairing/pairing_page.dart';
|
||||
import 'features/channels/agent_activity/observer_subscription.dart';
|
||||
import 'shared/auth/auth.dart';
|
||||
import 'shared/relay/relay.dart';
|
||||
import 'shared/theme/theme.dart';
|
||||
@@ -30,6 +31,7 @@ class App extends HookConsumerWidget {
|
||||
// authenticated. These providers connect and manage the websocket.
|
||||
if (authState.value?.status == AuthStatus.authenticated) {
|
||||
ref.watch(relaySessionProvider);
|
||||
ref.watch(observerRelayProvider);
|
||||
ref.watch(appLifecycleProvider);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../shared/theme/theme.dart';
|
||||
import '../../profile/user_cache_provider.dart';
|
||||
import '../date_formatters.dart';
|
||||
import 'observer_models.dart';
|
||||
import 'observer_subscription.dart';
|
||||
import 'transcript_item_widget.dart';
|
||||
|
||||
/// Full-screen modal bottom sheet showing the live agent activity transcript.
|
||||
class AgentActivitySheet extends HookConsumerWidget {
|
||||
final String channelId;
|
||||
final String agentPubkey;
|
||||
|
||||
const AgentActivitySheet({
|
||||
super.key,
|
||||
required this.channelId,
|
||||
required this.agentPubkey,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final observerState = ref.watch(
|
||||
observerSubscriptionProvider((
|
||||
channelId: channelId,
|
||||
agentPubkey: agentPubkey,
|
||||
)),
|
||||
);
|
||||
final transcript = observerState.transcript;
|
||||
final connection = observerState.connection;
|
||||
|
||||
// Resolve bot name.
|
||||
final profile = ref.watch(
|
||||
userCacheProvider.select((cache) => cache[agentPubkey.toLowerCase()]),
|
||||
);
|
||||
final botName = profile?.label ?? shortPubkey(agentPubkey);
|
||||
|
||||
// Auto-scroll to bottom on new items.
|
||||
final sheetControllerRef = useRef<ScrollController?>(null);
|
||||
final previousLength = useRef(0);
|
||||
|
||||
useEffect(() {
|
||||
final sc = sheetControllerRef.value;
|
||||
if (transcript.length > previousLength.value &&
|
||||
sc != null &&
|
||||
sc.hasClients) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (sc.hasClients) {
|
||||
sc.animateTo(
|
||||
sc.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
previousLength.value = transcript.length;
|
||||
return null;
|
||||
}, [transcript.length]);
|
||||
|
||||
// Preload the bot profile.
|
||||
useEffect(() {
|
||||
ref.read(userCacheProvider.notifier).preload([agentPubkey]);
|
||||
return null;
|
||||
}, [agentPubkey]);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.9,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (context, sheetScrollController) {
|
||||
sheetControllerRef.value = sheetScrollController;
|
||||
final bottomPadding =
|
||||
MediaQuery.viewPaddingOf(context).bottom + Grid.sm;
|
||||
return Column(
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.bot,
|
||||
size: 18,
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
botName,
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
_ConnectionBadge(connection: connection),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
Text(
|
||||
'Showing live activity from this point.',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Divider(color: context.colors.outlineVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Transcript list
|
||||
Expanded(
|
||||
child: transcript.isEmpty
|
||||
? Padding(
|
||||
padding: EdgeInsets.only(bottom: bottomPadding),
|
||||
child: _EmptyState(
|
||||
connection: connection,
|
||||
errorMessage: observerState.errorMessage,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: sheetScrollController,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.xs,
|
||||
Grid.xxs,
|
||||
Grid.xs,
|
||||
bottomPadding,
|
||||
),
|
||||
itemCount: transcript.length,
|
||||
itemBuilder: (context, index) {
|
||||
return TranscriptItemWidget(item: transcript[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
final ObserverConnectionState connection;
|
||||
final String? errorMessage;
|
||||
|
||||
const _EmptyState({required this.connection, this.errorMessage});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (connection == ObserverConnectionState.error) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.circleX, size: 24, color: context.colors.error),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
'Error: ${errorMessage ?? 'Unknown error'}',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (connection == ObserverConnectionState.idle) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Not connected',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// connecting or open — show spinner
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
'Waiting for activity\u2026',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectionBadge extends StatelessWidget {
|
||||
final ObserverConnectionState connection;
|
||||
|
||||
const _ConnectionBadge({required this.connection});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (color, label) = switch (connection) {
|
||||
ObserverConnectionState.idle => (context.colors.outline, 'Idle'),
|
||||
ObserverConnectionState.connecting => (
|
||||
context.appColors.warning,
|
||||
'Connecting',
|
||||
),
|
||||
ObserverConnectionState.open => (context.appColors.success, 'Live'),
|
||||
ObserverConnectionState.error => (context.colors.error, 'Error'),
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xxs,
|
||||
vertical: Grid.quarter,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
label,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Connection state for the observer relay subscription.
|
||||
enum ObserverConnectionState { idle, connecting, open, error }
|
||||
|
||||
/// Status of a tool execution.
|
||||
enum ToolStatus { executing, completed, failed, pending }
|
||||
|
||||
/// A decrypted observer frame from a kind:24200 event.
|
||||
@immutable
|
||||
class ObserverFrame {
|
||||
final int seq;
|
||||
final String timestamp;
|
||||
final String kind;
|
||||
final int? agentIndex;
|
||||
final String? channelId;
|
||||
final String? sessionId;
|
||||
final String? turnId;
|
||||
final dynamic payload;
|
||||
|
||||
const ObserverFrame({
|
||||
required this.seq,
|
||||
required this.timestamp,
|
||||
required this.kind,
|
||||
this.agentIndex,
|
||||
this.channelId,
|
||||
this.sessionId,
|
||||
this.turnId,
|
||||
this.payload,
|
||||
});
|
||||
|
||||
factory ObserverFrame.fromJson(Map<String, dynamic> json) => ObserverFrame(
|
||||
seq: json['seq'] as int? ?? 0,
|
||||
timestamp: json['timestamp'] as String? ?? '',
|
||||
kind: json['kind'] as String? ?? '',
|
||||
agentIndex: json['agentIndex'] as int?,
|
||||
channelId: json['channelId'] as String?,
|
||||
sessionId: json['sessionId'] as String?,
|
||||
turnId: json['turnId'] as String?,
|
||||
payload: json['payload'],
|
||||
);
|
||||
}
|
||||
|
||||
/// A section within prompt context metadata.
|
||||
@immutable
|
||||
class PromptSection {
|
||||
final String title;
|
||||
final String body;
|
||||
|
||||
const PromptSection({required this.title, required this.body});
|
||||
}
|
||||
|
||||
/// A single item in the agent activity transcript.
|
||||
sealed class TranscriptItem {
|
||||
String get id;
|
||||
String get timestamp;
|
||||
}
|
||||
|
||||
class MessageItem extends TranscriptItem {
|
||||
@override
|
||||
final String id;
|
||||
final String role;
|
||||
final String title;
|
||||
String text;
|
||||
@override
|
||||
final String timestamp;
|
||||
|
||||
MessageItem({
|
||||
required this.id,
|
||||
required this.role,
|
||||
required this.title,
|
||||
required this.text,
|
||||
required this.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
class ThoughtItem extends TranscriptItem {
|
||||
@override
|
||||
final String id;
|
||||
final String title;
|
||||
String text;
|
||||
@override
|
||||
final String timestamp;
|
||||
|
||||
ThoughtItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.text,
|
||||
required this.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
class LifecycleItem extends TranscriptItem {
|
||||
@override
|
||||
final String id;
|
||||
final String title;
|
||||
String text;
|
||||
@override
|
||||
final String timestamp;
|
||||
|
||||
LifecycleItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.text,
|
||||
required this.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
class MetadataItem extends TranscriptItem {
|
||||
@override
|
||||
final String id;
|
||||
final String title;
|
||||
List<PromptSection> sections;
|
||||
@override
|
||||
final String timestamp;
|
||||
|
||||
MetadataItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.sections,
|
||||
required this.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
class ToolItem extends TranscriptItem {
|
||||
@override
|
||||
final String id;
|
||||
String title;
|
||||
String toolName;
|
||||
String? sproutToolName;
|
||||
ToolStatus status;
|
||||
Map<String, dynamic> args;
|
||||
String result;
|
||||
bool isError;
|
||||
@override
|
||||
final String timestamp;
|
||||
|
||||
ToolItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.toolName,
|
||||
this.sproutToolName,
|
||||
required this.status,
|
||||
required this.args,
|
||||
required this.result,
|
||||
required this.isError,
|
||||
required this.timestamp,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../../../shared/crypto/nip44.dart';
|
||||
import '../../../shared/relay/relay.dart';
|
||||
import 'observer_models.dart';
|
||||
import 'transcript_builder.dart';
|
||||
|
||||
/// Maximum observer events to keep per agent.
|
||||
const _maxObserverEvents = 800;
|
||||
|
||||
/// Key for channel-scoped transcript reads.
|
||||
typedef ObserverKey = ({String channelId, String agentPubkey});
|
||||
|
||||
/// State emitted by the channel-scoped observer transcript provider.
|
||||
@immutable
|
||||
class ObserverState {
|
||||
final ObserverConnectionState connection;
|
||||
final List<TranscriptItem> transcript;
|
||||
final String? errorMessage;
|
||||
|
||||
const ObserverState({
|
||||
required this.connection,
|
||||
required this.transcript,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
const ObserverState.initial()
|
||||
: connection = ObserverConnectionState.idle,
|
||||
transcript = const [],
|
||||
errorMessage = null;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ObserverRelayState {
|
||||
final ObserverConnectionState connection;
|
||||
final Map<String, List<ObserverFrame>> framesByAgent;
|
||||
final String? errorMessage;
|
||||
|
||||
const ObserverRelayState({
|
||||
required this.connection,
|
||||
required this.framesByAgent,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
const ObserverRelayState.initial()
|
||||
: connection = ObserverConnectionState.idle,
|
||||
framesByAgent = const {},
|
||||
errorMessage = null;
|
||||
}
|
||||
|
||||
class ObserverRelayNotifier extends Notifier<ObserverRelayState> {
|
||||
final Map<String, List<ObserverFrame>> _framesByAgent = {};
|
||||
final Map<String, Set<String>> _dedupeKeysByAgent = {};
|
||||
final Map<String, Uint8List> _conversationKeysByAgent = {};
|
||||
|
||||
void Function()? _unsubscribe;
|
||||
Future<void>? _startFuture;
|
||||
String? _privHex;
|
||||
String? _ownerPubkey;
|
||||
String? _identityKey;
|
||||
String? _errorMessage;
|
||||
int _subscriptionEpoch = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
ObserverRelayState build() {
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
final sessionState = ref.watch(relaySessionProvider);
|
||||
final identityKey = '${config.baseUrl}|${config.nsec ?? ''}';
|
||||
|
||||
_disposed = false;
|
||||
if (_identityKey != null && _identityKey != identityKey) {
|
||||
_reset();
|
||||
}
|
||||
_identityKey = identityKey;
|
||||
|
||||
ref.onDispose(() {
|
||||
_disposed = true;
|
||||
_reset();
|
||||
});
|
||||
|
||||
if (sessionState.status == SessionStatus.connected) {
|
||||
Future.microtask(_ensureSubscribed);
|
||||
}
|
||||
|
||||
final hasSigningKey = config.nsec?.isNotEmpty == true;
|
||||
return ObserverRelayState(
|
||||
connection: hasSigningKey
|
||||
? _connectionForSession(sessionState.status)
|
||||
: ObserverConnectionState.idle,
|
||||
framesByAgent: _snapshotFrames(),
|
||||
errorMessage: _errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _ensureSubscribed() {
|
||||
if (_disposed) return Future.value();
|
||||
if (_unsubscribe != null) return Future.value();
|
||||
if (_startFuture != null) return _startFuture!;
|
||||
|
||||
_startFuture = _subscribe(_subscriptionEpoch);
|
||||
return _startFuture!;
|
||||
}
|
||||
|
||||
Future<void> _subscribe(int epoch) async {
|
||||
try {
|
||||
if (_disposed || epoch != _subscriptionEpoch) {
|
||||
return;
|
||||
}
|
||||
|
||||
final config = ref.read(relayConfigProvider);
|
||||
final nsec = config.nsec;
|
||||
if (nsec == null || nsec.isEmpty) {
|
||||
_errorMessage = null;
|
||||
_emit(connection: ObserverConnectionState.idle);
|
||||
return;
|
||||
}
|
||||
|
||||
final privHex = _decodePrivkey(nsec);
|
||||
final ownerPubkey = _derivePubkey(privHex);
|
||||
if (_disposed || epoch != _subscriptionEpoch) {
|
||||
return;
|
||||
}
|
||||
_privHex = privHex;
|
||||
_ownerPubkey = ownerPubkey;
|
||||
_errorMessage = null;
|
||||
_emit(connection: ObserverConnectionState.connecting);
|
||||
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final unsubscribe = await session.subscribe(
|
||||
NostrFilter(
|
||||
kinds: [EventKind.agentObserverFrame],
|
||||
tags: {
|
||||
'#p': [ownerPubkey],
|
||||
},
|
||||
limit: 0,
|
||||
),
|
||||
_handleEvent,
|
||||
onClosed: (message) {
|
||||
_unsubscribe = null;
|
||||
_errorMessage = 'Observer subscription closed: $message';
|
||||
_emit(connection: ObserverConnectionState.error);
|
||||
},
|
||||
);
|
||||
|
||||
if (_disposed || epoch != _subscriptionEpoch) {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
_unsubscribe = unsubscribe;
|
||||
_emit(connection: ObserverConnectionState.open);
|
||||
} catch (error) {
|
||||
if (_disposed || epoch != _subscriptionEpoch) {
|
||||
return;
|
||||
}
|
||||
_errorMessage = _observerErrorMessage(error);
|
||||
_emit(connection: ObserverConnectionState.error);
|
||||
} finally {
|
||||
if (epoch == _subscriptionEpoch) {
|
||||
_startFuture = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEvent(NostrEvent event) {
|
||||
final agentPubkey = event.getTagValue('agent');
|
||||
if (agentPubkey == null || event.getTagValue('frame') != 'telemetry') {
|
||||
return;
|
||||
}
|
||||
|
||||
final normalizedAgent = agentPubkey.toLowerCase();
|
||||
if (event.pubkey.toLowerCase() != normalizedAgent) {
|
||||
return;
|
||||
}
|
||||
|
||||
final ownerPubkey = _ownerPubkey;
|
||||
final privHex = _privHex;
|
||||
if (ownerPubkey == null || privHex == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final pTag = event.getTagValue('p');
|
||||
if (pTag?.toLowerCase() != ownerPubkey.toLowerCase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final frame = _decryptFrame(event, normalizedAgent, privHex);
|
||||
if (frame == null) return;
|
||||
|
||||
final dedupeKey = '${frame.seq}:${frame.timestamp}';
|
||||
final dedupeKeys = _dedupeKeysByAgent.putIfAbsent(
|
||||
normalizedAgent,
|
||||
() => <String>{},
|
||||
);
|
||||
if (!dedupeKeys.add(dedupeKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final frames = _framesByAgent.putIfAbsent(
|
||||
normalizedAgent,
|
||||
() => <ObserverFrame>[],
|
||||
);
|
||||
frames.add(frame);
|
||||
frames.sort(_compareObserverFrames);
|
||||
|
||||
if (frames.length > _maxObserverEvents) {
|
||||
final removeCount = frames.length - _maxObserverEvents;
|
||||
for (final removed in frames.take(removeCount)) {
|
||||
dedupeKeys.remove('${removed.seq}:${removed.timestamp}');
|
||||
}
|
||||
frames.removeRange(0, removeCount);
|
||||
}
|
||||
|
||||
_errorMessage = null;
|
||||
_emit(connection: ObserverConnectionState.open);
|
||||
}
|
||||
|
||||
ObserverFrame? _decryptFrame(
|
||||
NostrEvent event,
|
||||
String normalizedAgent,
|
||||
String privHex,
|
||||
) {
|
||||
try {
|
||||
final conversationKey = _conversationKeysByAgent.putIfAbsent(
|
||||
normalizedAgent,
|
||||
() => getConversationKey(privHex, normalizedAgent),
|
||||
);
|
||||
final plaintext = nip44Decrypt(conversationKey, event.content);
|
||||
final json = jsonDecode(plaintext) as Map<String, dynamic>;
|
||||
return ObserverFrame.fromJson(json);
|
||||
} catch (error) {
|
||||
_errorMessage = 'Observer event decrypt failed: $error';
|
||||
_emit(connection: ObserverConnectionState.error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _emit({required ObserverConnectionState connection}) {
|
||||
if (_disposed) return;
|
||||
state = ObserverRelayState(
|
||||
connection: connection,
|
||||
framesByAgent: _snapshotFrames(),
|
||||
errorMessage: _errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, List<ObserverFrame>> _snapshotFrames() {
|
||||
return Map<String, List<ObserverFrame>>.unmodifiable({
|
||||
for (final entry in _framesByAgent.entries)
|
||||
entry.key: List<ObserverFrame>.unmodifiable(entry.value),
|
||||
});
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
_subscriptionEpoch += 1;
|
||||
_unsubscribe?.call();
|
||||
_unsubscribe = null;
|
||||
_startFuture = null;
|
||||
_privHex = null;
|
||||
_ownerPubkey = null;
|
||||
_errorMessage = null;
|
||||
_framesByAgent.clear();
|
||||
_dedupeKeysByAgent.clear();
|
||||
_conversationKeysByAgent.clear();
|
||||
}
|
||||
|
||||
static String _decodePrivkey(String nsec) {
|
||||
try {
|
||||
final privHex = nostr.Nip19.decodePrivkey(nsec);
|
||||
if (privHex.isEmpty) {
|
||||
throw const FormatException('empty private key');
|
||||
}
|
||||
return privHex;
|
||||
} catch (_) {
|
||||
throw const FormatException('failed to decode private key');
|
||||
}
|
||||
}
|
||||
|
||||
static String _derivePubkey(String privHex) {
|
||||
try {
|
||||
return nostr.Keychain(privHex).public;
|
||||
} catch (_) {
|
||||
throw const FormatException('failed to derive pubkey');
|
||||
}
|
||||
}
|
||||
|
||||
ObserverConnectionState _connectionForSession(SessionStatus status) {
|
||||
if (_errorMessage != null && _unsubscribe == null && _startFuture == null) {
|
||||
return ObserverConnectionState.error;
|
||||
}
|
||||
return switch (status) {
|
||||
SessionStatus.connected =>
|
||||
_unsubscribe == null
|
||||
? ObserverConnectionState.connecting
|
||||
: ObserverConnectionState.open,
|
||||
SessionStatus.connecting ||
|
||||
SessionStatus.reconnecting => ObserverConnectionState.connecting,
|
||||
SessionStatus.disconnected => ObserverConnectionState.idle,
|
||||
};
|
||||
}
|
||||
|
||||
static String _observerErrorMessage(Object error) {
|
||||
if (error is FormatException) {
|
||||
return error.message;
|
||||
}
|
||||
return 'Observer subscription failed: $error';
|
||||
}
|
||||
|
||||
static int _compareObserverFrames(ObserverFrame a, ObserverFrame b) {
|
||||
final tsA = DateTime.tryParse(a.timestamp)?.millisecondsSinceEpoch ?? 0;
|
||||
final tsB = DateTime.tryParse(b.timestamp)?.millisecondsSinceEpoch ?? 0;
|
||||
if (tsA != tsB) return tsA.compareTo(tsB);
|
||||
return a.seq.compareTo(b.seq);
|
||||
}
|
||||
}
|
||||
|
||||
final observerRelayProvider =
|
||||
NotifierProvider<ObserverRelayNotifier, ObserverRelayState>(
|
||||
ObserverRelayNotifier.new,
|
||||
);
|
||||
|
||||
final observerSubscriptionProvider =
|
||||
Provider.family<ObserverState, ObserverKey>((ref, key) {
|
||||
final relayState = ref.watch(observerRelayProvider);
|
||||
final normalizedAgent = key.agentPubkey.toLowerCase();
|
||||
final frames = relayState.framesByAgent[normalizedAgent] ?? const [];
|
||||
final channelFrames = [
|
||||
for (final frame in frames)
|
||||
if (frame.channelId == null || frame.channelId == key.channelId)
|
||||
frame,
|
||||
];
|
||||
|
||||
return ObserverState(
|
||||
connection: relayState.connection,
|
||||
transcript: buildTranscript(channelFrames),
|
||||
errorMessage: relayState.errorMessage,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,734 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'observer_models.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sprout tool catalogs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _sproutReadTools = <String>{
|
||||
'get_messages',
|
||||
'get_channel_history',
|
||||
'get_thread',
|
||||
'search',
|
||||
'get_feed',
|
||||
'get_reactions',
|
||||
'list_channels',
|
||||
'get_channel',
|
||||
'get_users',
|
||||
'get_presence',
|
||||
'list_channel_members',
|
||||
'list_dms',
|
||||
'get_canvas',
|
||||
'list_workflows',
|
||||
'get_workflow_runs',
|
||||
'get_event',
|
||||
'get_user_notes',
|
||||
'get_contact_list',
|
||||
};
|
||||
|
||||
const _sproutWriteTools = <String>{
|
||||
'send_message',
|
||||
'send_diff_message',
|
||||
'edit_message',
|
||||
'delete_message',
|
||||
'add_reaction',
|
||||
'remove_reaction',
|
||||
'join_channel',
|
||||
'leave_channel',
|
||||
'update_channel',
|
||||
'set_channel_topic',
|
||||
'set_channel_purpose',
|
||||
'open_dm',
|
||||
'set_profile',
|
||||
'set_presence',
|
||||
'trigger_workflow',
|
||||
'approve_step',
|
||||
'create_channel',
|
||||
'archive_channel',
|
||||
'unarchive_channel',
|
||||
'add_channel_member',
|
||||
'remove_channel_member',
|
||||
'add_dm_member',
|
||||
'hide_dm',
|
||||
'set_canvas',
|
||||
'create_workflow',
|
||||
'update_workflow',
|
||||
'delete_workflow',
|
||||
'set_channel_add_policy',
|
||||
'vote_on_post',
|
||||
'publish_note',
|
||||
'set_contact_list',
|
||||
};
|
||||
|
||||
final _sproutToolNames = <String>{..._sproutReadTools, ..._sproutWriteTools};
|
||||
|
||||
final _sproutToolNamesByLength = _sproutToolNames.toList()
|
||||
..sort((a, b) => b.length.compareTo(a.length));
|
||||
|
||||
final _sproutToolTitleAliases = <(RegExp, String)>[
|
||||
(RegExp(r'\bsending message to channel\b'), 'send_message'),
|
||||
(RegExp(r'\bretrieving recent messages from channel\b'), 'get_messages'),
|
||||
(RegExp(r'\bgetting channel details\b'), 'get_channel'),
|
||||
(RegExp(r'\bgetting user information\b'), 'get_users'),
|
||||
(RegExp(r'\bsearching relay history\b'), 'search'),
|
||||
(RegExp(r'\bgetting thread\b'), 'get_thread'),
|
||||
(RegExp(r'\badding reaction\b'), 'add_reaction'),
|
||||
(RegExp(r'\bremoving reaction\b'), 'remove_reaction'),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility helpers (port of agentSessionUtils.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Map<String, dynamic> _asRecord(dynamic value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return value.cast<String, dynamic>();
|
||||
return const {};
|
||||
}
|
||||
|
||||
String? _asString(dynamic value) {
|
||||
return value is String ? value : null;
|
||||
}
|
||||
|
||||
String _shorten(String value) {
|
||||
if (value.length > 14) {
|
||||
return '${value.substring(0, 8)}...${value.substring(value.length - 4)}';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
String _titleCase(String value) {
|
||||
return value
|
||||
.replaceAll(RegExp(r'[_-]+'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim()
|
||||
.replaceAllMapped(RegExp(r'\b\w'), (m) => m[0]!.toUpperCase());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool name helpers (port of agentSessionToolCatalog.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
String _normalizeToolNameText(String value) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r'[^a-z0-9_]+'), '_')
|
||||
.replaceAll(RegExp(r'_+'), '_')
|
||||
.replaceAll(RegExp(r'^_+|_+$'), '');
|
||||
}
|
||||
|
||||
String? _findSproutToolName(String value, bool includeShortNames) {
|
||||
final alias = _findSproutToolAlias(value);
|
||||
if (alias != null) return alias;
|
||||
|
||||
final normalized = _normalizeToolNameText(value);
|
||||
for (final name in _sproutToolNamesByLength) {
|
||||
if ((!includeShortNames && name.length < 8) || !normalized.contains(name)) {
|
||||
continue;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _findSproutToolAlias(String value) {
|
||||
final normalizedPhrase = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r'[_-]+'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ');
|
||||
for (final (pattern, name) in _sproutToolTitleAliases) {
|
||||
if (pattern.hasMatch(normalizedPhrase)) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isGenericToolTitle(String value) {
|
||||
final normalized = _normalizeToolNameText(value);
|
||||
return normalized.isEmpty ||
|
||||
normalized == 'tool' ||
|
||||
normalized == 'tool_call' ||
|
||||
normalized == 'mcp_tool_call' ||
|
||||
normalized == 'unknown' ||
|
||||
normalized == 'read' ||
|
||||
normalized == 'write' ||
|
||||
normalized == 'execute' ||
|
||||
normalized == 'completed';
|
||||
}
|
||||
|
||||
String _normalizeToolName(String title) {
|
||||
final knownName = _findSproutToolName(title, true);
|
||||
if (knownName != null) return knownName;
|
||||
|
||||
final normalized = _normalizeToolNameText(
|
||||
title,
|
||||
).replaceAll(RegExp(r'^sprout_mcp_'), '').replaceAll(RegExp(r'^sprout_'), '');
|
||||
return RegExp(r'[a-z][a-z0-9_]+').firstMatch(normalized)?[0] ?? normalized;
|
||||
}
|
||||
|
||||
ToolStatus _normalizeToolStatus(String status) {
|
||||
final normalized = status.toLowerCase();
|
||||
if (normalized.contains('complete') ||
|
||||
normalized.contains('success') ||
|
||||
normalized == 'done') {
|
||||
return ToolStatus.completed;
|
||||
}
|
||||
if (normalized.contains('fail') || normalized.contains('error')) {
|
||||
return ToolStatus.failed;
|
||||
}
|
||||
if (normalized.contains('pending')) {
|
||||
return ToolStatus.pending;
|
||||
}
|
||||
return ToolStatus.executing;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transcript helpers (port of agentSessionTranscriptHelpers.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
String _extractContentText(dynamic value) {
|
||||
if (value is String) return value;
|
||||
if (value is List) return value.map(_extractBlockText).join('\n');
|
||||
return _extractBlockText(value);
|
||||
}
|
||||
|
||||
String _extractBlockText(dynamic value) {
|
||||
if (value is String) return value;
|
||||
if (value is List) return value.map(_extractBlockText).join('\n');
|
||||
final record = _asRecord(value);
|
||||
final nestedContent = record['content'];
|
||||
final rawOutput = record['rawOutput'];
|
||||
final nestedText = nestedContent != null && nestedContent is! String
|
||||
? _extractBlockText(nestedContent)
|
||||
: '';
|
||||
final rawOutputText = rawOutput == null
|
||||
? ''
|
||||
: rawOutput is String
|
||||
? rawOutput
|
||||
: _safeJsonEncode(rawOutput);
|
||||
final directText = _asString(record['text']) ?? _asString(record['content']);
|
||||
if (directText != null && directText.isNotEmpty) return directText;
|
||||
if (nestedText.isNotEmpty) return nestedText;
|
||||
if (rawOutputText.isNotEmpty) return rawOutputText;
|
||||
return '';
|
||||
}
|
||||
|
||||
String _extractPromptText(Map<String, dynamic> payload) {
|
||||
final params = _asRecord(payload['params']);
|
||||
final prompt = params['prompt'];
|
||||
if (prompt is! List) return '';
|
||||
return (prompt).map(_extractBlockText).where((s) => s.isNotEmpty).join('\n');
|
||||
}
|
||||
|
||||
({List<PromptSection> sections, String userText, String userTitle})
|
||||
_parsePromptText(String text) {
|
||||
final sections = _parsePromptSections(text);
|
||||
if (sections.isEmpty) {
|
||||
return (
|
||||
sections: <PromptSection>[],
|
||||
userText: text.trim(),
|
||||
userTitle: 'Prompt',
|
||||
);
|
||||
}
|
||||
|
||||
PromptSection? eventSection;
|
||||
for (final section in sections) {
|
||||
if (section.title.toLowerCase().startsWith('sprout event')) {
|
||||
eventSection = section;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final eventContent = eventSection != null
|
||||
? _extractEventContent(eventSection.body)
|
||||
: '';
|
||||
final eventKind = eventSection?.title.split(':').skip(1).join(':').trim();
|
||||
|
||||
return (
|
||||
sections: sections,
|
||||
userText: eventContent,
|
||||
userTitle: eventKind != null && eventKind.isNotEmpty
|
||||
? _titleCase(eventKind)
|
||||
: 'Sprout event',
|
||||
);
|
||||
}
|
||||
|
||||
List<PromptSection> _parsePromptSections(String text) {
|
||||
final sections = <PromptSection>[];
|
||||
final headerPattern = RegExp(r'^\[([^\]]+)]\s*$');
|
||||
String? currentTitle;
|
||||
final currentBody = StringBuffer();
|
||||
final preamble = <String>[];
|
||||
|
||||
for (final line in text.split(RegExp(r'\r?\n'))) {
|
||||
final header = headerPattern.firstMatch(line);
|
||||
if (header != null) {
|
||||
if (currentTitle != null) {
|
||||
sections.add(
|
||||
PromptSection(
|
||||
title: currentTitle,
|
||||
body: currentBody.toString().trim(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final pre = preamble.join('\n').trim();
|
||||
if (pre.isNotEmpty) {
|
||||
sections.add(PromptSection(title: 'Prompt', body: pre));
|
||||
}
|
||||
}
|
||||
currentTitle = header.group(1)!;
|
||||
currentBody.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentTitle != null) {
|
||||
if (currentBody.isNotEmpty) currentBody.write('\n');
|
||||
currentBody.write(line);
|
||||
} else {
|
||||
preamble.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTitle != null) {
|
||||
sections.add(
|
||||
PromptSection(title: currentTitle, body: currentBody.toString().trim()),
|
||||
);
|
||||
} else {
|
||||
final pre = preamble.join('\n').trim();
|
||||
if (pre.isNotEmpty) {
|
||||
sections.add(PromptSection(title: 'Prompt', body: pre));
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
String _extractEventContent(String body) {
|
||||
final match = RegExp(r'^Content:\s*(.*)$', multiLine: true).firstMatch(body);
|
||||
return match?.group(1)?.trim() ?? '';
|
||||
}
|
||||
|
||||
Map<String, dynamic> _extractToolArgs(Map<String, dynamic> update) {
|
||||
final candidates = [
|
||||
update['args'],
|
||||
update['arguments'],
|
||||
update['input'],
|
||||
update['rawInput'],
|
||||
];
|
||||
for (final candidate in candidates) {
|
||||
if (candidate is Map && candidate is! List) {
|
||||
return Map<String, dynamic>.from(candidate);
|
||||
}
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
|
||||
({String title, String toolName, String? sproutToolName}) _extractToolIdentity(
|
||||
Map<String, dynamic> update,
|
||||
) {
|
||||
final candidates = _collectToolNameCandidates(update);
|
||||
String? knownName;
|
||||
for (final c in candidates) {
|
||||
knownName = _findSproutToolName(c, true);
|
||||
if (knownName != null) break;
|
||||
}
|
||||
knownName ??= _findSproutToolName(_safeJsonEncode(update), false);
|
||||
String? firstSpecific;
|
||||
for (final candidate in candidates) {
|
||||
if (!_isGenericToolTitle(candidate)) {
|
||||
firstSpecific = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final title =
|
||||
_asString(update['title']) ?? knownName ?? firstSpecific ?? 'Tool call';
|
||||
return (
|
||||
title: title,
|
||||
toolName: knownName ?? _normalizeToolName(firstSpecific ?? title),
|
||||
sproutToolName: knownName,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _collectToolNameCandidates(Map<String, dynamic> update) {
|
||||
final args = _extractToolArgs(update);
|
||||
final tool = _asRecord(update['tool']);
|
||||
final input = _asRecord(update['input']);
|
||||
final rawInput = _asRecord(update['rawInput']);
|
||||
final sources = <dynamic>[
|
||||
update['toolName'],
|
||||
update['tool_name'],
|
||||
update['name'],
|
||||
update['title'],
|
||||
update['kind'],
|
||||
tool['name'],
|
||||
tool['toolName'],
|
||||
args['toolName'],
|
||||
args['tool_name'],
|
||||
args['name'],
|
||||
args['method'],
|
||||
input['toolName'],
|
||||
input['tool_name'],
|
||||
input['name'],
|
||||
rawInput['toolName'],
|
||||
rawInput['tool_name'],
|
||||
rawInput['name'],
|
||||
];
|
||||
return [
|
||||
for (final s in sources)
|
||||
if (s is String && s.isNotEmpty) s,
|
||||
];
|
||||
}
|
||||
|
||||
String _extractToolResult(Map<String, dynamic> update) {
|
||||
final contentText = _extractContentText(update['content']);
|
||||
if (contentText.isNotEmpty) return contentText;
|
||||
return _extractBlockText(update['rawOutput']);
|
||||
}
|
||||
|
||||
String _describeTurnStarted(dynamic payload) {
|
||||
final record = _asRecord(payload);
|
||||
final ids = record['triggeringEventIds'];
|
||||
if (ids is List) {
|
||||
final stringIds = ids.whereType<String>().toList();
|
||||
if (stringIds.isNotEmpty) {
|
||||
return 'Triggered by ${stringIds.map(_shorten).join(', ')}.';
|
||||
}
|
||||
}
|
||||
return 'Heartbeat or internal turn.';
|
||||
}
|
||||
|
||||
String _describeSessionResolved(dynamic payload) {
|
||||
final record = _asRecord(payload);
|
||||
final sessionId = _asString(record['sessionId']);
|
||||
final isNewSession = record['isNewSession'] == true;
|
||||
if (sessionId == null) return 'Using existing ACP session.';
|
||||
return '${isNewSession ? 'Created' : 'Using'} session ${_shorten(sessionId)}.';
|
||||
}
|
||||
|
||||
String _safeJsonEncode(dynamic value) {
|
||||
try {
|
||||
return jsonEncode(value);
|
||||
} catch (_) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildTranscript (port of agentSessionTranscript.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
List<TranscriptItem> buildTranscript(List<ObserverFrame> events) {
|
||||
final items = <TranscriptItem>[];
|
||||
final itemsById = <String, TranscriptItem>{};
|
||||
|
||||
// Maps a logical message ID to the actual key currently being appended to.
|
||||
final activeMessageKey = <String, String>{};
|
||||
final sealedKeys = <String>{};
|
||||
var continuationSeq = 0;
|
||||
|
||||
void sealOpenMessages() {
|
||||
for (final currentKey in activeMessageKey.values) {
|
||||
sealedKeys.add(currentKey);
|
||||
}
|
||||
}
|
||||
|
||||
void upsertMessage(
|
||||
String id,
|
||||
String role,
|
||||
String title,
|
||||
String text,
|
||||
String timestamp,
|
||||
) {
|
||||
final currentKey = activeMessageKey[id];
|
||||
|
||||
if (currentKey != null && !sealedKeys.contains(currentKey)) {
|
||||
final existing = itemsById[currentKey];
|
||||
if (existing is MessageItem) {
|
||||
existing.text += text;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
continuationSeq += 1;
|
||||
final newKey = currentKey != null ? '$id:c$continuationSeq' : id;
|
||||
final item = MessageItem(
|
||||
id: newKey,
|
||||
role: role,
|
||||
title: title,
|
||||
text: text,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
items.add(item);
|
||||
itemsById[newKey] = item;
|
||||
activeMessageKey[id] = newKey;
|
||||
}
|
||||
|
||||
void upsertTextItem(
|
||||
String id,
|
||||
String type,
|
||||
String title,
|
||||
String text,
|
||||
String timestamp,
|
||||
) {
|
||||
final existing = itemsById[id];
|
||||
if (existing != null) {
|
||||
if (type == 'thought' && existing is ThoughtItem) {
|
||||
existing.text += text;
|
||||
return;
|
||||
}
|
||||
if (type == 'lifecycle' && existing is LifecycleItem) {
|
||||
existing.text += text;
|
||||
return;
|
||||
}
|
||||
}
|
||||
sealOpenMessages();
|
||||
final TranscriptItem item;
|
||||
if (type == 'thought') {
|
||||
item = ThoughtItem(
|
||||
id: id,
|
||||
title: title,
|
||||
text: text,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
} else {
|
||||
item = LifecycleItem(
|
||||
id: id,
|
||||
title: title,
|
||||
text: text,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
}
|
||||
items.add(item);
|
||||
itemsById[id] = item;
|
||||
}
|
||||
|
||||
void upsertMetadata(
|
||||
String id,
|
||||
String title,
|
||||
List<PromptSection> sections,
|
||||
String timestamp,
|
||||
) {
|
||||
final existing = itemsById[id];
|
||||
if (existing is MetadataItem) {
|
||||
existing.sections = sections;
|
||||
return;
|
||||
}
|
||||
sealOpenMessages();
|
||||
final item = MetadataItem(
|
||||
id: id,
|
||||
title: title,
|
||||
sections: sections,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
items.add(item);
|
||||
itemsById[id] = item;
|
||||
}
|
||||
|
||||
void upsertTool(
|
||||
String id,
|
||||
String title,
|
||||
String toolName,
|
||||
String? sproutToolName,
|
||||
ToolStatus status,
|
||||
Map<String, dynamic> args,
|
||||
String result,
|
||||
bool isError,
|
||||
String timestamp,
|
||||
) {
|
||||
final existing = itemsById[id];
|
||||
final canonicalSproutToolName =
|
||||
sproutToolName ?? _findSproutToolName(toolName, true);
|
||||
if (existing is ToolItem) {
|
||||
if (!_isGenericToolTitle(title)) {
|
||||
existing.title = title;
|
||||
}
|
||||
if (canonicalSproutToolName != null) {
|
||||
existing.sproutToolName = canonicalSproutToolName;
|
||||
existing.toolName = canonicalSproutToolName;
|
||||
} else if (existing.sproutToolName == null &&
|
||||
!_isGenericToolTitle(toolName)) {
|
||||
existing.toolName = toolName;
|
||||
}
|
||||
existing.status = status;
|
||||
existing.args = args.isNotEmpty ? args : existing.args;
|
||||
if (result.isNotEmpty) existing.result = result;
|
||||
existing.isError = isError || existing.isError;
|
||||
return;
|
||||
}
|
||||
sealOpenMessages();
|
||||
final item = ToolItem(
|
||||
id: id,
|
||||
title: title,
|
||||
toolName: canonicalSproutToolName ?? toolName,
|
||||
sproutToolName: canonicalSproutToolName,
|
||||
status: status,
|
||||
args: args,
|
||||
result: result,
|
||||
isError: isError,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
items.add(item);
|
||||
itemsById[id] = item;
|
||||
}
|
||||
|
||||
for (final event in events) {
|
||||
if (event.kind == 'turn_started') {
|
||||
upsertTextItem(
|
||||
'turn:${event.turnId ?? '${event.seq}'}',
|
||||
'lifecycle',
|
||||
'Turn started',
|
||||
_describeTurnStarted(event.payload),
|
||||
event.timestamp,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.kind == 'session_resolved') {
|
||||
upsertTextItem(
|
||||
'session:${event.turnId ?? '${event.seq}'}',
|
||||
'lifecycle',
|
||||
'Session ready',
|
||||
_describeSessionResolved(event.payload),
|
||||
event.timestamp,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.kind == 'acp_parse_error') {
|
||||
upsertTextItem(
|
||||
'parse-error:${event.seq}',
|
||||
'lifecycle',
|
||||
'Wire parse error',
|
||||
_extractBlockText(event.payload),
|
||||
event.timestamp,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.kind != 'acp_read' && event.kind != 'acp_write') {
|
||||
continue;
|
||||
}
|
||||
|
||||
final payload = _asRecord(event.payload);
|
||||
final method = _asString(payload['method']);
|
||||
|
||||
if (event.kind == 'acp_write' && method == 'session/prompt') {
|
||||
final promptText = _extractPromptText(payload);
|
||||
if (promptText.isNotEmpty) {
|
||||
final parsedPrompt = _parsePromptText(promptText);
|
||||
if (parsedPrompt.userText.isNotEmpty) {
|
||||
upsertMessage(
|
||||
'prompt:${event.turnId ?? '${event.seq}'}',
|
||||
'user',
|
||||
parsedPrompt.userTitle,
|
||||
parsedPrompt.userText,
|
||||
event.timestamp,
|
||||
);
|
||||
}
|
||||
if (parsedPrompt.sections.isNotEmpty) {
|
||||
upsertMetadata(
|
||||
'prompt-context:${event.turnId ?? '${event.seq}'}',
|
||||
'Prompt context',
|
||||
parsedPrompt.sections,
|
||||
event.timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.kind != 'acp_read' || method != 'session/update') {
|
||||
continue;
|
||||
}
|
||||
|
||||
final params = _asRecord(payload['params']);
|
||||
final update = _asRecord(params['update']);
|
||||
final updateType = _asString(update['sessionUpdate']) ?? 'unknown';
|
||||
final turnKey = event.turnId ?? event.sessionId ?? 'unknown';
|
||||
final messageId = _asString(update['messageId']);
|
||||
|
||||
if (updateType == 'agent_message_chunk') {
|
||||
upsertMessage(
|
||||
'assistant:${messageId ?? turnKey}',
|
||||
'assistant',
|
||||
'Assistant',
|
||||
_extractContentText(update['content']),
|
||||
event.timestamp,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (updateType == 'user_message_chunk') {
|
||||
upsertMessage(
|
||||
'user:${messageId ?? turnKey}',
|
||||
'user',
|
||||
'User',
|
||||
_extractContentText(update['content']),
|
||||
event.timestamp,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (updateType == 'agent_thought_chunk') {
|
||||
upsertTextItem(
|
||||
'thinking:${messageId ?? turnKey}',
|
||||
'thought',
|
||||
'Thinking',
|
||||
_extractContentText(update['content']),
|
||||
event.timestamp,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (updateType == 'tool_call') {
|
||||
final toolId = _asString(update['toolCallId']) ?? 'tool:${event.seq}';
|
||||
final identity = _extractToolIdentity(update);
|
||||
upsertTool(
|
||||
'tool:$toolId',
|
||||
identity.title,
|
||||
identity.toolName,
|
||||
identity.sproutToolName,
|
||||
_normalizeToolStatus(_asString(update['status']) ?? 'executing'),
|
||||
_extractToolArgs(update),
|
||||
_extractToolResult(update),
|
||||
false,
|
||||
event.timestamp,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (updateType == 'tool_call_update') {
|
||||
final toolId = _asString(update['toolCallId']) ?? 'tool:${event.seq}';
|
||||
final status = _normalizeToolStatus(
|
||||
_asString(update['status']) ?? 'completed',
|
||||
);
|
||||
final identity = _extractToolIdentity(update);
|
||||
upsertTool(
|
||||
'tool:$toolId',
|
||||
identity.title,
|
||||
identity.toolName,
|
||||
identity.sproutToolName,
|
||||
status,
|
||||
_extractToolArgs(update),
|
||||
_extractToolResult(update),
|
||||
status == ToolStatus.failed,
|
||||
event.timestamp,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (updateType == 'plan') {
|
||||
final content = _extractContentText(update['content']);
|
||||
upsertTextItem(
|
||||
'plan:$turnKey',
|
||||
'thought',
|
||||
'Plan',
|
||||
content.isNotEmpty ? content : _safeJsonEncode(update),
|
||||
event.timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gpt_markdown/gpt_markdown.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../shared/theme/theme.dart';
|
||||
import 'observer_models.dart';
|
||||
|
||||
/// Renders a single [TranscriptItem] in the agent activity transcript.
|
||||
class TranscriptItemWidget extends StatelessWidget {
|
||||
final TranscriptItem item;
|
||||
|
||||
const TranscriptItemWidget({super.key, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return switch (item) {
|
||||
final MessageItem i => _MessageItemWidget(item: i),
|
||||
final ThoughtItem i => _ThoughtItemWidget(item: i),
|
||||
final LifecycleItem i => _LifecycleItemWidget(item: i),
|
||||
final MetadataItem i => _MetadataItemWidget(item: i),
|
||||
final ToolItem i => _ToolItemWidget(item: i),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _MessageItemWidget extends StatelessWidget {
|
||||
final MessageItem item;
|
||||
|
||||
const _MessageItemWidget({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isAssistant = item.role == 'assistant';
|
||||
final badgeColor = isAssistant
|
||||
? context.colors.primary
|
||||
: context.colors.outline;
|
||||
final badgeLabel = isAssistant ? 'Assistant' : 'User';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xxs,
|
||||
vertical: Grid.quarter,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: badgeColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
if (item.text.isNotEmpty)
|
||||
GptMarkdown(
|
||||
item.text,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thought
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _ThoughtItemWidget extends HookWidget {
|
||||
final ThoughtItem item;
|
||||
|
||||
const _ThoughtItemWidget({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final expanded = useState(item.text.length <= 200);
|
||||
|
||||
useEffect(() {
|
||||
expanded.value = item.text.length <= 200;
|
||||
return null;
|
||||
}, [item.id]);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
||||
child: GestureDetector(
|
||||
onTap: () => expanded.value = !expanded.value,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.brain,
|
||||
size: 14,
|
||||
color: context.colors.outline,
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.outline,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
expanded.value
|
||||
? LucideIcons.chevronUp
|
||||
: LucideIcons.chevronDown,
|
||||
size: 14,
|
||||
color: context.colors.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (expanded.value && item.text.isNotEmpty) ...[
|
||||
const SizedBox(height: Grid.half),
|
||||
GptMarkdown(
|
||||
item.text,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _LifecycleItemWidget extends StatelessWidget {
|
||||
final LifecycleItem item;
|
||||
|
||||
const _LifecycleItemWidget({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${item.title}${item.text.isNotEmpty ? ' \u2014 ${item.text}' : ''}',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _MetadataItemWidget extends HookWidget {
|
||||
final MetadataItem item;
|
||||
|
||||
const _MetadataItemWidget({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final expanded = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
expanded.value = false;
|
||||
return null;
|
||||
}, [item.id]);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
||||
child: GestureDetector(
|
||||
onTap: () => expanded.value = !expanded.value,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.fileText,
|
||||
size: 14,
|
||||
color: context.colors.outline,
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${item.title} (${item.sections.length} sections)',
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.outline,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
expanded.value
|
||||
? LucideIcons.chevronUp
|
||||
: LucideIcons.chevronDown,
|
||||
size: 14,
|
||||
color: context.colors.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (expanded.value)
|
||||
for (final section in item.sections) ...[
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
section.title,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (section.body.isNotEmpty) ...[
|
||||
const SizedBox(height: Grid.quarter),
|
||||
Text(
|
||||
section.body.length > 500
|
||||
? '${section.body.substring(0, 500)}\u2026'
|
||||
: section.body,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _ToolItemWidget extends HookWidget {
|
||||
final ToolItem item;
|
||||
|
||||
const _ToolItemWidget({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final argsExpanded = useState(false);
|
||||
final resultExpanded = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
argsExpanded.value = false;
|
||||
resultExpanded.value = false;
|
||||
return null;
|
||||
}, [item.id]);
|
||||
|
||||
final (statusColor, statusLabel, statusIcon) = _toolStatusDisplay(
|
||||
item.status,
|
||||
item.isError,
|
||||
context,
|
||||
);
|
||||
|
||||
final displayName = _formatToolName(item.toolName);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: item.isError
|
||||
? Border.all(color: context.colors.error.withValues(alpha: 0.3))
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header: tool name + status badge
|
||||
Row(
|
||||
children: [
|
||||
Icon(LucideIcons.wrench, size: 14, color: statusColor),
|
||||
const SizedBox(width: Grid.half),
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayName,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xxs,
|
||||
vertical: Grid.quarter,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(statusIcon, size: 10, color: statusColor),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
Text(
|
||||
statusLabel,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Args section
|
||||
if (item.args.isNotEmpty) ...[
|
||||
const SizedBox(height: Grid.half),
|
||||
GestureDetector(
|
||||
onTap: () => argsExpanded.value = !argsExpanded.value,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Arguments',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Icon(
|
||||
argsExpanded.value
|
||||
? LucideIcons.chevronUp
|
||||
: LucideIcons.chevronDown,
|
||||
size: 12,
|
||||
color: context.colors.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (argsExpanded.value) ...[
|
||||
const SizedBox(height: Grid.quarter),
|
||||
_CodeBlock(text: _prettyJson(item.args)),
|
||||
],
|
||||
],
|
||||
// Result section
|
||||
if (item.result.isNotEmpty) ...[
|
||||
const SizedBox(height: Grid.half),
|
||||
GestureDetector(
|
||||
onTap: () => resultExpanded.value = !resultExpanded.value,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Result',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: item.isError
|
||||
? context.colors.error
|
||||
: context.colors.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Icon(
|
||||
resultExpanded.value
|
||||
? LucideIcons.chevronUp
|
||||
: LucideIcons.chevronDown,
|
||||
size: 12,
|
||||
color: item.isError
|
||||
? context.colors.error
|
||||
: context.colors.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (resultExpanded.value) ...[
|
||||
const SizedBox(height: Grid.quarter),
|
||||
_CodeBlock(
|
||||
text: item.result.length > 2000
|
||||
? '${item.result.substring(0, 2000)}\n\n\u2026 (truncated)'
|
||||
: item.result,
|
||||
isError: item.isError,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
(Color, String, IconData) _toolStatusDisplay(
|
||||
ToolStatus status,
|
||||
bool isError,
|
||||
BuildContext context,
|
||||
) {
|
||||
if (isError || status == ToolStatus.failed) {
|
||||
return (context.colors.error, 'Error', LucideIcons.circleX);
|
||||
}
|
||||
if (status == ToolStatus.completed) {
|
||||
return (context.appColors.success, 'Done', LucideIcons.circleCheck);
|
||||
}
|
||||
if (status == ToolStatus.pending) {
|
||||
return (context.colors.outline, 'Pending', LucideIcons.circleDot);
|
||||
}
|
||||
return (context.appColors.warning, 'Running', LucideIcons.clock3);
|
||||
}
|
||||
|
||||
String _formatToolName(String toolName) {
|
||||
return toolName
|
||||
.split('_')
|
||||
.map(
|
||||
(part) =>
|
||||
part.isEmpty ? '' : '${part[0].toUpperCase()}${part.substring(1)}',
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
String _prettyJson(Map<String, dynamic> value) {
|
||||
try {
|
||||
return const JsonEncoder.withIndent(' ').convert(value);
|
||||
} catch (_) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
class _CodeBlock extends StatelessWidget {
|
||||
final String text;
|
||||
final bool isError;
|
||||
|
||||
const _CodeBlock({required this.text, this.isError = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
decoration: BoxDecoration(
|
||||
color: isError
|
||||
? context.colors.error.withValues(alpha: 0.06)
|
||||
: context.colors.surface,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Text(
|
||||
text,
|
||||
softWrap: false,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
color: isError
|
||||
? context.colors.error
|
||||
: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../channel_management_provider.dart';
|
||||
import '../channel_typing_provider.dart';
|
||||
|
||||
/// Derived provider that computes which bot members in a channel are currently
|
||||
/// typing (i.e. "working"). Returns a set of lowercase pubkeys.
|
||||
///
|
||||
/// Used by both the members button badge and the members sheet to avoid
|
||||
/// duplicating the bot-typing cross-reference logic.
|
||||
final workingBotPubkeysProvider = Provider.family<Set<String>, String>((
|
||||
ref,
|
||||
channelId,
|
||||
) {
|
||||
final typingEntries = ref.watch(channelTypingProvider(channelId));
|
||||
final membersAsync = ref.watch(channelMembersProvider(channelId));
|
||||
final allMembers = membersAsync.asData?.value ?? const <ChannelMember>[];
|
||||
|
||||
final botPubkeys = <String>{
|
||||
for (final m in allMembers)
|
||||
if (m.isBot) m.pubkey.toLowerCase(),
|
||||
};
|
||||
|
||||
return <String>{
|
||||
for (final e in typingEntries)
|
||||
if (botPubkeys.contains(e.pubkey.toLowerCase())) e.pubkey.toLowerCase(),
|
||||
};
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../forum/forum_posts_view.dart';
|
||||
import 'channel.dart';
|
||||
import 'agent_activity/working_bots_provider.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
import 'channel_messages_provider.dart';
|
||||
import 'channel_typing_provider.dart';
|
||||
@@ -182,20 +183,10 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (_) => MembersSheet(
|
||||
channel: resolvedChannel,
|
||||
currentPubkey: currentPubkey,
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'View members',
|
||||
icon: const Icon(LucideIcons.users),
|
||||
_MembersButton(
|
||||
channelId: resolvedChannel.id,
|
||||
channel: resolvedChannel,
|
||||
currentPubkey: currentPubkey,
|
||||
),
|
||||
if (!resolvedChannel.isDm)
|
||||
IconButton(
|
||||
@@ -1048,6 +1039,62 @@ class _TypingIndicator extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Members button with activity dot badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _MembersButton extends ConsumerWidget {
|
||||
final String channelId;
|
||||
final Channel channel;
|
||||
final String? currentPubkey;
|
||||
|
||||
const _MembersButton({
|
||||
required this.channelId,
|
||||
required this.channel,
|
||||
required this.currentPubkey,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasWorkingBot = ref
|
||||
.watch(workingBotPubkeysProvider(channelId))
|
||||
.isNotEmpty;
|
||||
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (_) =>
|
||||
MembersSheet(channel: channel, currentPubkey: currentPubkey),
|
||||
);
|
||||
},
|
||||
tooltip: 'View members',
|
||||
icon: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
const Icon(LucideIcons.users),
|
||||
if (hasWorkingBot)
|
||||
Positioned(
|
||||
top: -2,
|
||||
right: -2,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: context.appColors.success,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: context.colors.surface, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DmAppBarTitle extends ConsumerWidget {
|
||||
final Channel channel;
|
||||
final String? currentPubkey;
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import 'agent_activity/agent_activity_sheet.dart';
|
||||
import 'agent_activity/working_bots_provider.dart';
|
||||
import 'channel.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
|
||||
@@ -26,6 +28,7 @@ class MembersSheet extends HookConsumerWidget {
|
||||
final people = allMembers.where((member) => !member.isBot).toList();
|
||||
final bots = allMembers.where((member) => member.isBot).toList();
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
final typingBotPubkeys = ref.watch(workingBotPubkeysProvider(channel.id));
|
||||
|
||||
// Determine if the current user can manage members.
|
||||
final currentMember = allMembers.cast<ChannelMember?>().firstWhere(
|
||||
@@ -37,6 +40,23 @@ class MembersSheet extends HookConsumerWidget {
|
||||
currentMember.isElevated &&
|
||||
!channel.isArchived;
|
||||
|
||||
void openActivity(ChannelMember bot) {
|
||||
final navigator = Navigator.of(context);
|
||||
navigator.pop();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!navigator.mounted) return;
|
||||
showModalBottomSheet<void>(
|
||||
context: navigator.context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (_) => AgentActivitySheet(
|
||||
channelId: channel.id,
|
||||
agentPubkey: bot.pubkey,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Preload profiles for all members so avatars appear.
|
||||
useEffect(() {
|
||||
if (allMembers.isNotEmpty) {
|
||||
@@ -101,6 +121,16 @@ class MembersSheet extends HookConsumerWidget {
|
||||
canManage: canManage,
|
||||
isSelf: false,
|
||||
channelId: channel.id,
|
||||
isWorking: typingBotPubkeys.contains(
|
||||
bot.pubkey.toLowerCase(),
|
||||
),
|
||||
onViewActivity: () => openActivity(bot),
|
||||
onActivityTap:
|
||||
typingBotPubkeys.contains(
|
||||
bot.pubkey.toLowerCase(),
|
||||
)
|
||||
? () => openActivity(bot)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
if (people.isEmpty && bots.isEmpty)
|
||||
@@ -157,6 +187,11 @@ class _SectionLabel extends StatelessWidget {
|
||||
|
||||
const _changeableRoles = ['admin', 'member', 'guest'];
|
||||
|
||||
String _roleLabel(String role) {
|
||||
if (role.isEmpty) return 'Member';
|
||||
return '${role[0].toUpperCase()}${role.substring(1)}';
|
||||
}
|
||||
|
||||
class _MemberTile extends ConsumerWidget {
|
||||
final ChannelMember member;
|
||||
final String? currentPubkey;
|
||||
@@ -164,6 +199,9 @@ class _MemberTile extends ConsumerWidget {
|
||||
final bool canManage;
|
||||
final bool isSelf;
|
||||
final String channelId;
|
||||
final bool isWorking;
|
||||
final VoidCallback? onActivityTap;
|
||||
final VoidCallback? onViewActivity;
|
||||
|
||||
const _MemberTile({
|
||||
required this.member,
|
||||
@@ -172,45 +210,76 @@ class _MemberTile extends ConsumerWidget {
|
||||
required this.canManage,
|
||||
required this.isSelf,
|
||||
required this.channelId,
|
||||
this.isWorking = false,
|
||||
this.onActivityTap,
|
||||
this.onViewActivity,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final label = member.labelFor(currentPubkey);
|
||||
final initial = label.substring(0, 1).toUpperCase();
|
||||
final showMenu = canManage && !isSelf && !member.isOwner;
|
||||
final showManagementActions = canManage && !isSelf && !member.isOwner;
|
||||
final showMenu = showManagementActions || onViewActivity != null;
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: _MemberAvatar(avatarUrl: profile?.avatarUrl, initial: initial),
|
||||
title: Text(label),
|
||||
subtitle: Text(
|
||||
_roleLabel(member.role),
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
subtitle: isWorking
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 10,
|
||||
height: 10,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: context.appColors.success,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'Working\u2026',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.appColors.success,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Text(
|
||||
_roleLabel(member.role),
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
trailing: showMenu
|
||||
? IconButton(
|
||||
icon: const Icon(LucideIcons.ellipsis, size: 18),
|
||||
onPressed: () => _showMemberActions(context, ref),
|
||||
onPressed: () => _showMemberActions(
|
||||
context,
|
||||
ref,
|
||||
showManagementActions: showManagementActions,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
)
|
||||
: null,
|
||||
onTap: onActivityTap,
|
||||
);
|
||||
}
|
||||
|
||||
String _roleLabel(String role) {
|
||||
if (role.isEmpty) return 'Member';
|
||||
return '${role[0].toUpperCase()}${role.substring(1)}';
|
||||
}
|
||||
|
||||
void _showMemberActions(BuildContext context, WidgetRef ref) {
|
||||
void _showMemberActions(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required bool showManagementActions,
|
||||
}) {
|
||||
final label = member.labelFor(currentPubkey);
|
||||
final canChangeRole = showManagementActions && !member.isBot;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (_) => SafeArea(
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -220,83 +289,82 @@ class _MemberTile extends ConsumerWidget {
|
||||
child: Text(label, style: context.textTheme.titleSmall),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
|
||||
child: Text(
|
||||
'Change role',
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
for (final role in _changeableRoles)
|
||||
if (onViewActivity != null)
|
||||
ListTile(
|
||||
title: Text(_roleLabel(role)),
|
||||
trailing: role == member.role
|
||||
? Icon(
|
||||
LucideIcons.check,
|
||||
size: 16,
|
||||
color: context.colors.primary,
|
||||
)
|
||||
: null,
|
||||
enabled: role != member.role,
|
||||
onTap: role == member.role
|
||||
? null
|
||||
: () async {
|
||||
Navigator.of(context).pop();
|
||||
await ref
|
||||
.read(channelActionsProvider)
|
||||
.changeMemberRole(
|
||||
channelId: channelId,
|
||||
pubkey: member.pubkey,
|
||||
role: role,
|
||||
);
|
||||
},
|
||||
leading: Icon(
|
||||
LucideIcons.activity,
|
||||
size: 18,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
title: const Text('View activity'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
onViewActivity?.call();
|
||||
});
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
LucideIcons.userMinus,
|
||||
size: 18,
|
||||
color: context.colors.error,
|
||||
),
|
||||
title: Text(
|
||||
'Remove from channel',
|
||||
style: TextStyle(color: context.colors.error),
|
||||
),
|
||||
onTap: () async {
|
||||
Navigator.of(context).pop();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Remove member'),
|
||||
content: Text('Remove $label from this channel?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(
|
||||
'Remove',
|
||||
style: TextStyle(color: context.colors.error),
|
||||
if (showManagementActions) ...[
|
||||
if (canChangeRole) ...[
|
||||
const SizedBox(height: Grid.xxs),
|
||||
_RoleSelector(
|
||||
selectedRole: member.role,
|
||||
onChanged: (role) async {
|
||||
Navigator.of(sheetContext).pop();
|
||||
await ref
|
||||
.read(channelActionsProvider)
|
||||
.changeMemberRole(
|
||||
channelId: channelId,
|
||||
pubkey: member.pubkey,
|
||||
role: role,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
],
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
LucideIcons.userMinus,
|
||||
size: 18,
|
||||
color: context.colors.error,
|
||||
),
|
||||
title: Text(
|
||||
'Remove from channel',
|
||||
style: TextStyle(color: context.colors.error),
|
||||
),
|
||||
onTap: () async {
|
||||
Navigator.of(context).pop();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Remove member'),
|
||||
content: Text('Remove $label from this channel?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await ref
|
||||
.read(channelActionsProvider)
|
||||
.removeMember(
|
||||
channelId: channelId,
|
||||
pubkey: member.pubkey,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(
|
||||
'Remove',
|
||||
style: TextStyle(color: context.colors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await ref
|
||||
.read(channelActionsProvider)
|
||||
.removeMember(
|
||||
channelId: channelId,
|
||||
pubkey: member.pubkey,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: Grid.xxs),
|
||||
],
|
||||
),
|
||||
@@ -305,6 +373,60 @@ class _MemberTile extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _RoleSelector extends StatelessWidget {
|
||||
final String selectedRole;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
const _RoleSelector({required this.selectedRole, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasKnownRole = _changeableRoles.contains(selectedRole);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Role',
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: SegmentedButton<String>(
|
||||
segments: [
|
||||
for (final role in _changeableRoles)
|
||||
ButtonSegment<String>(
|
||||
value: role,
|
||||
label: Text(_roleLabel(role)),
|
||||
),
|
||||
],
|
||||
selected: hasKnownRole ? {selectedRole} : const <String>{},
|
||||
emptySelectionAllowed: !hasKnownRole,
|
||||
showSelectedIcon: false,
|
||||
style: ButtonStyle(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
textStyle: WidgetStatePropertyAll(context.textTheme.labelSmall),
|
||||
),
|
||||
onSelectionChanged: (roles) {
|
||||
if (roles.isEmpty) return;
|
||||
final role = roles.single;
|
||||
if (role == selectedRole) return;
|
||||
onChanged(role);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberAvatar extends HookWidget {
|
||||
final String? avatarUrl;
|
||||
final String initial;
|
||||
|
||||
@@ -9,6 +9,7 @@ abstract final class EventKind {
|
||||
static const streamMessage = 9;
|
||||
static const typingIndicator = 20002;
|
||||
static const auth = 22242;
|
||||
static const agentObserverFrame = 24200;
|
||||
static const readState = 30078;
|
||||
static const streamMessageV2 = 40002;
|
||||
static const streamMessageEdit = 40003;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../auth/auth.dart';
|
||||
@@ -38,12 +38,14 @@ class _HistorySubscription {
|
||||
class _LiveSubscription {
|
||||
final NostrFilter filter;
|
||||
final void Function(NostrEvent) onEvent;
|
||||
final void Function(String message)? onClosed;
|
||||
Completer<void>? readyCompleter;
|
||||
int? lastSeenCreatedAt;
|
||||
|
||||
_LiveSubscription({
|
||||
required this.filter,
|
||||
required this.onEvent,
|
||||
this.onClosed,
|
||||
this.readyCompleter,
|
||||
});
|
||||
}
|
||||
@@ -145,24 +147,36 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
/// `since: lastSeenCreatedAt - 5s` on reconnect.
|
||||
Future<void Function()> subscribe(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent,
|
||||
) async {
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
}) async {
|
||||
final subId = _nextSubId('l');
|
||||
final readyCompleter = Completer<void>();
|
||||
|
||||
_liveSubscriptions[subId] = _LiveSubscription(
|
||||
filter: filter,
|
||||
onEvent: onEvent,
|
||||
onClosed: onClosed,
|
||||
readyCompleter: readyCompleter,
|
||||
);
|
||||
|
||||
_sendReq(subId, filter);
|
||||
|
||||
// Wait for EOSE or a short fallback timeout.
|
||||
await readyCompleter.future.timeout(
|
||||
const Duration(milliseconds: 500),
|
||||
onTimeout: () {},
|
||||
);
|
||||
try {
|
||||
await readyCompleter.future.timeout(
|
||||
const Duration(milliseconds: 500),
|
||||
onTimeout: () {},
|
||||
);
|
||||
} catch (_) {
|
||||
_liveSubscriptions.remove(subId);
|
||||
_recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:'));
|
||||
rethrow;
|
||||
}
|
||||
final liveSub = _liveSubscriptions[subId];
|
||||
if (liveSub != null && liveSub.readyCompleter == readyCompleter) {
|
||||
liveSub.readyCompleter = null;
|
||||
}
|
||||
|
||||
return () => _unsubscribe(subId);
|
||||
}
|
||||
@@ -329,6 +343,8 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
_handleEvent(data);
|
||||
case 'EOSE':
|
||||
_handleEose(data);
|
||||
case 'CLOSED':
|
||||
_handleClosed(data);
|
||||
case 'OK':
|
||||
_handleOk(data);
|
||||
}
|
||||
@@ -385,6 +401,35 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleClosed(List<dynamic> data) {
|
||||
if (data.length < 2) return;
|
||||
final subId = data[1] as String;
|
||||
final message = data.length >= 3 && data[2] is String
|
||||
? data[2] as String
|
||||
: 'subscription closed by relay';
|
||||
|
||||
final historySub = _historySubscriptions.remove(subId);
|
||||
if (historySub != null) {
|
||||
historySub.timeout.cancel();
|
||||
if (!historySub.completer.isCompleted) {
|
||||
historySub.completer.completeError(Exception(message));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final liveSub = _liveSubscriptions.remove(subId);
|
||||
if (liveSub == null) return;
|
||||
_recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:'));
|
||||
|
||||
final readyCompleter = liveSub.readyCompleter;
|
||||
if (readyCompleter != null && !readyCompleter.isCompleted) {
|
||||
readyCompleter.completeError(Exception(message));
|
||||
return;
|
||||
}
|
||||
|
||||
liveSub.onClosed?.call(message);
|
||||
}
|
||||
|
||||
void _handleOk(List<dynamic> data) {
|
||||
if (data.length < 3) return;
|
||||
final eventId = data[1] as String;
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:sprout_mobile/features/channels/agent_activity/observer_models.dart';
|
||||
import 'package:sprout_mobile/features/channels/agent_activity/observer_subscription.dart';
|
||||
import 'package:sprout_mobile/shared/crypto/nip44.dart';
|
||||
import 'package:sprout_mobile/shared/relay/relay.dart';
|
||||
|
||||
void main() {
|
||||
test('provider initializes without circular dependency error', () {
|
||||
// Regression test: reading the provider should NOT throw
|
||||
// "Bad state: Tried to read the state of an uninitialized provider".
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relaySessionProvider.overrideWith(() => _RecordingRelaySession()),
|
||||
relayConfigProvider.overrideWith(
|
||||
() => _FakeRelayConfigNotifier(nsec: null),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
const key = (channelId: 'test-channel', agentPubkey: 'deadbeef');
|
||||
// This line threw before the fix.
|
||||
final state = container.read(observerSubscriptionProvider(key));
|
||||
expect(state.connection, ObserverConnectionState.idle);
|
||||
expect(state.transcript, isEmpty);
|
||||
});
|
||||
|
||||
test('transitions to error when nsec is invalid', () async {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relaySessionProvider.overrideWith(() => _RecordingRelaySession()),
|
||||
relayConfigProvider.overrideWith(
|
||||
() => _FakeRelayConfigNotifier(nsec: 'nsec1invalid'),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
const key = (channelId: 'test-channel', agentPubkey: 'deadbeef');
|
||||
container.read(observerSubscriptionProvider(key));
|
||||
|
||||
// Let the subscription microtask run.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final state = container.read(observerSubscriptionProvider(key));
|
||||
// With invalid nsec, it should be in error state, NOT throw.
|
||||
expect(state.connection, ObserverConnectionState.error);
|
||||
expect(state.errorMessage, isNotNull);
|
||||
});
|
||||
|
||||
test('stays idle when nsec is null', () async {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relaySessionProvider.overrideWith(() => _RecordingRelaySession()),
|
||||
relayConfigProvider.overrideWith(
|
||||
() => _FakeRelayConfigNotifier(nsec: null),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
const key = (channelId: 'test-channel', agentPubkey: 'deadbeef');
|
||||
container.read(observerSubscriptionProvider(key));
|
||||
|
||||
// Let the subscription microtask run. Without an nsec, it should no-op.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final state = container.read(observerSubscriptionProvider(key));
|
||||
expect(state.connection, ObserverConnectionState.idle);
|
||||
expect(state.transcript, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'subscribes with correct filter shape and transitions to open',
|
||||
() async {
|
||||
final userKeychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(userKeychain.private);
|
||||
final myPubkey = userKeychain.public;
|
||||
// Agent needs a valid 64-char hex pubkey for getConversationKey.
|
||||
final agentKeychain = nostr.Keychain.generate();
|
||||
final agentPubkey = agentKeychain.public;
|
||||
|
||||
final relaySession = _RecordingRelaySession();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relaySessionProvider.overrideWith(() => relaySession),
|
||||
relayConfigProvider.overrideWith(
|
||||
() => _FakeRelayConfigNotifier(nsec: nsec),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final key = (channelId: 'test-channel', agentPubkey: agentPubkey);
|
||||
container.read(observerSubscriptionProvider(key));
|
||||
|
||||
// Let the subscription microtask run.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final state = container.read(observerSubscriptionProvider(key));
|
||||
expect(state.connection, ObserverConnectionState.open);
|
||||
expect(state.transcript, isEmpty);
|
||||
|
||||
// Verify the subscription filter shape.
|
||||
expect(relaySession.filters, hasLength(1));
|
||||
final filter = relaySession.filters.first;
|
||||
expect(filter.kinds, [EventKind.agentObserverFrame]);
|
||||
expect(filter.limit, 0);
|
||||
expect(filter.tags['#p'], contains(myPubkey));
|
||||
expect(filter.since, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'uses one shared relay subscription for channel-scoped readers',
|
||||
() async {
|
||||
final userKeychain = nostr.Keychain.generate();
|
||||
final agentKeychain = nostr.Keychain.generate();
|
||||
final relaySession = _RecordingRelaySession();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relaySessionProvider.overrideWith(() => relaySession),
|
||||
relayConfigProvider.overrideWith(
|
||||
() => _FakeRelayConfigNotifier(
|
||||
nsec: nostr.Nip19.encodePrivkey(userKeychain.private),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.read(
|
||||
observerSubscriptionProvider((
|
||||
channelId: 'first-channel',
|
||||
agentPubkey: agentKeychain.public,
|
||||
)),
|
||||
);
|
||||
container.read(
|
||||
observerSubscriptionProvider((
|
||||
channelId: 'second-channel',
|
||||
agentPubkey: agentKeychain.public,
|
||||
)),
|
||||
);
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(relaySession.filters, hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
test('surfaces relay CLOSED messages through observer state', () async {
|
||||
final userKeychain = nostr.Keychain.generate();
|
||||
final agentKeychain = nostr.Keychain.generate();
|
||||
final relaySession = _RecordingRelaySession();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relaySessionProvider.overrideWith(() => relaySession),
|
||||
relayConfigProvider.overrideWith(
|
||||
() => _FakeRelayConfigNotifier(
|
||||
nsec: nostr.Nip19.encodePrivkey(userKeychain.private),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final key = (channelId: 'test-channel', agentPubkey: agentKeychain.public);
|
||||
container.read(observerSubscriptionProvider(key));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
relaySession.closeAll('restricted: p-gated events require #p');
|
||||
|
||||
final state = container.read(observerSubscriptionProvider(key));
|
||||
expect(state.connection, ObserverConnectionState.error);
|
||||
expect(state.errorMessage, contains('p-gated events require #p'));
|
||||
});
|
||||
|
||||
test('ignores stale subscribe completion after identity changes', () async {
|
||||
final firstUser = nostr.Keychain.generate();
|
||||
final secondUser = nostr.Keychain.generate();
|
||||
final agentKeychain = nostr.Keychain.generate();
|
||||
final relaySession = _RecordingRelaySession()..delaySubscribes = true;
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relaySessionProvider.overrideWith(() => relaySession),
|
||||
relayConfigProvider.overrideWith(
|
||||
() => _FakeRelayConfigNotifier(
|
||||
nsec: nostr.Nip19.encodePrivkey(firstUser.private),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final key = (channelId: 'test-channel', agentPubkey: agentKeychain.public);
|
||||
container.read(observerSubscriptionProvider(key));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(relaySession.filters, hasLength(1));
|
||||
expect(relaySession.filters.single.tags['#p'], [firstUser.public]);
|
||||
|
||||
(container.read(relayConfigProvider.notifier) as _FakeRelayConfigNotifier)
|
||||
.setNsec(nostr.Nip19.encodePrivkey(secondUser.private));
|
||||
container.read(observerSubscriptionProvider(key));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(relaySession.filters, hasLength(2));
|
||||
expect(relaySession.filters.last.tags['#p'], [secondUser.public]);
|
||||
|
||||
relaySession.releaseSubscribe(0);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(relaySession.filters, hasLength(1));
|
||||
expect(relaySession.filters.single.tags['#p'], [secondUser.public]);
|
||||
|
||||
relaySession.releaseSubscribe(1);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final state = container.read(observerSubscriptionProvider(key));
|
||||
expect(state.connection, ObserverConnectionState.open);
|
||||
expect(relaySession.filters, hasLength(1));
|
||||
});
|
||||
|
||||
test(
|
||||
'decrypts observer frames and exposes channel-scoped transcript',
|
||||
() async {
|
||||
final ownerKeychain = nostr.Keychain.generate();
|
||||
final agentKeychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(ownerKeychain.private);
|
||||
final relaySession = _RecordingRelaySession();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relaySessionProvider.overrideWith(() => relaySession),
|
||||
relayConfigProvider.overrideWith(
|
||||
() => _FakeRelayConfigNotifier(nsec: nsec),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
const channelId = 'test-channel';
|
||||
final key = (channelId: channelId, agentPubkey: agentKeychain.public);
|
||||
container.read(observerSubscriptionProvider(key));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final conversationKey = getConversationKey(
|
||||
agentKeychain.private,
|
||||
ownerKeychain.public,
|
||||
);
|
||||
final encrypted = nip44Encrypt(
|
||||
conversationKey,
|
||||
jsonEncode({
|
||||
'seq': 1,
|
||||
'timestamp': '2026-04-30T12:00:00.000Z',
|
||||
'kind': 'turn_started',
|
||||
'channelId': channelId,
|
||||
'turnId': 'turn-1',
|
||||
'payload': {
|
||||
'triggeringEventIds': ['0123456789abcdef'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
final event = nostr.Event.from(
|
||||
kind: EventKind.agentObserverFrame,
|
||||
content: encrypted,
|
||||
tags: [
|
||||
['p', ownerKeychain.public],
|
||||
['agent', agentKeychain.public],
|
||||
['frame', 'telemetry'],
|
||||
],
|
||||
privkey: agentKeychain.private,
|
||||
verify: false,
|
||||
);
|
||||
|
||||
relaySession.emit(NostrEvent.fromJson(event.toJson()));
|
||||
|
||||
final state = container.read(observerSubscriptionProvider(key));
|
||||
expect(state.connection, ObserverConnectionState.open);
|
||||
expect(state.transcript, hasLength(1));
|
||||
final item = state.transcript.single;
|
||||
expect(item, isA<LifecycleItem>());
|
||||
expect((item as LifecycleItem).title, 'Turn started');
|
||||
|
||||
final otherChannelState = container.read(
|
||||
observerSubscriptionProvider((
|
||||
channelId: 'other-channel',
|
||||
agentPubkey: agentKeychain.public,
|
||||
)),
|
||||
);
|
||||
expect(otherChannelState.transcript, isEmpty);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _RecordingRelaySession extends RelaySessionNotifier {
|
||||
final List<NostrFilter> filters = [];
|
||||
final List<void Function(NostrEvent)> _listeners = [];
|
||||
final List<void Function(String message)> _closedListeners = [];
|
||||
final List<Completer<void>> _subscribeGates = [];
|
||||
bool delaySubscribes = false;
|
||||
|
||||
@override
|
||||
SessionState build() => const SessionState(status: SessionStatus.connected);
|
||||
|
||||
@override
|
||||
Future<void Function()> subscribe(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
}) async {
|
||||
filters.add(filter);
|
||||
_listeners.add(onEvent);
|
||||
if (onClosed != null) {
|
||||
_closedListeners.add(onClosed);
|
||||
}
|
||||
if (delaySubscribes) {
|
||||
final gate = Completer<void>();
|
||||
_subscribeGates.add(gate);
|
||||
await gate.future;
|
||||
}
|
||||
return () {
|
||||
filters.remove(filter);
|
||||
_listeners.remove(onEvent);
|
||||
if (onClosed != null) {
|
||||
_closedListeners.remove(onClosed);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
void emit(NostrEvent event) {
|
||||
for (final listener in List.of(_listeners)) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
void closeAll(String message) {
|
||||
for (final listener in List.of(_closedListeners)) {
|
||||
listener(message);
|
||||
}
|
||||
filters.clear();
|
||||
_listeners.clear();
|
||||
_closedListeners.clear();
|
||||
}
|
||||
|
||||
void releaseSubscribe(int index) {
|
||||
final gate = _subscribeGates[index];
|
||||
if (!gate.isCompleted) {
|
||||
gate.complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeRelayConfigNotifier extends RelayConfigNotifier {
|
||||
String? _nsec;
|
||||
|
||||
_FakeRelayConfigNotifier({required String? nsec}) : _nsec = nsec;
|
||||
|
||||
@override
|
||||
RelayConfig build() =>
|
||||
RelayConfig(baseUrl: 'http://localhost:3000', nsec: _nsec);
|
||||
|
||||
void setNsec(String? nsec) {
|
||||
_nsec = nsec;
|
||||
state = RelayConfig(baseUrl: 'http://localhost:3000', nsec: _nsec);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sprout_mobile/features/channels/agent_activity/observer_models.dart';
|
||||
import 'package:sprout_mobile/features/channels/agent_activity/transcript_builder.dart';
|
||||
|
||||
void main() {
|
||||
test('aggregates assistant chunks until another item seals the message', () {
|
||||
final items = buildTranscript([
|
||||
_updateFrame(
|
||||
seq: 1,
|
||||
update: {
|
||||
'sessionUpdate': 'agent_message_chunk',
|
||||
'messageId': 'm1',
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'Hello'},
|
||||
],
|
||||
},
|
||||
),
|
||||
_updateFrame(
|
||||
seq: 2,
|
||||
update: {
|
||||
'sessionUpdate': 'agent_message_chunk',
|
||||
'messageId': 'm1',
|
||||
'content': [
|
||||
{'type': 'text', 'text': ' world'},
|
||||
],
|
||||
},
|
||||
),
|
||||
_updateFrame(
|
||||
seq: 3,
|
||||
update: {
|
||||
'sessionUpdate': 'tool_call',
|
||||
'toolCallId': 'tool-1',
|
||||
'title': 'sleep',
|
||||
'args': {'seconds': 5},
|
||||
},
|
||||
),
|
||||
_updateFrame(
|
||||
seq: 4,
|
||||
update: {
|
||||
'sessionUpdate': 'agent_message_chunk',
|
||||
'messageId': 'm1',
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'Done'},
|
||||
],
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
expect(items, hasLength(3));
|
||||
expect(items[0], isA<MessageItem>());
|
||||
expect((items[0] as MessageItem).text, 'Hello world');
|
||||
expect(items[1], isA<ToolItem>());
|
||||
expect(items[2], isA<MessageItem>());
|
||||
expect((items[2] as MessageItem).text, 'Done');
|
||||
});
|
||||
|
||||
test('normalizes sprout tool calls and applies result updates', () {
|
||||
final items = buildTranscript([
|
||||
_updateFrame(
|
||||
seq: 1,
|
||||
update: {
|
||||
'sessionUpdate': 'tool_call',
|
||||
'toolCallId': 'send-1',
|
||||
'title': 'Sending message to channel',
|
||||
'status': 'executing',
|
||||
'args': {'content': 'hi'},
|
||||
},
|
||||
),
|
||||
_updateFrame(
|
||||
seq: 2,
|
||||
update: {
|
||||
'sessionUpdate': 'tool_call_update',
|
||||
'toolCallId': 'send-1',
|
||||
'status': 'completed',
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'posted #activity-test-channel'},
|
||||
],
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
expect(items, hasLength(1));
|
||||
expect(items.single, isA<ToolItem>());
|
||||
final tool = items.single as ToolItem;
|
||||
expect(tool.sproutToolName, 'send_message');
|
||||
expect(tool.toolName, 'send_message');
|
||||
expect(tool.status, ToolStatus.completed);
|
||||
expect(tool.args, {'content': 'hi'});
|
||||
expect(tool.result, 'posted #activity-test-channel');
|
||||
});
|
||||
|
||||
test('parses sprout prompt text into user message and metadata', () {
|
||||
final items = buildTranscript([
|
||||
ObserverFrame(
|
||||
seq: 1,
|
||||
timestamp: _timestamp(1),
|
||||
kind: 'acp_write',
|
||||
turnId: 'turn-1',
|
||||
payload: {
|
||||
'method': 'session/prompt',
|
||||
'params': {
|
||||
'prompt': [
|
||||
{
|
||||
'content':
|
||||
'[Sprout event: stream message]\n'
|
||||
'Content: @claude can you do that again?\n\n'
|
||||
'[Channel]\n'
|
||||
'#activity-test-channel',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
expect(items, hasLength(2));
|
||||
expect(items[0], isA<MessageItem>());
|
||||
final message = items[0] as MessageItem;
|
||||
expect(message.role, 'user');
|
||||
expect(message.title, 'Stream Message');
|
||||
expect(message.text, '@claude can you do that again?');
|
||||
expect(items[1], isA<MetadataItem>());
|
||||
expect((items[1] as MetadataItem).sections, hasLength(2));
|
||||
});
|
||||
}
|
||||
|
||||
ObserverFrame _updateFrame({
|
||||
required int seq,
|
||||
required Map<String, dynamic> update,
|
||||
}) {
|
||||
return ObserverFrame(
|
||||
seq: seq,
|
||||
timestamp: _timestamp(seq),
|
||||
kind: 'acp_read',
|
||||
turnId: 'turn-1',
|
||||
payload: {
|
||||
'method': 'session/update',
|
||||
'params': {'update': update},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _timestamp(int seq) =>
|
||||
DateTime.utc(2026, 4, 30, 12, 0, seq).toIso8601String();
|
||||
@@ -144,8 +144,9 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
|
||||
@override
|
||||
Future<void Function()> subscribe(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent,
|
||||
) async {
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
}) async {
|
||||
operations.add('subscribe');
|
||||
liveFilters.add(filter);
|
||||
if (!_subscribed.isCompleted) {
|
||||
|
||||
@@ -133,8 +133,9 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
|
||||
@override
|
||||
Future<void Function()> subscribe(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent,
|
||||
) async {
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
}) async {
|
||||
filters.add(filter);
|
||||
_listeners.add(onEvent);
|
||||
return () {
|
||||
|
||||
@@ -39,6 +39,57 @@ void main() {
|
||||
unsubscribeFirst();
|
||||
unsubscribeSecond();
|
||||
});
|
||||
|
||||
test('live subscribe fails when relay closes before ready', () async {
|
||||
final session = RelaySessionNotifier();
|
||||
const filter = NostrFilter(kinds: [EventKind.agentObserverFrame], limit: 0);
|
||||
|
||||
final subscribe = session.subscribe(filter, (_) {});
|
||||
session.debugHandleMessage([
|
||||
'CLOSED',
|
||||
'l-1',
|
||||
'restricted: p-gated events require #p matching your pubkey',
|
||||
]);
|
||||
|
||||
await expectLater(
|
||||
subscribe,
|
||||
throwsA(
|
||||
isA<Exception>().having(
|
||||
(error) => error.toString(),
|
||||
'message',
|
||||
contains('p-gated events require #p'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'live onClosed callback runs when relay closes an open subscription',
|
||||
() async {
|
||||
final session = RelaySessionNotifier();
|
||||
final closedMessages = <String>[];
|
||||
const filter = NostrFilter(
|
||||
kinds: [EventKind.agentObserverFrame],
|
||||
limit: 0,
|
||||
);
|
||||
|
||||
final subscribe = session.subscribe(
|
||||
filter,
|
||||
(_) {},
|
||||
onClosed: closedMessages.add,
|
||||
);
|
||||
session.debugHandleMessage(['EOSE', 'l-1']);
|
||||
final unsubscribe = await subscribe;
|
||||
session.debugHandleMessage([
|
||||
'CLOSED',
|
||||
'l-1',
|
||||
'restricted: no longer valid',
|
||||
]);
|
||||
|
||||
expect(closedMessages, ['restricted: no longer valid']);
|
||||
unsubscribe();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const _channelId = '11111111-1111-4111-8111-111111111111';
|
||||
|
||||
Reference in New Issue
Block a user