feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,272 @@
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/l10n/l10n.dart';
import '../../../shared/theme/theme.dart';
import '../../../shared/widgets/buzz_loading_indicator.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: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
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(
context.l10n.agentLiveActivityHint,
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.gutter,
Grid.xxs,
Grid.gutter,
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) {
final localizedError =
Localizations.localeOf(context).languageCode == 'en'
? errorMessage
: context.l10n.agentObserverFailed;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.circleX, size: 24, color: context.colors.error),
const SizedBox(height: Grid.xxs),
Text(
'${context.l10n.transcriptError}: '
'${localizedError ?? context.l10n.agentUnknownError}',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
textAlign: TextAlign.center,
),
],
),
);
}
if (connection == ObserverConnectionState.idle) {
return Center(
child: Text(
context.l10n.agentNotConnected,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
);
}
// connecting or open — show spinner
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BuzzLoadingIndicator(
size: 28,
color: context.colors.onSurfaceVariant,
semanticLabel: context.l10n.waitingForAgent,
),
const SizedBox(height: Grid.xxs),
Text(
context.l10n.agentWaitingActivity,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
);
}
}
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.onSurfaceVariant,
context.l10n.agentConnectionIdle,
),
ObserverConnectionState.connecting => (
context.appColors.warning,
context.l10n.agentConnectionConnecting,
),
ObserverConnectionState.open => (
context.appColors.success,
context.l10n.agentConnectionLive,
),
ObserverConnectionState.error => (
context.colors.error,
context.l10n.agentConnectionError,
),
};
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? buzzToolName;
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.buzzToolName,
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.decode(payload: nsec).data;
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.Keys(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,714 @@
import 'dart:convert';
import 'observer_models.dart';
const _buzzReadTools = <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 _buzzWriteTools = <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 _buzzToolNames = <String>{..._buzzReadTools, ..._buzzWriteTools};
final _buzzToolNamesByLength = _buzzToolNames.toList()
..sort((a, b) => b.length.compareTo(a.length));
final _buzzToolTitleAliases = <(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'),
];
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());
}
String _normalizeToolNameText(String value) {
return value
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9_]+'), '_')
.replaceAll(RegExp(r'_+'), '_')
.replaceAll(RegExp(r'^_+|_+$'), '');
}
String? _findBuzzToolName(String value, bool includeShortNames) {
final alias = _findBuzzToolAlias(value);
if (alias != null) return alias;
final normalized = _normalizeToolNameText(value);
for (final name in _buzzToolNamesByLength) {
if ((!includeShortNames && name.length < 8) || !normalized.contains(name)) {
continue;
}
return name;
}
return null;
}
String? _findBuzzToolAlias(String value) {
final normalizedPhrase = value
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[_-]+'), ' ')
.replaceAll(RegExp(r'\s+'), ' ');
for (final (pattern, name) in _buzzToolTitleAliases) {
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 = _findBuzzToolName(title, true);
if (knownName != null) return knownName;
final normalized = _normalizeToolNameText(
title,
).replaceAll(RegExp(r'^buzz_'), '');
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;
}
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('buzz 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)
: 'Buzz 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? buzzToolName}) _extractToolIdentity(
Map<String, dynamic> update,
) {
final candidates = _collectToolNameCandidates(update);
String? knownName;
for (final c in candidates) {
knownName = _findBuzzToolName(c, true);
if (knownName != null) break;
}
knownName ??= _findBuzzToolName(_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),
buzzToolName: 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();
}
}
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? buzzToolName,
ToolStatus status,
Map<String, dynamic> args,
String result,
bool isError,
String timestamp,
) {
final existing = itemsById[id];
final canonicalBuzzToolName =
buzzToolName ?? _findBuzzToolName(toolName, true);
if (existing is ToolItem) {
if (!_isGenericToolTitle(title)) {
existing.title = title;
}
if (canonicalBuzzToolName != null) {
existing.buzzToolName = canonicalBuzzToolName;
existing.toolName = canonicalBuzzToolName;
} else if (existing.buzzToolName == 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: canonicalBuzzToolName ?? toolName,
buzzToolName: canonicalBuzzToolName,
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.buzzToolName,
_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.buzzToolName,
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,559 @@
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/l10n/l10n.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),
};
}
}
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.onSurfaceVariant;
final badgeLabel = isAssistant
? context.l10n.transcriptAssistant
: context.l10n.transcriptUser;
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,
),
),
],
),
);
}
}
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.onSurfaceVariant,
),
const SizedBox(width: Grid.half),
Expanded(
child: Text(
_localizedTranscriptLabel(context, item.title),
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
Icon(
expanded.value
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 14,
color: context.colors.onSurfaceVariant,
),
],
),
if (expanded.value && item.text.isNotEmpty) ...[
const SizedBox(height: Grid.half),
GptMarkdown(
item.text,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
],
),
),
),
);
}
}
class _LifecycleItemWidget extends StatelessWidget {
final LifecycleItem item;
const _LifecycleItemWidget({required this.item});
@override
Widget build(BuildContext context) {
final localizedText = _localizedTranscriptText(context, item.text);
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.half),
child: Center(
child: Text(
'${_localizedTranscriptLabel(context, item.title)}'
'${localizedText.isNotEmpty ? ' \u2014 $localizedText' : ''}',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
);
}
}
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.onSurfaceVariant,
),
const SizedBox(width: Grid.half),
Expanded(
child: Text(
'${_localizedTranscriptLabel(context, item.title)} '
'(${context.l10n.transcriptSections(item.sections.length)})',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
Icon(
expanded.value
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 14,
color: context.colors.onSurfaceVariant,
),
],
),
if (expanded.value)
for (final section in item.sections) ...[
const SizedBox(height: Grid.xxs),
Text(
_localizedTranscriptLabel(context, 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.onSurfaceVariant,
),
),
],
],
],
),
),
),
);
}
}
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(
context.l10n.transcriptArguments,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(width: Grid.half),
Icon(
argsExpanded.value
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 12,
color: context.colors.onSurfaceVariant,
),
],
),
),
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(
context.l10n.transcriptResult,
style: context.textTheme.labelSmall?.copyWith(
color: item.isError
? context.colors.error
: context.colors.onSurfaceVariant,
),
),
const SizedBox(width: Grid.half),
Icon(
resultExpanded.value
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 12,
color: item.isError
? context.colors.error
: context.colors.onSurfaceVariant,
),
],
),
),
if (resultExpanded.value) ...[
const SizedBox(height: Grid.quarter),
_CodeBlock(
text: item.result.length > 2000
? '${item.result.substring(0, 2000)}\n\n'
'${context.l10n.transcriptTruncated}'
: item.result,
isError: item.isError,
),
],
],
],
),
),
);
}
}
(Color, String, IconData) _toolStatusDisplay(
ToolStatus status,
bool isError,
BuildContext context,
) {
if (isError || status == ToolStatus.failed) {
return (
context.colors.error,
context.l10n.transcriptError,
LucideIcons.circleX,
);
}
if (status == ToolStatus.completed) {
return (
context.appColors.success,
context.l10n.transcriptDone,
LucideIcons.circleCheck,
);
}
if (status == ToolStatus.pending) {
return (
context.colors.onSurfaceVariant,
context.l10n.transcriptPending,
LucideIcons.circleDot,
);
}
return (
context.appColors.warning,
context.l10n.transcriptRunning,
LucideIcons.clock3,
);
}
String _formatToolName(String toolName) {
return toolName
.split('_')
.map(
(part) =>
part.isEmpty ? '' : '${part[0].toUpperCase()}${part.substring(1)}',
)
.join(' ');
}
String _localizedTranscriptLabel(BuildContext context, String label) {
final l10n = context.l10n;
return switch (label) {
'Assistant' => l10n.transcriptAssistant,
'Base' => l10n.transcriptBase,
'Buzz event' => l10n.transcriptBuzzEvent,
'Channel Canvas' => l10n.transcriptChannelCanvas,
'Core Memory' => l10n.transcriptCoreMemory,
'Plan' => l10n.transcriptPlan,
'Prompt' => l10n.prompt,
'Prompt context' => l10n.transcriptPromptContext,
'Session ready' => l10n.transcriptSessionReady,
'System' => l10n.transcriptSystem,
'System prompt' => l10n.transcriptSystemPrompt,
'Team Instructions' => l10n.transcriptTeamInstructions,
'Thinking' => l10n.transcriptThinking,
'Tool call' => l10n.transcriptToolCall,
'Turn started' => l10n.transcriptTurnStarted,
'User' => l10n.transcriptUser,
'Wire parse error' => l10n.transcriptWireParseError,
_ => label,
};
}
String _localizedTranscriptText(BuildContext context, String text) {
final l10n = context.l10n;
if (text == 'Heartbeat or internal turn.') {
return l10n.transcriptInternalTurn;
}
if (text == 'Using existing ACP session.') {
return l10n.transcriptUsingExistingSession;
}
if (text.startsWith('Triggered by ') && text.endsWith('.')) {
return l10n.transcriptTriggeredBy(
text.substring('Triggered by '.length, text.length - 1),
);
}
if (text.startsWith('Created session ') && text.endsWith('.')) {
return l10n.transcriptCreatedSession(
text.substring('Created session '.length, text.length - 1),
);
}
if (text.startsWith('Using session ') && text.endsWith('.')) {
return l10n.transcriptUsingSession(
text.substring('Using session '.length, text.length - 1),
);
}
return text;
}
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,27 @@
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.autoDispose
.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(),
};
});