mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
Add inline mobile agent activity
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../shared/relay/relay.dart';
|
||||
import 'observer_models.dart';
|
||||
import 'observer_subscription.dart';
|
||||
|
||||
const _defaultLivenessTimeout = Duration(seconds: 30);
|
||||
const _activeTurnClockInterval = Duration(seconds: 5);
|
||||
const _maximumLivenessInterval = Duration(hours: 24);
|
||||
const _livenessTimeoutSlack = Duration(seconds: 30);
|
||||
|
||||
/// Lifecycle state reconstructed from owner-scoped observer frames.
|
||||
enum AgentTurnPhase { working, finished, error }
|
||||
|
||||
/// One observed agent turn, including its explicit terminal outcome when known.
|
||||
@immutable
|
||||
class AgentTurnState {
|
||||
final String agentPubkey;
|
||||
final String channelId;
|
||||
final String turnId;
|
||||
final DateTime startedAt;
|
||||
final DateTime lastActivityAt;
|
||||
final Duration livenessTimeout;
|
||||
final AgentTurnPhase phase;
|
||||
final DateTime? terminalAt;
|
||||
final String? errorMessage;
|
||||
final String? triggeringEventId;
|
||||
|
||||
const AgentTurnState({
|
||||
required this.agentPubkey,
|
||||
required this.channelId,
|
||||
required this.turnId,
|
||||
required this.startedAt,
|
||||
required this.lastActivityAt,
|
||||
required this.livenessTimeout,
|
||||
required this.phase,
|
||||
this.terminalAt,
|
||||
this.errorMessage,
|
||||
this.triggeringEventId,
|
||||
});
|
||||
|
||||
bool get isWorking => phase == AgentTurnPhase.working;
|
||||
|
||||
AgentTurnState withActivity({required DateTime at, Duration? timeout}) =>
|
||||
AgentTurnState(
|
||||
agentPubkey: agentPubkey,
|
||||
channelId: channelId,
|
||||
turnId: turnId,
|
||||
startedAt: startedAt,
|
||||
lastActivityAt: at,
|
||||
livenessTimeout: timeout ?? livenessTimeout,
|
||||
phase: AgentTurnPhase.working,
|
||||
triggeringEventId: triggeringEventId,
|
||||
);
|
||||
|
||||
AgentTurnState withTerminal({
|
||||
required AgentTurnPhase phase,
|
||||
required DateTime at,
|
||||
String? errorMessage,
|
||||
}) => AgentTurnState(
|
||||
agentPubkey: agentPubkey,
|
||||
channelId: channelId,
|
||||
turnId: turnId,
|
||||
startedAt: startedAt,
|
||||
lastActivityAt: at,
|
||||
livenessTimeout: livenessTimeout,
|
||||
phase: phase,
|
||||
terminalAt: at,
|
||||
errorMessage: errorMessage,
|
||||
triggeringEventId: triggeringEventId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Reconstructs live and explicitly terminal turns from the retained frame
|
||||
/// buffer. Silent working turns expire instead of being mislabeled finished.
|
||||
List<AgentTurnState> reduceAgentTurnStates(
|
||||
Map<String, List<ObserverFrame>> framesByAgent, {
|
||||
required DateTime now,
|
||||
}) {
|
||||
final states = <AgentTurnState>[];
|
||||
|
||||
for (final entry in framesByAgent.entries) {
|
||||
final agentPubkey = entry.key.toLowerCase();
|
||||
final frames = [...entry.value]..sort(_compareFrames);
|
||||
final turnsById = <String, AgentTurnState>{};
|
||||
final terminalOrderById = <String, DateTime>{};
|
||||
|
||||
for (final frame in frames) {
|
||||
final frameOrderAt = _frameTimestamp(frame);
|
||||
final frameAt = frame.receivedAt ?? frameOrderAt;
|
||||
switch (frame.kind) {
|
||||
case 'turn_started':
|
||||
final channelId = frame.channelId;
|
||||
if (channelId == null) continue;
|
||||
final turnId = frame.turnId ?? 'seq-${frame.seq}';
|
||||
turnsById[turnId] = AgentTurnState(
|
||||
agentPubkey: agentPubkey,
|
||||
channelId: channelId,
|
||||
turnId: turnId,
|
||||
startedAt: _safeStartedAt(frame, frameAt),
|
||||
lastActivityAt: frameAt,
|
||||
livenessTimeout: _livenessTimeout(frame.payload),
|
||||
phase: AgentTurnPhase.working,
|
||||
triggeringEventId: _triggeringEventId(frame.payload),
|
||||
);
|
||||
case 'turn_completed':
|
||||
case 'turn_error':
|
||||
case 'agent_panic':
|
||||
final terminalPhase = frame.kind == 'turn_completed'
|
||||
? AgentTurnPhase.finished
|
||||
: AgentTurnPhase.error;
|
||||
final turnId = frame.turnId;
|
||||
if (turnId != null) {
|
||||
terminalOrderById[turnId] = frameOrderAt;
|
||||
final existing = turnsById[turnId];
|
||||
final channelId = existing?.channelId ?? frame.channelId;
|
||||
if (channelId == null) continue;
|
||||
turnsById[turnId] =
|
||||
existing?.withTerminal(
|
||||
phase: terminalPhase,
|
||||
at: frameAt,
|
||||
errorMessage: _turnError(frame.payload),
|
||||
) ??
|
||||
AgentTurnState(
|
||||
agentPubkey: agentPubkey,
|
||||
channelId: channelId,
|
||||
turnId: turnId,
|
||||
startedAt: _safeStartedAt(frame, frameAt),
|
||||
lastActivityAt: frameAt,
|
||||
livenessTimeout: _livenessTimeout(frame.payload),
|
||||
phase: terminalPhase,
|
||||
terminalAt: frameAt,
|
||||
errorMessage: _turnError(frame.payload),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
final channelId = frame.channelId;
|
||||
if (channelId == null) continue;
|
||||
final matching = turnsById.values
|
||||
.where((turn) => turn.channelId == channelId && turn.isWorking)
|
||||
.fold<AgentTurnState?>(
|
||||
null,
|
||||
(latest, turn) =>
|
||||
latest == null ||
|
||||
turn.lastActivityAt.isAfter(latest.lastActivityAt)
|
||||
? turn
|
||||
: latest,
|
||||
);
|
||||
if (matching == null) continue;
|
||||
terminalOrderById[matching.turnId] = frameOrderAt;
|
||||
turnsById[matching.turnId] = matching.withTerminal(
|
||||
phase: terminalPhase,
|
||||
at: frameAt,
|
||||
errorMessage: _turnError(frame.payload),
|
||||
);
|
||||
case 'acp_read':
|
||||
case 'acp_write':
|
||||
case 'turn_liveness':
|
||||
final turnId = frame.turnId;
|
||||
if (turnId == null) continue;
|
||||
final existing = turnsById[turnId];
|
||||
if (existing?.isWorking == true) {
|
||||
turnsById[turnId] = existing!.withActivity(
|
||||
at: frameAt,
|
||||
timeout: frame.kind == 'turn_liveness'
|
||||
? _livenessTimeout(frame.payload)
|
||||
: null,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
final terminalOrder = terminalOrderById[turnId];
|
||||
if (terminalOrder != null && !frameOrderAt.isAfter(terminalOrder)) {
|
||||
continue;
|
||||
}
|
||||
final channelId = frame.channelId;
|
||||
if (channelId == null) continue;
|
||||
turnsById[turnId] = AgentTurnState(
|
||||
agentPubkey: agentPubkey,
|
||||
channelId: channelId,
|
||||
turnId: turnId,
|
||||
startedAt: _safeStartedAt(frame, frameAt),
|
||||
lastActivityAt: frameAt,
|
||||
livenessTimeout: _livenessTimeout(frame.payload),
|
||||
phase: AgentTurnPhase.working,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
states.addAll(
|
||||
turnsById.values.where(
|
||||
(turn) =>
|
||||
!turn.isWorking ||
|
||||
now.difference(turn.lastActivityAt) <= turn.livenessTimeout,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
states.sort((a, b) {
|
||||
final started = a.startedAt.compareTo(b.startedAt);
|
||||
if (started != 0) return started;
|
||||
final agent = a.agentPubkey.compareTo(b.agentPubkey);
|
||||
if (agent != 0) return agent;
|
||||
return a.turnId.compareTo(b.turnId);
|
||||
});
|
||||
return List.unmodifiable(states);
|
||||
}
|
||||
|
||||
/// Most recent state for one agent in one channel.
|
||||
AgentTurnState? latestAgentTurnState(
|
||||
Iterable<AgentTurnState> states, {
|
||||
required String agentPubkey,
|
||||
required String channelId,
|
||||
String? turnId,
|
||||
}) {
|
||||
final normalizedAgent = agentPubkey.toLowerCase();
|
||||
AgentTurnState? latest;
|
||||
for (final state in states) {
|
||||
if (state.agentPubkey != normalizedAgent ||
|
||||
state.channelId != channelId ||
|
||||
(turnId != null && state.turnId != turnId)) {
|
||||
continue;
|
||||
}
|
||||
if (latest == null || state.lastActivityAt.isAfter(latest.lastActivityAt)) {
|
||||
latest = state;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
final _activeAgentTurnClockProvider = StreamProvider<DateTime>((ref) async* {
|
||||
yield DateTime.now().toUtc();
|
||||
yield* Stream<DateTime>.periodic(
|
||||
_activeTurnClockInterval,
|
||||
(_) => DateTime.now().toUtc(),
|
||||
);
|
||||
});
|
||||
|
||||
/// Observer-derived turn states, including buffered terminal outcomes.
|
||||
final agentTurnStatesProvider = Provider<List<AgentTurnState>>((ref) {
|
||||
final observerState = ref.watch(observerRelayProvider);
|
||||
if (observerState.framesByAgent.isEmpty) return const [];
|
||||
ref.watch(appLifecycleProvider);
|
||||
final now =
|
||||
ref.watch(_activeAgentTurnClockProvider).value ?? DateTime.now().toUtc();
|
||||
return reduceAgentTurnStates(observerState.framesByAgent, now: now);
|
||||
});
|
||||
|
||||
/// Observer-derived turns that are still live according to local receipt time.
|
||||
final activeAgentTurnsProvider = Provider<List<AgentTurnState>>((ref) {
|
||||
return [
|
||||
for (final turn in ref.watch(agentTurnStatesProvider))
|
||||
if (turn.isWorking) turn,
|
||||
];
|
||||
});
|
||||
|
||||
DateTime _frameTimestamp(ObserverFrame frame) =>
|
||||
DateTime.tryParse(frame.timestamp)?.toUtc() ??
|
||||
DateTime.fromMillisecondsSinceEpoch(frame.seq, isUtc: true);
|
||||
|
||||
DateTime _safeStartedAt(ObserverFrame frame, DateTime frameAt) {
|
||||
final hostFrameAt = DateTime.tryParse(frame.timestamp)?.toUtc();
|
||||
final hostStartedAt = DateTime.tryParse(frame.startedAt ?? '')?.toUtc();
|
||||
if (hostFrameAt == null ||
|
||||
hostStartedAt == null ||
|
||||
hostStartedAt.isAfter(hostFrameAt)) {
|
||||
return frameAt;
|
||||
}
|
||||
return frameAt.subtract(hostFrameAt.difference(hostStartedAt));
|
||||
}
|
||||
|
||||
String? _triggeringEventId(dynamic payload) {
|
||||
if (payload is! Map) return null;
|
||||
final eventIds = payload['triggeringEventIds'];
|
||||
if (eventIds is! List) return null;
|
||||
for (final eventId in eventIds) {
|
||||
if (eventId is String && eventId.isNotEmpty) return eventId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _turnError(dynamic payload) {
|
||||
if (payload is! Map) return null;
|
||||
final error = payload['error'];
|
||||
return error is String && error.trim().isNotEmpty ? error.trim() : null;
|
||||
}
|
||||
|
||||
Duration _livenessTimeout(dynamic payload) {
|
||||
final rawInterval = payload is Map ? payload['livenessIntervalSecs'] : null;
|
||||
if (rawInterval is! num) return _defaultLivenessTimeout;
|
||||
|
||||
final intervalSeconds = rawInterval.toInt();
|
||||
if (intervalSeconds <= 0) {
|
||||
return _maximumLivenessInterval + _livenessTimeoutSlack;
|
||||
}
|
||||
final boundedInterval = intervalSeconds.clamp(
|
||||
5,
|
||||
_maximumLivenessInterval.inSeconds,
|
||||
);
|
||||
final timeoutSeconds = boundedInterval + _livenessTimeoutSlack.inSeconds;
|
||||
return Duration(
|
||||
seconds: timeoutSeconds < _defaultLivenessTimeout.inSeconds
|
||||
? _defaultLivenessTimeout.inSeconds
|
||||
: timeoutSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
int _compareFrames(ObserverFrame a, ObserverFrame b) {
|
||||
final timestamp = _frameTimestamp(a).compareTo(_frameTimestamp(b));
|
||||
return timestamp != 0 ? timestamp : a.seq.compareTo(b.seq);
|
||||
}
|
||||
@@ -0,0 +1,958 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
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 '../../../shared/utils/string_utils.dart';
|
||||
import '../../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../profile/user_cache_provider.dart';
|
||||
import '../../profile/user_profile.dart';
|
||||
import '../channel_typing_indicator.dart';
|
||||
import '../small_avatar.dart';
|
||||
import 'active_agent_turns.dart';
|
||||
import 'observer_models.dart';
|
||||
import 'observer_subscription.dart';
|
||||
import 'working_bots_provider.dart';
|
||||
|
||||
part 'composer_agent_activity_indicator/agent_activity_controls.dart';
|
||||
part 'composer_agent_activity_indicator/compact_activity_item.dart';
|
||||
|
||||
/// Composer-adjacent agent status that expands upward into a bounded activity
|
||||
/// stream without moving or covering the composer itself.
|
||||
class ComposerAgentActivityIndicator extends HookConsumerWidget {
|
||||
static const _compactSurfaceHeight = 52.0;
|
||||
static const _expandedTargetHeight = 328.0;
|
||||
static const _surfaceMorphDuration = Duration(milliseconds: 220);
|
||||
static const _surfaceMorphCurve = Cubic(0.77, 0, 0.175, 1);
|
||||
|
||||
final String channelId;
|
||||
final String? threadHeadId;
|
||||
final bool animated;
|
||||
final double horizontalInset;
|
||||
final double? overlayTopBoundary;
|
||||
final double compactWidthFactor;
|
||||
final Animation<double>? composerWidthAnimation;
|
||||
final FocusNode? composerFocusNode;
|
||||
final ValueNotifier<bool>? composerInteractionLock;
|
||||
final ValueNotifier<int>? composerActivationRequests;
|
||||
final VoidCallback? onRestoreComposerFocus;
|
||||
|
||||
const ComposerAgentActivityIndicator({
|
||||
super.key,
|
||||
required this.channelId,
|
||||
this.threadHeadId,
|
||||
this.animated = true,
|
||||
this.horizontalInset = Grid.twelve,
|
||||
this.overlayTopBoundary,
|
||||
this.compactWidthFactor = 1,
|
||||
this.composerWidthAnimation,
|
||||
this.composerFocusNode,
|
||||
this.composerInteractionLock,
|
||||
this.composerActivationRequests,
|
||||
this.onRestoreComposerFocus,
|
||||
}) : assert(compactWidthFactor > 0 && compactWidthFactor <= 1);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final activity = ref.watch(
|
||||
composerActivityStateProvider((
|
||||
channelId: channelId,
|
||||
threadHeadId: threadHeadId,
|
||||
)),
|
||||
);
|
||||
final turnStates = ref.watch(agentTurnStatesProvider);
|
||||
final profiles = ref.watch(userCacheProvider);
|
||||
final expanded = useState(false);
|
||||
final pendingExpansion = useState(false);
|
||||
final selectedAgent = useState<String?>(null);
|
||||
final pinnedTurnId = useState<String?>(null);
|
||||
final rememberedAgents = useState<List<String>>(const []);
|
||||
final activityAnchorKey = useMemoized(GlobalKey.new);
|
||||
final activityOverlayController = useMemoized(OverlayPortalController.new);
|
||||
final compactActivityWidth = useState(0.0);
|
||||
final expandedHeight = useState(_expandedTargetHeight);
|
||||
final expansionController = useAnimationController(
|
||||
duration: _surfaceMorphDuration,
|
||||
);
|
||||
final scrollController = useScrollController();
|
||||
final followsTail = useRef(true);
|
||||
final composerFocusToRestore = useRef<FocusNode?>(null);
|
||||
final restoreComposerFocus = useRef(false);
|
||||
final previousTranscriptVersion = useRef('');
|
||||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final motionDisabled = !animated || reducedMotion;
|
||||
final mediaSize = MediaQuery.sizeOf(context);
|
||||
final viewInsets = MediaQuery.viewInsetsOf(context);
|
||||
final viewPadding = MediaQuery.viewPaddingOf(context);
|
||||
final platformView = View.of(context);
|
||||
final keyboardInsetBottom = math.max(
|
||||
viewInsets.bottom,
|
||||
platformView.viewInsets.bottom / platformView.devicePixelRatio,
|
||||
);
|
||||
final widthAnimation = composerWidthAnimation ?? kAlwaysDismissedAnimation;
|
||||
useListenable(widthAnimation);
|
||||
final composerWidthProgress = widthAnimation.value
|
||||
.clamp(0.0, 1.0)
|
||||
.toDouble();
|
||||
|
||||
void updateOverlayGeometry() {
|
||||
final renderObject = activityAnchorKey.currentContext?.findRenderObject();
|
||||
if (renderObject is! RenderBox || !renderObject.hasSize) return;
|
||||
final anchorBottom = renderObject
|
||||
.localToGlobal(Offset(0, renderObject.size.height))
|
||||
.dy;
|
||||
final topBoundary = overlayTopBoundary ?? frostedAppBarHeight(context);
|
||||
final availableHeight = math.max(
|
||||
_compactSurfaceHeight,
|
||||
anchorBottom - topBoundary - Grid.xxs,
|
||||
);
|
||||
final nextExpandedHeight = math.min(
|
||||
_expandedTargetHeight,
|
||||
availableHeight,
|
||||
);
|
||||
final nextCompactWidth = renderObject.size.width;
|
||||
if ((compactActivityWidth.value - nextCompactWidth).abs() >= 0.5) {
|
||||
compactActivityWidth.value = nextCompactWidth;
|
||||
}
|
||||
if ((expandedHeight.value - nextExpandedHeight).abs() >= 0.5) {
|
||||
expandedHeight.value = nextExpandedHeight;
|
||||
}
|
||||
}
|
||||
|
||||
void releaseComposerFocusLock() {
|
||||
final interactionLock = composerInteractionLock;
|
||||
if (interactionLock != null && interactionLock.value) {
|
||||
interactionLock.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void restoreComposerAfterCollapse() {
|
||||
final focusNode = composerFocusToRestore.value;
|
||||
final shouldRestore = restoreComposerFocus.value;
|
||||
composerFocusToRestore.value = null;
|
||||
restoreComposerFocus.value = false;
|
||||
releaseComposerFocusLock();
|
||||
if (!shouldRestore || focusNode == null) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted) return;
|
||||
if (onRestoreComposerFocus != null) {
|
||||
onRestoreComposerFocus!();
|
||||
} else if (focusNode.canRequestFocus) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
return () {
|
||||
final interactionLock = composerInteractionLock;
|
||||
if (interactionLock != null && interactionLock.value) {
|
||||
interactionLock.value = false;
|
||||
}
|
||||
};
|
||||
}, [composerInteractionLock]);
|
||||
|
||||
void hideActivityOverlay() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted && activityOverlayController.isShowing) {
|
||||
activityOverlayController.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) updateOverlayGeometry();
|
||||
});
|
||||
return null;
|
||||
},
|
||||
[
|
||||
mediaSize.height,
|
||||
keyboardInsetBottom,
|
||||
viewPadding.top,
|
||||
overlayTopBoundary,
|
||||
expanded.value,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
if (!pendingExpansion.value ||
|
||||
keyboardInsetBottom > 0.5 ||
|
||||
composerWidthProgress > 0.001) {
|
||||
return null;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted || !pendingExpansion.value) return;
|
||||
updateOverlayGeometry();
|
||||
activityOverlayController.show();
|
||||
pendingExpansion.value = false;
|
||||
expanded.value = true;
|
||||
});
|
||||
return null;
|
||||
}, [pendingExpansion.value, keyboardInsetBottom, composerWidthProgress]);
|
||||
|
||||
useEffect(() {
|
||||
if (motionDisabled) {
|
||||
expansionController.value = expanded.value ? 1 : 0;
|
||||
} else if (expanded.value) {
|
||||
expansionController.forward();
|
||||
} else {
|
||||
expansionController.reverse();
|
||||
}
|
||||
return null;
|
||||
}, [expanded.value, motionDisabled, expansionController]);
|
||||
|
||||
useEffect(() {
|
||||
void clearSelectionAfterCollapse(AnimationStatus status) {
|
||||
if (status != AnimationStatus.dismissed || expanded.value) return;
|
||||
selectedAgent.value = null;
|
||||
pinnedTurnId.value = null;
|
||||
rememberedAgents.value = const [];
|
||||
hideActivityOverlay();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted ||
|
||||
expanded.value ||
|
||||
!expansionController.isDismissed) {
|
||||
return;
|
||||
}
|
||||
restoreComposerAfterCollapse();
|
||||
});
|
||||
}
|
||||
|
||||
expansionController.addStatusListener(clearSelectionAfterCollapse);
|
||||
return () =>
|
||||
expansionController.removeStatusListener(clearSelectionAfterCollapse);
|
||||
}, [expansionController]);
|
||||
|
||||
final viewableWorking = [
|
||||
for (final signal in activity.agents)
|
||||
if (signal.canViewActivity) signal,
|
||||
];
|
||||
final statusOnlyAgents = [
|
||||
for (final signal in activity.agents)
|
||||
if (!signal.canViewActivity) signal,
|
||||
];
|
||||
final effectiveSelectedAgent =
|
||||
selectedAgent.value ?? viewableWorking.firstOrNull?.pubkey;
|
||||
final selectedSignal = viewableWorking
|
||||
.where((signal) => signal.pubkey == effectiveSelectedAgent)
|
||||
.firstOrNull;
|
||||
final effectiveTurnId = pinnedTurnId.value ?? selectedSignal?.turnId;
|
||||
final selectedTurn =
|
||||
effectiveSelectedAgent == null || effectiveTurnId == null
|
||||
? null
|
||||
: latestAgentTurnState(
|
||||
turnStates,
|
||||
agentPubkey: effectiveSelectedAgent,
|
||||
channelId: channelId,
|
||||
turnId: effectiveTurnId,
|
||||
);
|
||||
final ObserverState? observerState;
|
||||
if (effectiveSelectedAgent == null || effectiveTurnId == null) {
|
||||
observerState = null;
|
||||
} else {
|
||||
observerState = ref.watch(
|
||||
observerTurnSubscriptionProvider((
|
||||
channelId: channelId,
|
||||
agentPubkey: effectiveSelectedAgent,
|
||||
turnId: effectiveTurnId,
|
||||
)),
|
||||
);
|
||||
}
|
||||
final transcript = compactActivityItems(
|
||||
observerState?.transcript ?? const [],
|
||||
);
|
||||
|
||||
String nameFor(String pubkey) =>
|
||||
profiles[pubkey.toLowerCase()]?.label ?? shortPubkey(pubkey);
|
||||
|
||||
useEffect(() {
|
||||
final pubkeys = {
|
||||
...activity.agents.map((signal) => signal.pubkey),
|
||||
?effectiveSelectedAgent,
|
||||
};
|
||||
if (pubkeys.isNotEmpty) {
|
||||
ref.read(userCacheProvider.notifier).preload(pubkeys.toList());
|
||||
}
|
||||
return null;
|
||||
}, [activity.agents, effectiveSelectedAgent]);
|
||||
|
||||
useEffect(() {
|
||||
final turnId = selectedSignal?.turnId;
|
||||
if (!expanded.value || pinnedTurnId.value != null || turnId == null) {
|
||||
return null;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted && expanded.value && pinnedTurnId.value == null) {
|
||||
pinnedTurnId.value = turnId;
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}, [expanded.value, selectedSignal?.turnId]);
|
||||
|
||||
final transcriptVersion = _transcriptVersion(transcript);
|
||||
useEffect(() {
|
||||
if (transcriptVersion == previousTranscriptVersion.value) return null;
|
||||
previousTranscriptVersion.value = transcriptVersion;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (scrollController.hasClients && followsTail.value) {
|
||||
scrollController.jumpTo(scrollController.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}, [transcriptVersion, effectiveSelectedAgent]);
|
||||
|
||||
void collapse() {
|
||||
pendingExpansion.value = false;
|
||||
expanded.value = false;
|
||||
followsTail.value = true;
|
||||
if (expansionController.isDismissed) {
|
||||
selectedAgent.value = null;
|
||||
pinnedTurnId.value = null;
|
||||
rememberedAgents.value = const [];
|
||||
hideActivityOverlay();
|
||||
restoreComposerAfterCollapse();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
final requests = composerActivationRequests;
|
||||
if (requests == null) return null;
|
||||
void handOffToComposer() {
|
||||
if (!expanded.value && !pendingExpansion.value) return;
|
||||
if (composerInteractionLock != null) {
|
||||
composerInteractionLock!.value = true;
|
||||
}
|
||||
final focusNode = composerFocusNode;
|
||||
if (focusNode != null) {
|
||||
restoreComposerFocus.value = true;
|
||||
composerFocusToRestore.value = focusNode;
|
||||
}
|
||||
collapse();
|
||||
}
|
||||
|
||||
requests.addListener(handOffToComposer);
|
||||
return () => requests.removeListener(handOffToComposer);
|
||||
},
|
||||
[
|
||||
composerActivationRequests,
|
||||
composerFocusNode,
|
||||
expanded.value,
|
||||
pendingExpansion.value,
|
||||
],
|
||||
);
|
||||
|
||||
void selectAgent(String pubkey) {
|
||||
final signal = viewableWorking
|
||||
.where((candidate) => candidate.pubkey == pubkey)
|
||||
.firstOrNull;
|
||||
final latestTurn = latestAgentTurnState(
|
||||
turnStates,
|
||||
agentPubkey: pubkey,
|
||||
channelId: channelId,
|
||||
);
|
||||
selectedAgent.value = pubkey;
|
||||
pinnedTurnId.value = signal?.turnId ?? latestTurn?.turnId;
|
||||
followsTail.value = true;
|
||||
}
|
||||
|
||||
void toggleExpanded() {
|
||||
if (expanded.value || pendingExpansion.value) {
|
||||
collapse();
|
||||
return;
|
||||
}
|
||||
final initial = effectiveSelectedAgent;
|
||||
if (initial == null) return;
|
||||
selectedAgent.value = initial;
|
||||
pinnedTurnId.value = selectedSignal?.turnId;
|
||||
rememberedAgents.value = [
|
||||
for (final signal in viewableWorking) signal.pubkey,
|
||||
];
|
||||
followsTail.value = true;
|
||||
final wasKeyboardOpen = keyboardInsetBottom > 0.5;
|
||||
final focusedNode =
|
||||
composerFocusNode ?? FocusManager.instance.primaryFocus;
|
||||
restoreComposerFocus.value =
|
||||
wasKeyboardOpen && focusedNode?.hasFocus == true;
|
||||
composerFocusToRestore.value = restoreComposerFocus.value
|
||||
? focusedNode
|
||||
: null;
|
||||
if (composerInteractionLock != null) {
|
||||
composerInteractionLock!.value = true;
|
||||
}
|
||||
if (composerFocusNode != null) {
|
||||
composerFocusNode!.unfocus(
|
||||
disposition: UnfocusDisposition.previouslyFocusedChild,
|
||||
);
|
||||
} else if (focusedNode != null) {
|
||||
focusedNode.unfocus(
|
||||
disposition: UnfocusDisposition.previouslyFocusedChild,
|
||||
);
|
||||
}
|
||||
if (wasKeyboardOpen || composerWidthProgress > 0.001) {
|
||||
pendingExpansion.value = true;
|
||||
} else {
|
||||
updateOverlayGeometry();
|
||||
activityOverlayController.show();
|
||||
expanded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
final selectorAgents = <String>{
|
||||
...rememberedAgents.value,
|
||||
...viewableWorking.map((signal) => signal.pubkey),
|
||||
?effectiveSelectedAgent,
|
||||
}.toList();
|
||||
final hasContent =
|
||||
expanded.value ||
|
||||
rememberedAgents.value.isNotEmpty ||
|
||||
activity.agents.isNotEmpty ||
|
||||
activity.humanTyping.isNotEmpty;
|
||||
final child = hasContent
|
||||
? Column(
|
||||
key: const ValueKey('composer-activity-region'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if ((viewableWorking.isNotEmpty ||
|
||||
rememberedAgents.value.isNotEmpty) &&
|
||||
effectiveSelectedAgent != null)
|
||||
OverlayPortal.overlayChildLayoutBuilder(
|
||||
controller: activityOverlayController,
|
||||
overlayChildBuilder: (context, layoutInfo) {
|
||||
final anchorOrigin = MatrixUtils.transformPoint(
|
||||
layoutInfo.childPaintTransform,
|
||||
Offset.zero,
|
||||
);
|
||||
final availableWidth = math.max(
|
||||
layoutInfo.childSize.width,
|
||||
layoutInfo.overlaySize.width - Grid.twelve * 2,
|
||||
);
|
||||
final expandedActivityWidth = math.max(
|
||||
layoutInfo.childSize.width,
|
||||
math.min(
|
||||
availableWidth,
|
||||
layoutInfo.childSize.width / compactWidthFactor,
|
||||
),
|
||||
);
|
||||
return Stack(
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: expansionController,
|
||||
builder: (context, _) {
|
||||
final progress = _surfaceMorphCurve.transform(
|
||||
expansionController.value,
|
||||
);
|
||||
final panelViewportHeight =
|
||||
_compactSurfaceHeight +
|
||||
(expandedHeight.value - _compactSurfaceHeight) *
|
||||
progress;
|
||||
final panelWidth =
|
||||
compactActivityWidth.value +
|
||||
(expandedActivityWidth -
|
||||
compactActivityWidth.value) *
|
||||
progress;
|
||||
if (panelWidth <= 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Positioned(
|
||||
left:
|
||||
(layoutInfo.overlaySize.width - panelWidth) /
|
||||
2,
|
||||
bottom:
|
||||
layoutInfo.overlaySize.height -
|
||||
anchorOrigin.dy -
|
||||
layoutInfo.childSize.height,
|
||||
child: SizedBox(
|
||||
key: const ValueKey(
|
||||
'composer-agent-activity-overlay',
|
||||
),
|
||||
width: panelWidth,
|
||||
height: panelViewportHeight,
|
||||
child: IgnorePointer(
|
||||
ignoring: expansionController.isDismissed,
|
||||
child: ExcludeSemantics(
|
||||
excluding: expansionController.isDismissed,
|
||||
child: ClipRect(
|
||||
child: _InlineActivityPanel(
|
||||
height: panelViewportHeight,
|
||||
expandedHeight: expandedHeight.value,
|
||||
morphProgress: progress,
|
||||
targetExpanded: expanded.value,
|
||||
agentName: nameFor(
|
||||
effectiveSelectedAgent,
|
||||
),
|
||||
selectedTurn: selectedTurn,
|
||||
isFallbackWorking:
|
||||
selectedSignal != null,
|
||||
observerState: observerState,
|
||||
transcript: transcript,
|
||||
profiles: profiles,
|
||||
selectorAgents: selectorAgents,
|
||||
selectedAgent: effectiveSelectedAgent,
|
||||
nameFor: nameFor,
|
||||
onSelectAgent: selectAgent,
|
||||
onToggleExpanded: toggleExpanded,
|
||||
horizontalInset: horizontalInset,
|
||||
scrollController: scrollController,
|
||||
onScroll: (notification) {
|
||||
if (notification
|
||||
is ScrollUpdateNotification &&
|
||||
notification.dragDetails !=
|
||||
null) {
|
||||
followsTail.value =
|
||||
notification
|
||||
.metrics
|
||||
.maxScrollExtent -
|
||||
notification
|
||||
.metrics
|
||||
.pixels <=
|
||||
24;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: SizedBox(
|
||||
key: activityAnchorKey,
|
||||
width: double.infinity,
|
||||
height: _compactSurfaceHeight,
|
||||
child: Semantics(
|
||||
key: const ValueKey('composer-agent-activity-surface'),
|
||||
container: true,
|
||||
child: AnimatedBuilder(
|
||||
animation: expansionController,
|
||||
builder: (context, _) {
|
||||
final progress = _surfaceMorphCurve.transform(
|
||||
expansionController.value,
|
||||
);
|
||||
if (progress >= 1 || viewableWorking.isEmpty) {
|
||||
return const SizedBox.expand();
|
||||
}
|
||||
return Opacity(
|
||||
opacity: 1 - progress,
|
||||
child: IgnorePointer(
|
||||
ignoring:
|
||||
expanded.value ||
|
||||
!expansionController.isDismissed,
|
||||
child: _AgentActivityControl(
|
||||
signals: viewableWorking,
|
||||
selectedAgent: effectiveSelectedAgent,
|
||||
selectedTurn: selectedTurn,
|
||||
transcript: transcript,
|
||||
profiles: profiles,
|
||||
nameFor: nameFor,
|
||||
onTap: toggleExpanded,
|
||||
horizontalInset: horizontalInset,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (statusOnlyAgents.isNotEmpty)
|
||||
_AgentStatusRow(
|
||||
signals: statusOnlyAgents,
|
||||
profiles: profiles,
|
||||
nameFor: nameFor,
|
||||
horizontalInset: horizontalInset,
|
||||
),
|
||||
if (activity.humanTyping.isNotEmpty)
|
||||
ChannelTypingIndicator(entries: activity.humanTyping),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
if (motionDisabled) return child;
|
||||
return AnimatedSize(
|
||||
duration: _surfaceMorphDuration,
|
||||
curve: _surfaceMorphCurve,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
List<TranscriptItem> compactActivityItems(List<TranscriptItem> transcript) {
|
||||
final useful = <TranscriptItem>[];
|
||||
for (final item in transcript) {
|
||||
if (item is MetadataItem) continue;
|
||||
if (item is LifecycleItem &&
|
||||
(item.title == 'Turn started' || item.title == 'Session ready')) {
|
||||
continue;
|
||||
}
|
||||
useful.add(item);
|
||||
}
|
||||
return List.unmodifiable(
|
||||
useful.length <= 40 ? useful : useful.sublist(useful.length - 40),
|
||||
);
|
||||
}
|
||||
|
||||
class _InlineActivityPanel extends StatelessWidget {
|
||||
static const _footerHeight = 44.0;
|
||||
|
||||
final double height;
|
||||
final double expandedHeight;
|
||||
final double morphProgress;
|
||||
final bool targetExpanded;
|
||||
final String agentName;
|
||||
final AgentTurnState? selectedTurn;
|
||||
final bool isFallbackWorking;
|
||||
final ObserverState? observerState;
|
||||
final List<TranscriptItem> transcript;
|
||||
final Map<String, UserProfile> profiles;
|
||||
final List<String> selectorAgents;
|
||||
final String selectedAgent;
|
||||
final String Function(String) nameFor;
|
||||
final ValueChanged<String> onSelectAgent;
|
||||
final VoidCallback onToggleExpanded;
|
||||
final double horizontalInset;
|
||||
final ScrollController scrollController;
|
||||
final bool Function(ScrollNotification) onScroll;
|
||||
|
||||
const _InlineActivityPanel({
|
||||
required this.height,
|
||||
required this.expandedHeight,
|
||||
required this.morphProgress,
|
||||
required this.targetExpanded,
|
||||
required this.agentName,
|
||||
required this.selectedTurn,
|
||||
required this.isFallbackWorking,
|
||||
required this.observerState,
|
||||
required this.transcript,
|
||||
required this.profiles,
|
||||
required this.selectorAgents,
|
||||
required this.selectedAgent,
|
||||
required this.nameFor,
|
||||
required this.onSelectAgent,
|
||||
required this.onToggleExpanded,
|
||||
required this.horizontalInset,
|
||||
required this.scrollController,
|
||||
required this.onScroll,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = _activityStatus(selectedTurn, isFallbackWorking);
|
||||
final headline = _selectedActivityHeadline(selectedTurn, transcript);
|
||||
final compactLabel = _agentActivityLabel(
|
||||
pubkeys: selectorAgents,
|
||||
selectedTurn: selectedTurn,
|
||||
transcript: transcript,
|
||||
nameFor: nameFor,
|
||||
expanded: false,
|
||||
).visibleLabel;
|
||||
final topInset = Grid.xxs * morphProgress;
|
||||
final panelHeight = math.max(_footerHeight, height - Grid.xxs - topInset);
|
||||
final expandedDetailHeight = math.max(
|
||||
0.0,
|
||||
expandedHeight - Grid.xxs * 2 - _footerHeight,
|
||||
);
|
||||
final detailsOpacity = Curves.easeOut.transform(
|
||||
((morphProgress - 0.16) / 0.84).clamp(0.0, 1.0),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
0,
|
||||
topInset,
|
||||
0,
|
||||
Grid.xxs,
|
||||
).add(EdgeInsets.symmetric(horizontal: horizontalInset)),
|
||||
child: Semantics(
|
||||
container: true,
|
||||
label: 'Live activity for $agentName',
|
||||
child: Material(
|
||||
key: const ValueKey('composer-agent-activity-panel'),
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
side: BorderSide(color: Colors.black.withValues(alpha: 0.04)),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SizedBox(
|
||||
height: panelHeight,
|
||||
width: double.infinity,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
bottom: _footerHeight,
|
||||
child: ClipRect(
|
||||
child: IgnorePointer(
|
||||
ignoring: morphProgress < 0.95,
|
||||
child: Opacity(
|
||||
key: const ValueKey('composer-agent-activity-details'),
|
||||
opacity: detailsOpacity,
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.topCenter,
|
||||
minHeight: expandedDetailHeight,
|
||||
maxHeight: expandedDetailHeight,
|
||||
child: Column(
|
||||
children: [
|
||||
if (selectorAgents.length > 1)
|
||||
_AgentSegmentedControl(
|
||||
agents: selectorAgents,
|
||||
selectedAgent: selectedAgent,
|
||||
nameFor: nameFor,
|
||||
onSelectAgent: onSelectAgent,
|
||||
)
|
||||
else
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xxs,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Live activity may be partial.',
|
||||
style: context.textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color:
|
||||
context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: transcript.isEmpty
|
||||
? _ActivityEmptyState(
|
||||
status: status,
|
||||
observerState: observerState,
|
||||
)
|
||||
: NotificationListener<ScrollNotification>(
|
||||
onNotification: onScroll,
|
||||
child: ListView.builder(
|
||||
key: const ValueKey(
|
||||
'composer-agent-activity-transcript',
|
||||
),
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.xxs,
|
||||
Grid.half,
|
||||
Grid.xxs,
|
||||
Grid.xxs,
|
||||
),
|
||||
itemCount: transcript.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_CompactActivityItem(
|
||||
item: transcript[index],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: _footerHeight,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label:
|
||||
'$agentName $headline. ${targetExpanded ? 'Collapse' : 'Expand'} live activity.',
|
||||
child: ExcludeSemantics(
|
||||
child: InkWell(
|
||||
key: const ValueKey('composer-agent-activity-collapse'),
|
||||
onTap: onToggleExpanded,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xxs,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_AgentAvatarStack(
|
||||
pubkeys: selectorAgents,
|
||||
profiles: profiles,
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 1 - morphProgress,
|
||||
child: Text(
|
||||
compactLabel,
|
||||
style: context.textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: context
|
||||
.colors
|
||||
.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Opacity(
|
||||
opacity: morphProgress,
|
||||
child: Text(
|
||||
'Live activity',
|
||||
style: context.textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: context
|
||||
.colors
|
||||
.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ClipRect(
|
||||
child: Align(
|
||||
widthFactor: morphProgress,
|
||||
child: Opacity(
|
||||
opacity: morphProgress,
|
||||
child: _ActivityStatusBadge(status: status),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: Grid.half * morphProgress),
|
||||
Transform.rotate(
|
||||
angle: math.pi * morphProgress,
|
||||
child: Icon(
|
||||
LucideIcons.chevronUp,
|
||||
size: 18,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActivityStatusBadge extends StatelessWidget {
|
||||
final _ActivityStatus status;
|
||||
|
||||
const _ActivityStatusBadge({required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = switch (status) {
|
||||
_ActivityStatus.working => context.appColors.success,
|
||||
_ActivityStatus.finished => context.colors.onSurfaceVariant,
|
||||
_ActivityStatus.error => context.colors.error,
|
||||
_ActivityStatus.waiting => context.appColors.warning,
|
||||
};
|
||||
final label = switch (status) {
|
||||
_ActivityStatus.working => 'Working',
|
||||
_ActivityStatus.finished => 'Finished',
|
||||
_ActivityStatus.error => 'Error',
|
||||
_ActivityStatus.waiting => 'Waiting',
|
||||
};
|
||||
return Container(
|
||||
key: const ValueKey('composer-agent-activity-status'),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xxs,
|
||||
vertical: Grid.quarter,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActivityEmptyState extends StatelessWidget {
|
||||
final _ActivityStatus status;
|
||||
final ObserverState? observerState;
|
||||
|
||||
const _ActivityEmptyState({
|
||||
required this.status,
|
||||
required this.observerState,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (status == _ActivityStatus.error) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'The agent stopped with an error.',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (observerState?.connection == ObserverConnectionState.error) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Live activity is unavailable.',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (status == _ActivityStatus.working ||
|
||||
status == _ActivityStatus.waiting)
|
||||
BuzzLoadingIndicator(
|
||||
size: 18,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
semanticLabel: 'Waiting for agent activity',
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
Text(
|
||||
status == _ActivityStatus.finished
|
||||
? 'No activity rows were captured for this turn.'
|
||||
: 'Waiting for live activity…',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
part of '../composer_agent_activity_indicator.dart';
|
||||
|
||||
class _AgentSegmentedControl extends StatelessWidget {
|
||||
static const _inset = Grid.xxs;
|
||||
static const _cornerRadius = Radii.dialog - _inset;
|
||||
|
||||
final List<String> agents;
|
||||
final String selectedAgent;
|
||||
final String Function(String) nameFor;
|
||||
final ValueChanged<String> onSelectAgent;
|
||||
|
||||
const _AgentSegmentedControl({
|
||||
required this.agents,
|
||||
required this.selectedAgent,
|
||||
required this.nameFor,
|
||||
required this.onSelectAgent,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
return SizedBox(
|
||||
height: Grid.xl,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _inset,
|
||||
vertical: Grid.half + Grid.quarter,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final control = isIos
|
||||
? ClipRSuperellipse(
|
||||
key: const ValueKey('composer-agent-segmented-frame'),
|
||||
borderRadius: BorderRadius.circular(_cornerRadius),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
color: context.colors.surface,
|
||||
shape: RoundedSuperellipseBorder(
|
||||
borderRadius: BorderRadius.circular(_cornerRadius),
|
||||
side: BorderSide(
|
||||
color: context.colors.outlineVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: CupertinoSlidingSegmentedControl<String>(
|
||||
key: const ValueKey(
|
||||
'composer-agent-segmented-container',
|
||||
),
|
||||
groupValue: selectedAgent,
|
||||
thumbColor: context.colors.primary,
|
||||
backgroundColor: Colors.transparent,
|
||||
proportionalWidth: false,
|
||||
children: {
|
||||
for (final agent in agents)
|
||||
agent: _IosAgentSegment(
|
||||
pubkey: agent,
|
||||
label: nameFor(agent),
|
||||
selected: agent == selectedAgent,
|
||||
),
|
||||
},
|
||||
onValueChanged: (agent) {
|
||||
if (agent == null || agent == selectedAgent) return;
|
||||
onSelectAgent(agent);
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
: Material(
|
||||
key: const ValueKey('composer-agent-segmented-container'),
|
||||
color: context.colors.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
side: BorderSide(color: context.colors.outlineVariant),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var index = 0; index < agents.length; index++) ...[
|
||||
if (index > 0)
|
||||
ColoredBox(
|
||||
color: context.colors.outlineVariant,
|
||||
child: const SizedBox(width: 1),
|
||||
),
|
||||
_AgentSegment(
|
||||
pubkey: agents[index],
|
||||
label: nameFor(agents[index]),
|
||||
selected: agents[index] == selectedAgent,
|
||||
onTap: () => onSelectAgent(agents[index]),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return SingleChildScrollView(
|
||||
key: const ValueKey('composer-agent-activity-selector'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||
child: control,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IosAgentSegment extends StatelessWidget {
|
||||
final String pubkey;
|
||||
final String label;
|
||||
final bool selected;
|
||||
|
||||
const _IosAgentSegment({
|
||||
required this.pubkey,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
key: ValueKey('composer-agent-segment-$pubkey'),
|
||||
constraints: const BoxConstraints(minWidth: 96, minHeight: Grid.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: selected
|
||||
? context.colors.onPrimary
|
||||
: context.colors.onSurfaceVariant,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AgentSegment extends StatelessWidget {
|
||||
final String pubkey;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _AgentSegment({
|
||||
required this.pubkey,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: label,
|
||||
child: ExcludeSemantics(
|
||||
child: Material(
|
||||
key: ValueKey('composer-agent-segment-$pubkey'),
|
||||
color: selected ? context.colors.primary : Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 96,
|
||||
minHeight: Grid.md,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: selected
|
||||
? context.colors.onPrimary
|
||||
: context.colors.onSurfaceVariant,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AgentActivityControl extends StatelessWidget {
|
||||
final List<WorkingAgentSignal> signals;
|
||||
final String? selectedAgent;
|
||||
final AgentTurnState? selectedTurn;
|
||||
final List<TranscriptItem> transcript;
|
||||
final Map<String, UserProfile> profiles;
|
||||
final String Function(String) nameFor;
|
||||
final VoidCallback onTap;
|
||||
final double horizontalInset;
|
||||
|
||||
const _AgentActivityControl({
|
||||
required this.signals,
|
||||
required this.selectedAgent,
|
||||
required this.selectedTurn,
|
||||
required this.transcript,
|
||||
required this.profiles,
|
||||
required this.nameFor,
|
||||
required this.onTap,
|
||||
required this.horizontalInset,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pubkeys = signals.isNotEmpty
|
||||
? [for (final signal in signals) signal.pubkey]
|
||||
: [?selectedAgent];
|
||||
final label = _agentActivityLabel(
|
||||
pubkeys: pubkeys,
|
||||
selectedTurn: selectedTurn,
|
||||
transcript: transcript,
|
||||
nameFor: nameFor,
|
||||
expanded: false,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: Grid.xxs,
|
||||
).add(EdgeInsets.symmetric(horizontal: horizontalInset)),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
toggled: false,
|
||||
label: label.semanticLabel,
|
||||
child: ExcludeSemantics(
|
||||
child: Material(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
side: BorderSide(color: Colors.black.withValues(alpha: 0.04)),
|
||||
),
|
||||
child: InkWell(
|
||||
key: const ValueKey('composer-agent-activity-control'),
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 44),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xxs,
|
||||
vertical: Grid.half,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_AgentAvatarStack(pubkeys: pubkeys, profiles: profiles),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label.visibleLabel,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
LucideIcons.chevronUp,
|
||||
size: 16,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AgentStatusRow extends StatelessWidget {
|
||||
final List<WorkingAgentSignal> signals;
|
||||
final Map<String, UserProfile> profiles;
|
||||
final String Function(String) nameFor;
|
||||
final double horizontalInset;
|
||||
|
||||
const _AgentStatusRow({
|
||||
required this.signals,
|
||||
required this.profiles,
|
||||
required this.nameFor,
|
||||
required this.horizontalInset,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pubkeys = [for (final signal in signals) signal.pubkey];
|
||||
final text = switch (signals.length) {
|
||||
1 => '${nameFor(signals.single.pubkey)} is working…',
|
||||
_ => '${signals.length} agents are working…',
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: Grid.xxs,
|
||||
).add(EdgeInsets.symmetric(horizontal: horizontalInset)),
|
||||
child: Container(
|
||||
key: const ValueKey('composer-agent-status-only'),
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(minHeight: 44),
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
border: Border.all(color: Colors.black.withValues(alpha: 0.04)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_AgentAvatarStack(pubkeys: pubkeys, profiles: profiles),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AgentAvatarStack extends StatelessWidget {
|
||||
final List<String> pubkeys;
|
||||
final Map<String, UserProfile> profiles;
|
||||
|
||||
const _AgentAvatarStack({required this.pubkeys, required this.profiles});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visible = pubkeys.take(3).toList();
|
||||
return SizedBox(
|
||||
width: 24.0 + math.max(0, visible.length - 1) * 14.0,
|
||||
height: 24,
|
||||
child: Stack(
|
||||
children: [
|
||||
for (var index = 0; index < visible.length; index++)
|
||||
Positioned(
|
||||
left: index * 14.0,
|
||||
child: SmallAvatar(
|
||||
pubkey: visible[index],
|
||||
userCache: profiles,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _ActivityStatus { working, finished, error, waiting }
|
||||
|
||||
_ActivityStatus _activityStatus(AgentTurnState? turn, bool isFallbackWorking) {
|
||||
return switch (turn?.phase) {
|
||||
AgentTurnPhase.working => _ActivityStatus.working,
|
||||
AgentTurnPhase.finished => _ActivityStatus.finished,
|
||||
AgentTurnPhase.error => _ActivityStatus.error,
|
||||
null =>
|
||||
isFallbackWorking ? _ActivityStatus.working : _ActivityStatus.waiting,
|
||||
};
|
||||
}
|
||||
|
||||
({String visibleLabel, String semanticLabel}) _agentActivityLabel({
|
||||
required List<String> pubkeys,
|
||||
required AgentTurnState? selectedTurn,
|
||||
required List<TranscriptItem> transcript,
|
||||
required String Function(String) nameFor,
|
||||
required bool expanded,
|
||||
}) {
|
||||
final action = expanded ? 'Collapse live activity.' : 'Show live activity.';
|
||||
if (pubkeys.length > 1) {
|
||||
return (
|
||||
visibleLabel: '${pubkeys.length} agents are working…',
|
||||
semanticLabel: '${pubkeys.length} agents are working. $action',
|
||||
);
|
||||
}
|
||||
final name = pubkeys.isEmpty ? 'Agent' : nameFor(pubkeys.single);
|
||||
final status = _selectedActivityHeadline(selectedTurn, transcript);
|
||||
return (
|
||||
visibleLabel: '$name $status',
|
||||
semanticLabel: '$name $status. $action',
|
||||
);
|
||||
}
|
||||
|
||||
String _selectedActivityHeadline(
|
||||
AgentTurnState? selectedTurn,
|
||||
List<TranscriptItem> transcript,
|
||||
) => switch (selectedTurn?.phase) {
|
||||
AgentTurnPhase.finished => 'finished',
|
||||
AgentTurnPhase.error => 'stopped with an error',
|
||||
_ =>
|
||||
transcript.isNotEmpty ? _compactHeadline(transcript.last) : 'is working…',
|
||||
};
|
||||
|
||||
String _compactHeadline(TranscriptItem item) => switch (item) {
|
||||
final ToolItem tool => _toolActivityLabel(tool),
|
||||
final ThoughtItem thought => thought.title.toLowerCase(),
|
||||
final MessageItem message =>
|
||||
message.role == 'assistant' ? 'is responding…' : 'is reading the prompt…',
|
||||
final LifecycleItem lifecycle => lifecycle.title.toLowerCase(),
|
||||
MetadataItem() => 'is working…',
|
||||
};
|
||||
|
||||
String _toolActivityLabel(ToolItem tool) {
|
||||
final title = tool.title.trim().isEmpty ? tool.toolName : tool.title.trim();
|
||||
return switch (tool.status) {
|
||||
ToolStatus.completed => 'Finished $title',
|
||||
ToolStatus.failed => '$title failed',
|
||||
ToolStatus.pending => 'Queued $title',
|
||||
ToolStatus.executing => 'Running $title',
|
||||
};
|
||||
}
|
||||
|
||||
String _oneLine(String value) => value.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
|
||||
String _transcriptVersion(List<TranscriptItem> transcript) {
|
||||
if (transcript.isEmpty) return 'empty';
|
||||
final last = transcript.last;
|
||||
final contentLength = switch (last) {
|
||||
final MessageItem item => item.text.length,
|
||||
final ThoughtItem item => item.text.length,
|
||||
final LifecycleItem item => item.text.length,
|
||||
final MetadataItem item => item.sections.length,
|
||||
final ToolItem item => item.result.length + item.args.length,
|
||||
};
|
||||
return '${transcript.length}:${last.id}:$contentLength';
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
part of '../composer_agent_activity_indicator.dart';
|
||||
|
||||
class _CompactActivityItem extends StatelessWidget {
|
||||
final TranscriptItem item;
|
||||
|
||||
const _CompactActivityItem({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (icon, label, color) = switch (item) {
|
||||
final ToolItem tool => (
|
||||
tool.isError || tool.status == ToolStatus.failed
|
||||
? LucideIcons.circleX
|
||||
: tool.status == ToolStatus.completed
|
||||
? LucideIcons.circleCheck
|
||||
: LucideIcons.wrench,
|
||||
_toolActivityLabel(tool),
|
||||
tool.isError || tool.status == ToolStatus.failed
|
||||
? context.colors.error
|
||||
: context.colors.onSurfaceVariant,
|
||||
),
|
||||
final ThoughtItem thought => (
|
||||
LucideIcons.brain,
|
||||
thought.title,
|
||||
context.colors.onSurfaceVariant,
|
||||
),
|
||||
final MessageItem message => (
|
||||
LucideIcons.messageSquare,
|
||||
'${message.role == 'assistant' ? 'Response' : 'Prompt'}: ${_oneLine(message.text)}',
|
||||
context.colors.onSurfaceVariant,
|
||||
),
|
||||
final LifecycleItem lifecycle => (
|
||||
lifecycle.title.toLowerCase().contains('error')
|
||||
? LucideIcons.circleX
|
||||
: LucideIcons.activity,
|
||||
'${lifecycle.title}${lifecycle.text.isEmpty ? '' : ': ${_oneLine(lifecycle.text)}'}',
|
||||
lifecycle.title.toLowerCase().contains('error')
|
||||
? context.colors.error
|
||||
: context.colors.onSurfaceVariant,
|
||||
),
|
||||
MetadataItem() => (
|
||||
LucideIcons.activity,
|
||||
'Activity',
|
||||
context.colors.onSurfaceVariant,
|
||||
),
|
||||
};
|
||||
return Padding(
|
||||
key: ValueKey('compact-activity-${item.id}'),
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.quarter),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: Grid.half),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodySmall?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ class ObserverFrame {
|
||||
final String? channelId;
|
||||
final String? sessionId;
|
||||
final String? turnId;
|
||||
final String? startedAt;
|
||||
final DateTime? receivedAt;
|
||||
final dynamic payload;
|
||||
|
||||
const ObserverFrame({
|
||||
@@ -26,10 +28,15 @@ class ObserverFrame {
|
||||
this.channelId,
|
||||
this.sessionId,
|
||||
this.turnId,
|
||||
this.startedAt,
|
||||
this.receivedAt,
|
||||
this.payload,
|
||||
});
|
||||
|
||||
factory ObserverFrame.fromJson(Map<String, dynamic> json) => ObserverFrame(
|
||||
factory ObserverFrame.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
DateTime? receivedAt,
|
||||
}) => ObserverFrame(
|
||||
seq: json['seq'] as int? ?? 0,
|
||||
timestamp: json['timestamp'] as String? ?? '',
|
||||
kind: json['kind'] as String? ?? '',
|
||||
@@ -37,6 +44,8 @@ class ObserverFrame {
|
||||
channelId: json['channelId'] as String?,
|
||||
sessionId: json['sessionId'] as String?,
|
||||
turnId: json['turnId'] as String?,
|
||||
startedAt: json['startedAt'] as String?,
|
||||
receivedAt: receivedAt,
|
||||
payload: json['payload'],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ const _observerBatchKind = 'batch';
|
||||
/// Key for channel-scoped transcript reads.
|
||||
typedef ObserverKey = ({String channelId, String agentPubkey});
|
||||
|
||||
/// Key for a transcript constrained to one agent turn.
|
||||
typedef ObserverTurnKey = ({
|
||||
String channelId,
|
||||
String agentPubkey,
|
||||
String turnId,
|
||||
});
|
||||
|
||||
/// State emitted by the channel-scoped observer transcript provider.
|
||||
@immutable
|
||||
class ObserverState {
|
||||
@@ -246,7 +253,8 @@ class ObserverRelayNotifier extends Notifier<ObserverRelayState> {
|
||||
);
|
||||
final plaintext = nip44Decrypt(conversationKey, event.content);
|
||||
final json = jsonDecode(plaintext) as Map<String, dynamic>;
|
||||
final frame = ObserverFrame.fromJson(json);
|
||||
final receivedAt = DateTime.now().toUtc();
|
||||
final frame = ObserverFrame.fromJson(json, receivedAt: receivedAt);
|
||||
if (frame.kind != _observerBatchKind) {
|
||||
return [frame];
|
||||
}
|
||||
@@ -261,7 +269,10 @@ class ObserverRelayNotifier extends Notifier<ObserverRelayState> {
|
||||
|
||||
return [
|
||||
for (final inner in events)
|
||||
ObserverFrame.fromJson(inner as Map<String, dynamic>),
|
||||
ObserverFrame.fromJson(
|
||||
inner as Map<String, dynamic>,
|
||||
receivedAt: receivedAt,
|
||||
),
|
||||
];
|
||||
} catch (error) {
|
||||
_errorMessage = 'Observer event decrypt failed: $error';
|
||||
@@ -371,3 +382,26 @@ final observerSubscriptionProvider =
|
||||
errorMessage: relayState.errorMessage,
|
||||
);
|
||||
});
|
||||
|
||||
/// Live transcript for one exact turn.
|
||||
///
|
||||
/// The narrower key keeps compact composer activity from merging concurrent or
|
||||
/// unrelated turns that happen to share an agent and channel.
|
||||
final observerTurnSubscriptionProvider =
|
||||
Provider.family<ObserverState, ObserverTurnKey>((ref, key) {
|
||||
final relayState = ref.watch(observerRelayProvider);
|
||||
final normalizedAgent = key.agentPubkey.toLowerCase();
|
||||
final frames = relayState.framesByAgent[normalizedAgent] ?? const [];
|
||||
final turnFrames = [
|
||||
for (final frame in frames)
|
||||
if ((frame.channelId == key.channelId || frame.channelId == null) &&
|
||||
frame.turnId == key.turnId)
|
||||
frame,
|
||||
];
|
||||
|
||||
return ObserverState(
|
||||
connection: relayState.connection,
|
||||
transcript: buildTranscript(turnFrames),
|
||||
errorMessage: relayState.errorMessage,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -586,6 +586,20 @@ List<TranscriptItem> buildTranscript(List<ObserverFrame> events) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.kind == 'turn_error' || event.kind == 'agent_panic') {
|
||||
final payload = _asRecord(event.payload);
|
||||
final error = _asString(payload['error']) ?? 'Unknown error';
|
||||
final outcome = _asString(payload['outcome']) ?? 'error';
|
||||
upsertTextItem(
|
||||
'${event.kind}:${event.turnId ?? event.seq}',
|
||||
'lifecycle',
|
||||
event.kind == 'agent_panic' ? 'Agent error' : 'Turn error',
|
||||
'$outcome: $error',
|
||||
event.timestamp,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.kind != 'acp_read' && event.kind != 'acp_write') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,152 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../profile/user_cache_provider.dart';
|
||||
import '../channel_management_provider.dart';
|
||||
import '../channel_typing_provider.dart';
|
||||
import 'active_agent_turns.dart';
|
||||
import 'observer_subscription.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.
|
||||
/// Composer activity is scoped either to a channel or to one thread.
|
||||
typedef ComposerActivityKey = ({String channelId, String? threadHeadId});
|
||||
|
||||
enum AgentWorkingSource { observer, typing }
|
||||
|
||||
/// One agent currently surfaced beside the composer.
|
||||
@immutable
|
||||
class WorkingAgentSignal {
|
||||
final String pubkey;
|
||||
final AgentWorkingSource source;
|
||||
final bool canViewActivity;
|
||||
final String? turnId;
|
||||
final DateTime? startedAt;
|
||||
|
||||
const WorkingAgentSignal({
|
||||
required this.pubkey,
|
||||
required this.source,
|
||||
required this.canViewActivity,
|
||||
this.turnId,
|
||||
this.startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/// Agent work and human typing are intentionally separate presentation lanes.
|
||||
@immutable
|
||||
class ComposerActivityState {
|
||||
final List<WorkingAgentSignal> agents;
|
||||
final List<TypingEntry> humanTyping;
|
||||
|
||||
const ComposerActivityState({
|
||||
required this.agents,
|
||||
required this.humanTyping,
|
||||
});
|
||||
}
|
||||
|
||||
/// Unified composer state. Observer activity is authoritative in channels;
|
||||
/// kind:20002 typing fills gaps and is the only thread-scoped signal because
|
||||
/// observer frames do not carry a thread id.
|
||||
final composerActivityStateProvider = Provider.autoDispose
|
||||
.family<ComposerActivityState, ComposerActivityKey>((ref, key) {
|
||||
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
|
||||
final typing = [
|
||||
for (final entry in ref.watch(channelTypingProvider(key.channelId)))
|
||||
if (entry.threadHeadId == key.threadHeadId &&
|
||||
entry.pubkey.toLowerCase() != currentPubkey)
|
||||
entry,
|
||||
];
|
||||
final profiles = ref.watch(userCacheProvider);
|
||||
final channelMembers =
|
||||
ref.watch(channelMembersProvider(key.channelId)).asData?.value ??
|
||||
const <ChannelMember>[];
|
||||
final channelAgents = <String>{
|
||||
...ref.watch(agentMentionPubkeysProvider(key.channelId)),
|
||||
for (final member in channelMembers)
|
||||
if (member.isBot) member.pubkey.toLowerCase(),
|
||||
for (final entry in profiles.entries)
|
||||
if (entry.value.ownerPubkey != null) entry.key.toLowerCase(),
|
||||
};
|
||||
final observerState = ref.watch(observerRelayProvider);
|
||||
final ownerByAgent =
|
||||
ref.watch(agentOwnersProvider).asData?.value ??
|
||||
const <String, String>{};
|
||||
final activeTurns = ref.watch(activeAgentTurnsProvider);
|
||||
final activeByAgent = <String, AgentTurnState>{};
|
||||
for (final turn in activeTurns) {
|
||||
if (turn.channelId != key.channelId) continue;
|
||||
final existing = activeByAgent[turn.agentPubkey];
|
||||
if (existing == null ||
|
||||
turn.lastActivityAt.isAfter(existing.lastActivityAt)) {
|
||||
activeByAgent[turn.agentPubkey] = turn;
|
||||
}
|
||||
}
|
||||
|
||||
bool canView(String pubkey) {
|
||||
if (currentPubkey == null) return false;
|
||||
final normalized = pubkey.toLowerCase();
|
||||
return ownerByAgent[normalized]?.toLowerCase() == currentPubkey ||
|
||||
profiles[normalized]?.ownerPubkey?.toLowerCase() == currentPubkey;
|
||||
}
|
||||
|
||||
final signals = <String, WorkingAgentSignal>{};
|
||||
// A channel can trust observer activity directly. A thread cannot: the
|
||||
// observer protocol has no thread id, so a thread requires typing first.
|
||||
if (key.threadHeadId == null) {
|
||||
for (final entry in activeByAgent.entries) {
|
||||
if (!channelAgents.contains(entry.key)) continue;
|
||||
final turn = entry.value;
|
||||
signals[entry.key] = WorkingAgentSignal(
|
||||
pubkey: entry.key,
|
||||
source: AgentWorkingSource.observer,
|
||||
canViewActivity: canView(entry.key),
|
||||
turnId: turn.turnId,
|
||||
startedAt: turn.startedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final humans = <TypingEntry>[];
|
||||
for (final entry in typing) {
|
||||
final pubkey = entry.pubkey.toLowerCase();
|
||||
if (!channelAgents.contains(pubkey) &&
|
||||
!observerState.framesByAgent.containsKey(pubkey)) {
|
||||
humans.add(entry);
|
||||
continue;
|
||||
}
|
||||
if (signals.containsKey(pubkey)) continue;
|
||||
final turn = activeByAgent[pubkey];
|
||||
signals[pubkey] = WorkingAgentSignal(
|
||||
pubkey: pubkey,
|
||||
source: AgentWorkingSource.typing,
|
||||
canViewActivity: canView(pubkey),
|
||||
turnId: turn?.turnId,
|
||||
startedAt: turn?.startedAt,
|
||||
);
|
||||
}
|
||||
|
||||
final agents = signals.values.toList()
|
||||
..sort((a, b) {
|
||||
final aAt = a.startedAt;
|
||||
final bAt = b.startedAt;
|
||||
if (aAt != null && bAt != null) return aAt.compareTo(bAt);
|
||||
if (aAt != null) return -1;
|
||||
if (bAt != null) return 1;
|
||||
return a.pubkey.compareTo(b.pubkey);
|
||||
});
|
||||
return ComposerActivityState(
|
||||
agents: List.unmodifiable(agents),
|
||||
humanTyping: List.unmodifiable(humans),
|
||||
);
|
||||
});
|
||||
|
||||
/// Agent pubkeys working in a channel, for the members badge and sheet.
|
||||
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(),
|
||||
};
|
||||
final activity = ref.watch(
|
||||
composerActivityStateProvider((
|
||||
channelId: channelId,
|
||||
threadHeadId: null,
|
||||
)),
|
||||
);
|
||||
return Set.unmodifiable(activity.agents.map((agent) => agent.pubkey));
|
||||
});
|
||||
|
||||
@@ -30,12 +30,11 @@ import 'android_ime_lift.dart';
|
||||
import 'channel.dart';
|
||||
import 'channel_actions_sheet.dart';
|
||||
import 'channel_link_navigation.dart';
|
||||
import 'agent_activity/composer_agent_activity_indicator.dart';
|
||||
import 'agent_activity/working_bots_provider.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
import 'channel_sections/channel_sections_provider.dart';
|
||||
import 'channel_messages_provider.dart';
|
||||
import 'channel_typing_provider.dart';
|
||||
import 'channel_typing_indicator.dart';
|
||||
import 'channels_provider.dart';
|
||||
import 'unread_badge/observed_unread_event.dart';
|
||||
import 'compose_bar.dart';
|
||||
@@ -193,16 +192,6 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
.watch(profileProvider)
|
||||
.whenData((value) => value?.pubkey)
|
||||
.value;
|
||||
// Only show channel-level typing (exclude thread-scoped entries and self).
|
||||
final typingEntries = ref
|
||||
.watch(channelTypingProvider(channel.id))
|
||||
.where((e) => e.threadHeadId == null)
|
||||
.where(
|
||||
(e) =>
|
||||
currentPubkey == null ||
|
||||
e.pubkey.toLowerCase() != currentPubkey.toLowerCase(),
|
||||
)
|
||||
.toList();
|
||||
final baseChannel =
|
||||
channelsAsync
|
||||
.whenData(
|
||||
@@ -480,15 +469,12 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
if (!resolvedChannel.isForum &&
|
||||
(!resolvedChannel.isMember ||
|
||||
resolvedChannel.isArchived)) ...[
|
||||
AnimatedSize(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: typingEntries.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: ChannelTypingIndicator(entries: typingEntries),
|
||||
ComposerAgentActivityIndicator(
|
||||
channelId: channel.id,
|
||||
overlayTopBoundary: frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
),
|
||||
),
|
||||
if (!resolvedChannel.isDm)
|
||||
_ReadOnlyNotice(channel: resolvedChannel),
|
||||
@@ -508,18 +494,30 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: typingEntries.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: ChannelTypingIndicator(entries: typingEntries),
|
||||
),
|
||||
ComposeBar(
|
||||
channelId: channel.id,
|
||||
activityIndicatorBuilder:
|
||||
(
|
||||
composerWidthAnimation,
|
||||
composerFocusNode,
|
||||
composerInteractionLock,
|
||||
composerActivationRequests,
|
||||
restoreComposerFocus,
|
||||
) => ComposerAgentActivityIndicator(
|
||||
channelId: channel.id,
|
||||
horizontalInset: 0,
|
||||
overlayTopBoundary: frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
),
|
||||
compactWidthFactor: 0.85,
|
||||
composerWidthAnimation: composerWidthAnimation,
|
||||
composerFocusNode: composerFocusNode,
|
||||
composerInteractionLock: composerInteractionLock,
|
||||
composerActivationRequests:
|
||||
composerActivationRequests,
|
||||
onRestoreComposerFocus: restoreComposerFocus,
|
||||
),
|
||||
channelName: resolvedChannel.isDm
|
||||
? ''
|
||||
: resolvedChannel.name,
|
||||
|
||||
@@ -57,6 +57,7 @@ part 'compose_bar/camera_preview.dart';
|
||||
part 'compose_bar/send_button.dart';
|
||||
part 'compose_bar/layout.dart';
|
||||
part 'compose_bar/dock.dart';
|
||||
part 'compose_bar/activity_handoff.dart';
|
||||
part 'compose_bar/compose_bar_widget.dart';
|
||||
|
||||
/// Callback used by channels and threads to submit composer content.
|
||||
@@ -66,3 +67,12 @@ typedef ComposeBarOnSend =
|
||||
List<String> mentionPubkeys, {
|
||||
List<List<String>> mediaTags,
|
||||
});
|
||||
|
||||
typedef ComposeBarActivityIndicatorBuilder =
|
||||
Widget Function(
|
||||
Animation<double> composerWidthAnimation,
|
||||
FocusNode composerFocusNode,
|
||||
ValueNotifier<bool> composerInteractionLock,
|
||||
ValueNotifier<int> composerActivationRequests,
|
||||
VoidCallback restoreComposerFocus,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
part of '../compose_bar.dart';
|
||||
|
||||
void _useComposerActivityFocusHandoff({
|
||||
required FocusNode focusNode,
|
||||
required ValueNotifier<bool> activityInteractionLock,
|
||||
required ValueNotifier<int> composerActivationRequests,
|
||||
required ValueNotifier<bool> isEmojiPickerOpen,
|
||||
required VoidCallback collapseComposer,
|
||||
}) {
|
||||
useEffect(() {
|
||||
void coordinateFocus() {
|
||||
if (focusNode.hasFocus) {
|
||||
composerActivationRequests.value += 1;
|
||||
if (activityInteractionLock.value) {
|
||||
focusNode.unfocus(
|
||||
disposition: UnfocusDisposition.previouslyFocusedChild,
|
||||
);
|
||||
}
|
||||
} else if (!focusNode.hasFocus && !isEmojiPickerOpen.value) {
|
||||
collapseComposer();
|
||||
}
|
||||
}
|
||||
|
||||
focusNode.addListener(coordinateFocus);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => coordinateFocus());
|
||||
return () => focusNode.removeListener(coordinateFocus);
|
||||
}, [focusNode, activityInteractionLock, composerActivationRequests]);
|
||||
}
|
||||
|
||||
bool _requestComposerHandoff(
|
||||
ValueNotifier<bool> activityInteractionLock,
|
||||
ValueNotifier<int> composerActivationRequests,
|
||||
) {
|
||||
composerActivationRequests.value += 1;
|
||||
return activityInteractionLock.value;
|
||||
}
|
||||
@@ -5,9 +5,9 @@ class ComposeBar extends HookConsumerWidget {
|
||||
final String channelName;
|
||||
final String? hintText;
|
||||
final ComposeBarOnSend onSend;
|
||||
final ComposeBarActivityIndicatorBuilder? activityIndicatorBuilder;
|
||||
|
||||
/// Runs immediately before the editor requests focus, allowing a parent to
|
||||
/// prepare focus-dependent layout (for example, following a thread tail).
|
||||
/// Runs before editor focus so a parent can prepare focus-dependent layout.
|
||||
final VoidCallback? onFocusRequested;
|
||||
|
||||
/// Optional thread IDs for thread-scoped typing indicators.
|
||||
@@ -21,6 +21,7 @@ class ComposeBar extends HookConsumerWidget {
|
||||
this.threadHeadId,
|
||||
this.rootId,
|
||||
this.onFocusRequested,
|
||||
this.activityIndicatorBuilder,
|
||||
required this.onSend,
|
||||
});
|
||||
@override
|
||||
@@ -31,15 +32,8 @@ class ComposeBar extends HookConsumerWidget {
|
||||
() => controller.text,
|
||||
);
|
||||
useEffect(() => controller.dispose, [controller]);
|
||||
// Restore and persist unsent text as a local draft so the Activity
|
||||
// inbox Drafts filter reflects real composer state.
|
||||
//
|
||||
// The effect is additionally keyed on the active relay + pubkey identity:
|
||||
// provider-level namespacing alone cannot protect a composer that stays
|
||||
// mounted through an in-place community/account switch — the controller
|
||||
// would retain the old identity's text and the next edit would persist it
|
||||
// into the new identity's store. On identity change we replace the
|
||||
// controller content with the new identity's own saved draft (or clear).
|
||||
// Key drafts by relay + pubkey so in-place identity switches cannot
|
||||
// persist the previous identity's mounted controller text.
|
||||
final draftKey = composeDraftKey(channelId, threadHeadId: threadHeadId);
|
||||
final draftRevision = useRef(0);
|
||||
final draftIdentity =
|
||||
@@ -51,6 +45,8 @@ class ComposeBar extends HookConsumerWidget {
|
||||
);
|
||||
final androidImeFallbackTimer = useRef<Timer?>(null);
|
||||
final focusNode = useFocusNode();
|
||||
final activityInteractionLock = useState(false);
|
||||
final composerActivationRequests = useState(0);
|
||||
useEffect(
|
||||
() =>
|
||||
() => androidImeFallbackTimer.value?.cancel(),
|
||||
@@ -112,16 +108,13 @@ class ComposeBar extends HookConsumerWidget {
|
||||
isComposerExpanded.value = false;
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
void collapseWhenUnfocused() {
|
||||
if (!focusNode.hasFocus && !isEmojiPickerOpen.value) {
|
||||
collapseComposer();
|
||||
}
|
||||
}
|
||||
|
||||
focusNode.addListener(collapseWhenUnfocused);
|
||||
return () => focusNode.removeListener(collapseWhenUnfocused);
|
||||
}, [focusNode]);
|
||||
_useComposerActivityFocusHandoff(
|
||||
focusNode: focusNode,
|
||||
activityInteractionLock: activityInteractionLock,
|
||||
composerActivationRequests: composerActivationRequests,
|
||||
isEmojiPickerOpen: isEmojiPickerOpen,
|
||||
collapseComposer: collapseComposer,
|
||||
);
|
||||
|
||||
final appView = View.of(context);
|
||||
useEffect(() {
|
||||
@@ -205,7 +198,6 @@ class ComposeBar extends HookConsumerWidget {
|
||||
};
|
||||
}, [focusNode]);
|
||||
|
||||
// Mention state --------------------------------------------------------
|
||||
final mentionQuery = useState<String?>(null);
|
||||
final mentionStartIdx = useState(-1);
|
||||
// Map of displayName → selected mention candidate built as the user selects
|
||||
@@ -213,7 +205,6 @@ class ComposeBar extends HookConsumerWidget {
|
||||
// selected non-member agents before the message is published.
|
||||
final mentionMap = useRef(<String, MentionCandidate>{});
|
||||
|
||||
// Channel autocomplete state ----------------------------------------------
|
||||
final channelQuery = useState<String?>(null);
|
||||
final channelStartIdx = useState(-1);
|
||||
final channelsAsync = ref.watch(channelsProvider);
|
||||
@@ -852,16 +843,24 @@ class ComposeBar extends HookConsumerWidget {
|
||||
return null;
|
||||
}, [suggestionOverlayController]);
|
||||
|
||||
void expandComposer() => _expandComposer(
|
||||
context: context,
|
||||
isExpanded: isComposerExpanded,
|
||||
attachmentSurface: attachmentSurface,
|
||||
onFocusRequested: onFocusRequested,
|
||||
focusNode: focusNode,
|
||||
view: appView,
|
||||
androidImeTransitionStarted: androidImeTransitionStarted,
|
||||
androidImeFallbackTimer: androidImeFallbackTimer,
|
||||
);
|
||||
void expandComposer() {
|
||||
if (_requestComposerHandoff(
|
||||
activityInteractionLock,
|
||||
composerActivationRequests,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
_expandComposer(
|
||||
context: context,
|
||||
isExpanded: isComposerExpanded,
|
||||
attachmentSurface: attachmentSurface,
|
||||
onFocusRequested: onFocusRequested,
|
||||
focusNode: focusNode,
|
||||
view: appView,
|
||||
androidImeTransitionStarted: androidImeTransitionStarted,
|
||||
androidImeFallbackTimer: androidImeFallbackTimer,
|
||||
);
|
||||
}
|
||||
|
||||
final suggestionPanel = _composerSuggestionPanel(
|
||||
channelSuggestions: channelSuggestions,
|
||||
@@ -912,13 +911,20 @@ class ComposeBar extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Suggestions and attachments live in the overlay.
|
||||
final hasPendingUploads = uploadingCount.value > 0;
|
||||
final activityIndicator = activityIndicatorBuilder?.call(
|
||||
composerExpansionController,
|
||||
focusNode,
|
||||
activityInteractionLock,
|
||||
composerActivationRequests,
|
||||
expandComposer,
|
||||
);
|
||||
return _ComposerDockFrame(
|
||||
expansionAnimation: composerExpansionController,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
?activityIndicator,
|
||||
_UploadProgressMotion(
|
||||
visible: hasPendingUploads,
|
||||
progress: uploadProgress.value,
|
||||
@@ -934,10 +940,11 @@ class ComposeBar extends HookConsumerWidget {
|
||||
controller: suggestionOverlayController,
|
||||
attachmentSurface: attachmentSurface,
|
||||
reducedMotion: reducedMotion,
|
||||
interactionLocked: activityInteractionLock.value,
|
||||
onInteractionLockedTap: () => composerActivationRequests.value += 1,
|
||||
buildOverlayPanel: buildOverlayPanel,
|
||||
onDismissAttachmentSurface: () {
|
||||
attachmentSurface.value = _AttachmentSurface.closed;
|
||||
},
|
||||
onDismissAttachmentSurface: () =>
|
||||
attachmentSurface.value = _AttachmentSurface.closed,
|
||||
child: _ComposeBarLayout(
|
||||
attachments: attachments.value,
|
||||
onRemoveAttachment: removeAttachment,
|
||||
|
||||
@@ -58,6 +58,8 @@ class _ComposerOverlayPortal extends StatelessWidget {
|
||||
final OverlayPortalController controller;
|
||||
final ValueListenable<_AttachmentSurface> attachmentSurface;
|
||||
final bool reducedMotion;
|
||||
final bool interactionLocked;
|
||||
final VoidCallback onInteractionLockedTap;
|
||||
final Widget Function(_AttachmentSurface surface) buildOverlayPanel;
|
||||
final VoidCallback onDismissAttachmentSurface;
|
||||
final Widget child;
|
||||
@@ -66,6 +68,8 @@ class _ComposerOverlayPortal extends StatelessWidget {
|
||||
required this.controller,
|
||||
required this.attachmentSurface,
|
||||
required this.reducedMotion,
|
||||
required this.interactionLocked,
|
||||
required this.onInteractionLockedTap,
|
||||
required this.buildOverlayPanel,
|
||||
required this.onDismissAttachmentSurface,
|
||||
required this.child,
|
||||
@@ -144,7 +148,22 @@ class _ComposerOverlayPortal extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
child: Stack(
|
||||
children: [
|
||||
AbsorbPointer(absorbing: interactionLocked, child: child),
|
||||
if (interactionLocked)
|
||||
Positioned.fill(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: 'Open composer',
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onInteractionLockedTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,24 +90,48 @@ class _ThreadTailIntent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-scoped typing status with optional size animation.
|
||||
/// Thread-scoped composer activity with optional size animation.
|
||||
class _ThreadTypingIndicator extends StatelessWidget {
|
||||
final List<TypingEntry> entries;
|
||||
final String channelId;
|
||||
final String threadHeadId;
|
||||
final bool animated;
|
||||
final double horizontalInset;
|
||||
final double? overlayTopBoundary;
|
||||
final double compactWidthFactor;
|
||||
final Animation<double>? composerWidthAnimation;
|
||||
final FocusNode? composerFocusNode;
|
||||
final ValueNotifier<bool>? composerInteractionLock;
|
||||
final ValueNotifier<int>? composerActivationRequests;
|
||||
final VoidCallback? onRestoreComposerFocus;
|
||||
|
||||
const _ThreadTypingIndicator({required this.entries, this.animated = true});
|
||||
const _ThreadTypingIndicator({
|
||||
required this.channelId,
|
||||
required this.threadHeadId,
|
||||
this.animated = true,
|
||||
this.horizontalInset = Grid.twelve,
|
||||
this.overlayTopBoundary,
|
||||
this.compactWidthFactor = 1,
|
||||
this.composerWidthAnimation,
|
||||
this.composerFocusNode,
|
||||
this.composerInteractionLock,
|
||||
this.composerActivationRequests,
|
||||
this.onRestoreComposerFocus,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = entries.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: ChannelTypingIndicator(entries: entries);
|
||||
if (!animated || MediaQuery.disableAnimationsOf(context)) return child;
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: child,
|
||||
return ComposerAgentActivityIndicator(
|
||||
channelId: channelId,
|
||||
threadHeadId: threadHeadId,
|
||||
animated: animated,
|
||||
horizontalInset: horizontalInset,
|
||||
overlayTopBoundary: overlayTopBoundary,
|
||||
compactWidthFactor: compactWidthFactor,
|
||||
composerWidthAnimation: composerWidthAnimation,
|
||||
composerFocusNode: composerFocusNode,
|
||||
composerInteractionLock: composerInteractionLock,
|
||||
composerActivationRequests: composerActivationRequests,
|
||||
onRestoreComposerFocus: onRestoreComposerFocus,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,10 @@ import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
|
||||
import '../../shared/widgets/message_author_meta.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import 'agent_activity/composer_agent_activity_indicator.dart';
|
||||
import 'android_ime_lift.dart';
|
||||
import 'channel_link_navigation.dart';
|
||||
import 'channel_messages_provider.dart';
|
||||
import 'channel_typing_provider.dart';
|
||||
import 'channel_typing_indicator.dart';
|
||||
import 'thread_replies_provider.dart';
|
||||
import 'channels_provider.dart';
|
||||
import 'compose_bar.dart';
|
||||
@@ -407,17 +406,6 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
return null;
|
||||
}, [threadHead.id, readState.isReady, visibleReplyReadKey]);
|
||||
|
||||
// Thread-scoped typing indicators (exclude self).
|
||||
final allTyping = ref.watch(channelTypingProvider(channelId));
|
||||
final threadTyping = allTyping
|
||||
.where((e) => e.threadHeadId == threadHead.id)
|
||||
.where(
|
||||
(e) =>
|
||||
currentPubkey == null ||
|
||||
e.pubkey.toLowerCase() != currentPubkey?.toLowerCase(),
|
||||
)
|
||||
.toList();
|
||||
|
||||
// Resolve thread head from live data (reactions/edits may have changed).
|
||||
final liveHead =
|
||||
allMsgs.where((m) => m.id == threadHead.id).firstOrNull ?? threadHead;
|
||||
@@ -734,7 +722,12 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
if (!isMember || isArchived)
|
||||
_ThreadTypingIndicator(entries: threadTyping, animated: false),
|
||||
_ThreadTypingIndicator(
|
||||
channelId: channelId,
|
||||
threadHeadId: threadHead.id,
|
||||
animated: false,
|
||||
overlayTopBoundary: frostedAppBarHeight(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isMember && !isArchived)
|
||||
@@ -747,9 +740,28 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ThreadTypingIndicator(entries: threadTyping),
|
||||
ComposeBar(
|
||||
channelId: channelId,
|
||||
activityIndicatorBuilder:
|
||||
(
|
||||
composerWidthAnimation,
|
||||
composerFocusNode,
|
||||
composerInteractionLock,
|
||||
composerActivationRequests,
|
||||
restoreComposerFocus,
|
||||
) => _ThreadTypingIndicator(
|
||||
channelId: channelId,
|
||||
threadHeadId: threadHead.id,
|
||||
horizontalInset: 0,
|
||||
overlayTopBoundary: frostedAppBarHeight(context),
|
||||
compactWidthFactor: 0.85,
|
||||
composerWidthAnimation: composerWidthAnimation,
|
||||
composerFocusNode: composerFocusNode,
|
||||
composerInteractionLock: composerInteractionLock,
|
||||
composerActivationRequests:
|
||||
composerActivationRequests,
|
||||
onRestoreComposerFocus: restoreComposerFocus,
|
||||
),
|
||||
hintText: 'Reply in thread\u2026',
|
||||
threadHeadId: threadHead.id,
|
||||
rootId: effectiveRootId,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/active_agent_turns.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/observer_models.dart';
|
||||
|
||||
void main() {
|
||||
test('tracks and refreshes a live turn using device receipt time', () {
|
||||
final turns = reduceAgentTurnStates({
|
||||
'AGENT-A': [
|
||||
_frame(
|
||||
seq: 1,
|
||||
second: 1,
|
||||
kind: 'turn_started',
|
||||
receivedSecond: 10,
|
||||
payload: {
|
||||
'triggeringEventIds': ['message-1'],
|
||||
},
|
||||
),
|
||||
_frame(seq: 2, second: 11, kind: 'turn_liveness', receivedSecond: 20),
|
||||
],
|
||||
}, now: DateTime.utc(2026, 8, 16, 12, 0, 25));
|
||||
|
||||
expect(turns, hasLength(1));
|
||||
expect(turns.single.agentPubkey, 'agent-a');
|
||||
expect(turns.single.phase, AgentTurnPhase.working);
|
||||
expect(turns.single.triggeringEventId, 'message-1');
|
||||
expect(turns.single.lastActivityAt, DateTime.utc(2026, 8, 16, 12, 0, 20));
|
||||
});
|
||||
|
||||
test('preserves explicit completion and error outcomes', () {
|
||||
final turns = reduceAgentTurnStates({
|
||||
'agent-a': [
|
||||
_frame(seq: 1, second: 1, kind: 'turn_started'),
|
||||
_frame(seq: 2, second: 2, kind: 'turn_completed'),
|
||||
],
|
||||
'agent-b': [
|
||||
_frame(seq: 1, second: 1, kind: 'turn_started', turnId: 'turn-b'),
|
||||
_frame(
|
||||
seq: 2,
|
||||
second: 2,
|
||||
kind: 'turn_error',
|
||||
turnId: 'turn-b',
|
||||
payload: {'error': 'Tool permission denied'},
|
||||
),
|
||||
],
|
||||
}, now: DateTime.utc(2026, 8, 16, 12, 1));
|
||||
|
||||
expect(turns, hasLength(2));
|
||||
expect(turns[0].phase, AgentTurnPhase.finished);
|
||||
expect(turns[1].phase, AgentTurnPhase.error);
|
||||
expect(turns[1].errorMessage, 'Tool permission denied');
|
||||
});
|
||||
|
||||
test('expires silence without claiming the turn finished', () {
|
||||
final turns = reduceAgentTurnStates({
|
||||
'agent-a': [_frame(seq: 1, second: 1, kind: 'turn_started')],
|
||||
}, now: DateTime.utc(2026, 8, 16, 12, 0, 32));
|
||||
|
||||
expect(turns, isEmpty);
|
||||
});
|
||||
|
||||
test('recovers a missed start and rejects stale post-terminal liveness', () {
|
||||
final turns = reduceAgentTurnStates({
|
||||
'agent-a': [
|
||||
_frame(
|
||||
seq: 1,
|
||||
second: 1,
|
||||
kind: 'turn_liveness',
|
||||
startedAt: DateTime.utc(2026, 8, 16, 11, 59).toIso8601String(),
|
||||
),
|
||||
_frame(seq: 2, second: 2, kind: 'turn_completed'),
|
||||
_frame(
|
||||
seq: 0,
|
||||
second: 1,
|
||||
kind: 'turn_liveness',
|
||||
startedAt: DateTime.utc(2026, 8, 16, 11, 59).toIso8601String(),
|
||||
),
|
||||
],
|
||||
}, now: DateTime.utc(2026, 8, 16, 12, 0, 20));
|
||||
|
||||
expect(turns, hasLength(1));
|
||||
expect(turns.single.phase, AgentTurnPhase.finished);
|
||||
expect(turns.single.startedAt, DateTime.utc(2026, 8, 16, 11, 59));
|
||||
});
|
||||
|
||||
test('terminal without a turn id updates the latest turn in its channel', () {
|
||||
final turns = reduceAgentTurnStates({
|
||||
'agent-a': [
|
||||
_frame(seq: 1, second: 1, kind: 'turn_started'),
|
||||
_frame(
|
||||
seq: 2,
|
||||
second: 2,
|
||||
kind: 'agent_panic',
|
||||
turnId: null,
|
||||
payload: {'error': 'Process exited'},
|
||||
),
|
||||
],
|
||||
}, now: DateTime.utc(2026, 8, 16, 12, 0, 20));
|
||||
|
||||
expect(turns.single.phase, AgentTurnPhase.error);
|
||||
expect(turns.single.errorMessage, 'Process exited');
|
||||
});
|
||||
}
|
||||
|
||||
ObserverFrame _frame({
|
||||
required int seq,
|
||||
required int second,
|
||||
required String kind,
|
||||
String? turnId = 'turn-1',
|
||||
String channelId = 'channel-1',
|
||||
int? receivedSecond,
|
||||
String? startedAt,
|
||||
dynamic payload = const <String, dynamic>{},
|
||||
}) {
|
||||
return ObserverFrame(
|
||||
seq: seq,
|
||||
timestamp: DateTime.utc(2026, 8, 16, 12, 0, second).toIso8601String(),
|
||||
kind: kind,
|
||||
channelId: channelId,
|
||||
turnId: turnId,
|
||||
startedAt: startedAt,
|
||||
receivedAt: receivedSecond == null
|
||||
? null
|
||||
: DateTime.utc(2026, 8, 16, 12, 0, receivedSecond),
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,43 @@ import 'package:buzz/shared/crypto/nip44.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
|
||||
void main() {
|
||||
test('turn-scoped provider does not merge concurrent agent turns', () {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
observerRelayProvider.overrideWith(
|
||||
() => _StaticObserverRelay({
|
||||
'agent-a': [
|
||||
_turnMessageFrame(seq: 1, turnId: 'turn-1', text: 'First turn'),
|
||||
_turnMessageFrame(seq: 2, turnId: 'turn-2', text: 'Second turn'),
|
||||
ObserverFrame(
|
||||
seq: 3,
|
||||
timestamp: '2026-04-30T12:00:03.000Z',
|
||||
kind: 'agent_panic',
|
||||
turnId: 'turn-1',
|
||||
payload: const {'error': 'Process exited'},
|
||||
),
|
||||
],
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final state = container.read(
|
||||
observerTurnSubscriptionProvider((
|
||||
channelId: 'test-channel',
|
||||
agentPubkey: 'agent-a',
|
||||
turnId: 'turn-1',
|
||||
)),
|
||||
);
|
||||
|
||||
expect(state.transcript.whereType<MessageItem>().single.text, 'First turn');
|
||||
expect(
|
||||
state.transcript.whereType<LifecycleItem>().single.title,
|
||||
'Agent error',
|
||||
);
|
||||
});
|
||||
|
||||
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".
|
||||
@@ -254,6 +291,7 @@ void main() {
|
||||
'kind': 'turn_started',
|
||||
'channelId': channelId,
|
||||
'turnId': 'turn-1',
|
||||
'startedAt': '2026-04-30T11:59:00.000Z',
|
||||
'payload': {
|
||||
'triggeringEventIds': ['0123456789abcdef'],
|
||||
},
|
||||
@@ -279,6 +317,12 @@ void main() {
|
||||
final item = state.transcript.single;
|
||||
expect(item, isA<LifecycleItem>());
|
||||
expect((item as LifecycleItem).title, 'Turn started');
|
||||
final storedFrame = container
|
||||
.read(observerRelayProvider)
|
||||
.framesByAgent[agentKeychain.public]!
|
||||
.single;
|
||||
expect(storedFrame.startedAt, '2026-04-30T11:59:00.000Z');
|
||||
expect(storedFrame.receivedAt, isNotNull);
|
||||
|
||||
final otherChannelState = container.read(
|
||||
observerSubscriptionProvider((
|
||||
@@ -518,6 +562,30 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
ObserverFrame _turnMessageFrame({
|
||||
required int seq,
|
||||
required String turnId,
|
||||
required String text,
|
||||
}) => ObserverFrame(
|
||||
seq: seq,
|
||||
timestamp: '2026-04-30T12:00:0$seq.000Z',
|
||||
kind: 'acp_read',
|
||||
channelId: 'test-channel',
|
||||
turnId: turnId,
|
||||
payload: {
|
||||
'method': 'session/update',
|
||||
'params': {
|
||||
'update': {
|
||||
'sessionUpdate': 'agent_message_chunk',
|
||||
'messageId': 'message-$turnId',
|
||||
'content': [
|
||||
{'type': 'text', 'text': text},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Map<String, dynamic> _observerFrameJson({
|
||||
required int seq,
|
||||
required String channelId,
|
||||
@@ -614,6 +682,18 @@ class _RecordingRelaySession extends RelaySessionNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
class _StaticObserverRelay extends ObserverRelayNotifier {
|
||||
final Map<String, List<ObserverFrame>> frames;
|
||||
|
||||
_StaticObserverRelay(this.frames);
|
||||
|
||||
@override
|
||||
ObserverRelayState build() => ObserverRelayState(
|
||||
connection: ObserverConnectionState.open,
|
||||
framesByAgent: frames,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeRelayConfigNotifier extends RelayConfigNotifier {
|
||||
String? _nsec;
|
||||
|
||||
|
||||
@@ -122,6 +122,29 @@ void main() {
|
||||
expect(items[1], isA<MetadataItem>());
|
||||
expect((items[1] as MetadataItem).sections, hasLength(2));
|
||||
});
|
||||
|
||||
test('surfaces explicit turn errors without inventing completion rows', () {
|
||||
final items = buildTranscript([
|
||||
ObserverFrame(
|
||||
seq: 1,
|
||||
timestamp: _timestamp(1),
|
||||
kind: 'turn_error',
|
||||
turnId: 'turn-1',
|
||||
payload: const {'outcome': 'failed', 'error': 'Permission denied'},
|
||||
),
|
||||
ObserverFrame(
|
||||
seq: 2,
|
||||
timestamp: _timestamp(2),
|
||||
kind: 'turn_completed',
|
||||
turnId: 'turn-2',
|
||||
),
|
||||
]);
|
||||
|
||||
expect(items, hasLength(1));
|
||||
expect(items.single, isA<LifecycleItem>());
|
||||
expect((items.single as LifecycleItem).title, 'Turn error');
|
||||
expect((items.single as LifecycleItem).text, 'failed: Permission denied');
|
||||
});
|
||||
}
|
||||
|
||||
ObserverFrame _updateFrame({
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/active_agent_turns.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/observer_models.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/observer_subscription.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/working_bots_provider.dart';
|
||||
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||||
import 'package:buzz/features/channels/channel_typing_provider.dart';
|
||||
import 'package:buzz/features/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
||||
|
||||
const _channelId = 'channel-1';
|
||||
|
||||
void main() {
|
||||
test('prefers observer work and keeps human typing separate', () {
|
||||
final observerTurn = _turn('agent-a');
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
currentPubkeyProvider.overrideWith((ref) => 'owner'),
|
||||
channelMembersProvider(
|
||||
_channelId,
|
||||
).overrideWith((ref) async => const <ChannelMember>[]),
|
||||
channelTypingProvider(_channelId).overrideWith(
|
||||
() => _FakeTypingNotifier(const [
|
||||
TypingEntry(pubkey: 'agent-a', expiresAtMs: 9999999999999),
|
||||
TypingEntry(pubkey: 'agent-b', expiresAtMs: 9999999999999),
|
||||
TypingEntry(pubkey: 'human', expiresAtMs: 9999999999999),
|
||||
]),
|
||||
),
|
||||
agentMentionPubkeysProvider(
|
||||
_channelId,
|
||||
).overrideWith((ref) => const {'agent-a', 'agent-b'}),
|
||||
agentOwnersProvider.overrideWithValue(
|
||||
const AsyncData({'agent-a': 'owner', 'agent-b': 'owner'}),
|
||||
),
|
||||
userCacheProvider.overrideWith(_FakeUserCacheNotifier.new),
|
||||
observerRelayProvider.overrideWith(
|
||||
() => _FakeObserverRelayNotifier({
|
||||
'agent-a': [_observerFrame('agent-a')],
|
||||
}),
|
||||
),
|
||||
activeAgentTurnsProvider.overrideWithValue([observerTurn]),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final state = container.read(
|
||||
composerActivityStateProvider((
|
||||
channelId: _channelId,
|
||||
threadHeadId: null,
|
||||
)),
|
||||
);
|
||||
|
||||
expect(state.agents, hasLength(2));
|
||||
expect(state.agents[0].pubkey, 'agent-a');
|
||||
expect(state.agents[0].source, AgentWorkingSource.observer);
|
||||
expect(state.agents[0].canViewActivity, isTrue);
|
||||
expect(state.agents[1].source, AgentWorkingSource.typing);
|
||||
expect(state.agents[1].canViewActivity, isTrue);
|
||||
expect(state.humanTyping.single.pubkey, 'human');
|
||||
});
|
||||
|
||||
test(
|
||||
'thread scope requires typing and does not surface observer-only work',
|
||||
() {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
currentPubkeyProvider.overrideWith((ref) => 'owner'),
|
||||
channelMembersProvider(
|
||||
_channelId,
|
||||
).overrideWith((ref) async => const <ChannelMember>[]),
|
||||
channelTypingProvider(_channelId).overrideWith(
|
||||
() => _FakeTypingNotifier(const [
|
||||
TypingEntry(
|
||||
pubkey: 'agent-b',
|
||||
threadHeadId: 'thread-1',
|
||||
expiresAtMs: 9999999999999,
|
||||
),
|
||||
TypingEntry(
|
||||
pubkey: 'human',
|
||||
threadHeadId: 'thread-1',
|
||||
expiresAtMs: 9999999999999,
|
||||
),
|
||||
]),
|
||||
),
|
||||
agentMentionPubkeysProvider(
|
||||
_channelId,
|
||||
).overrideWith((ref) => const {'agent-a', 'agent-b'}),
|
||||
agentOwnersProvider.overrideWithValue(
|
||||
const AsyncData({'agent-b': 'owner'}),
|
||||
),
|
||||
userCacheProvider.overrideWith(_FakeUserCacheNotifier.new),
|
||||
observerRelayProvider.overrideWith(
|
||||
() => _FakeObserverRelayNotifier({
|
||||
'agent-a': [_observerFrame('agent-a')],
|
||||
}),
|
||||
),
|
||||
activeAgentTurnsProvider.overrideWithValue([_turn('agent-a')]),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final state = container.read(
|
||||
composerActivityStateProvider((
|
||||
channelId: _channelId,
|
||||
threadHeadId: 'thread-1',
|
||||
)),
|
||||
);
|
||||
|
||||
expect(state.agents.single.pubkey, 'agent-b');
|
||||
expect(state.agents.single.source, AgentWorkingSource.typing);
|
||||
expect(state.humanTyping.single.pubkey, 'human');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
AgentTurnState _turn(String pubkey) => AgentTurnState(
|
||||
agentPubkey: pubkey,
|
||||
channelId: _channelId,
|
||||
turnId: 'turn-$pubkey',
|
||||
startedAt: DateTime.utc(2026, 8, 16, 12),
|
||||
lastActivityAt: DateTime.utc(2026, 8, 16, 12),
|
||||
livenessTimeout: const Duration(seconds: 30),
|
||||
phase: AgentTurnPhase.working,
|
||||
);
|
||||
|
||||
ObserverFrame _observerFrame(String pubkey) => ObserverFrame(
|
||||
seq: 1,
|
||||
timestamp: DateTime.utc(2026, 8, 16, 12).toIso8601String(),
|
||||
kind: 'turn_started',
|
||||
channelId: _channelId,
|
||||
turnId: 'turn-$pubkey',
|
||||
);
|
||||
|
||||
class _FakeTypingNotifier extends ChannelTypingNotifier {
|
||||
final List<TypingEntry> entries;
|
||||
|
||||
_FakeTypingNotifier(this.entries) : super(_channelId);
|
||||
|
||||
@override
|
||||
List<TypingEntry> build() => entries;
|
||||
}
|
||||
|
||||
class _FakeUserCacheNotifier extends UserCacheNotifier {
|
||||
@override
|
||||
Map<String, UserProfile> build() => const {};
|
||||
|
||||
@override
|
||||
void preload(List<String> pubkeys) {}
|
||||
}
|
||||
|
||||
class _FakeObserverRelayNotifier extends ObserverRelayNotifier {
|
||||
final Map<String, List<ObserverFrame>> frames;
|
||||
|
||||
_FakeObserverRelayNotifier(this.frames);
|
||||
|
||||
@override
|
||||
ObserverRelayState build() => ObserverRelayState(
|
||||
connection: ObserverConnectionState.open,
|
||||
framesByAgent: frames,
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,16 @@ import 'package:flutter/rendering.dart' show ScrollDirection;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/misc.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart' as http_testing;
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
import 'package:buzz/features/channels/channel.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/active_agent_turns.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/observer_models.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/observer_subscription.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/working_bots_provider.dart';
|
||||
import 'package:buzz/features/channels/channel_detail_page.dart';
|
||||
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||||
import 'package:buzz/features/channels/channel_messages_provider.dart';
|
||||
@@ -193,6 +198,7 @@ Widget _buildTestable({
|
||||
bool disableAnimations = false,
|
||||
RelaySessionNotifier? relaySessionNotifier,
|
||||
http.Client? mediaClient,
|
||||
List<Override> extraOverrides = const [],
|
||||
}) {
|
||||
final resolvedChannel = channel ?? _testChannel;
|
||||
final fakeChannelsNotifier =
|
||||
@@ -254,6 +260,7 @@ Widget _buildTestable({
|
||||
relaySessionProvider.overrideWith(() => relaySessionNotifier),
|
||||
// Compose bar drafts persist through SharedPreferences.
|
||||
savedPrefsProvider.overrideWithValue(_testPrefs),
|
||||
...extraOverrides,
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
@@ -2305,6 +2312,240 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'agent activity follows composer width and overlays without reflowing the tail',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
const agentPubkey = 'agent-a';
|
||||
const turnId = 'turn-a';
|
||||
final messages = [
|
||||
for (var i = 0; i < 20; i++)
|
||||
_textMsg(
|
||||
id: 'msg$i',
|
||||
pubkey: 'alice',
|
||||
content: 'Message $i',
|
||||
createdAt: 1000 + i,
|
||||
),
|
||||
];
|
||||
final turn = AgentTurnState(
|
||||
agentPubkey: agentPubkey,
|
||||
channelId: _channelId,
|
||||
turnId: turnId,
|
||||
startedAt: DateTime.utc(2026, 8, 16, 12),
|
||||
lastActivityAt: DateTime.utc(2026, 8, 16, 12, 0, 5),
|
||||
livenessTimeout: const Duration(seconds: 30),
|
||||
phase: AgentTurnPhase.working,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
disableAnimations: false,
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
agentPubkey: UserProfile(
|
||||
pubkey: agentPubkey,
|
||||
displayName: 'Pollen',
|
||||
ownerPubkey: 'self',
|
||||
),
|
||||
},
|
||||
extraOverrides: [
|
||||
composerActivityStateProvider((
|
||||
channelId: _channelId,
|
||||
threadHeadId: null,
|
||||
)).overrideWithValue(
|
||||
const ComposerActivityState(
|
||||
agents: [
|
||||
WorkingAgentSignal(
|
||||
pubkey: agentPubkey,
|
||||
source: AgentWorkingSource.observer,
|
||||
canViewActivity: true,
|
||||
turnId: turnId,
|
||||
),
|
||||
],
|
||||
humanTyping: [],
|
||||
),
|
||||
),
|
||||
agentTurnStatesProvider.overrideWithValue([turn]),
|
||||
observerTurnSubscriptionProvider((
|
||||
channelId: _channelId,
|
||||
agentPubkey: agentPubkey,
|
||||
turnId: turnId,
|
||||
)).overrideWithValue(
|
||||
ObserverState(
|
||||
connection: ObserverConnectionState.open,
|
||||
transcript: [
|
||||
ToolItem(
|
||||
id: 'tool-1',
|
||||
title: 'Read channel',
|
||||
toolName: 'get_messages',
|
||||
status: ToolStatus.executing,
|
||||
args: const {},
|
||||
result: '',
|
||||
isError: false,
|
||||
timestamp: '2026-08-16T12:00:05Z',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final latestMessage = find.byKey(
|
||||
const ValueKey('channel-message-group-msg19'),
|
||||
);
|
||||
final composerDock = find.byKey(
|
||||
const ValueKey('channel-composer-dock'),
|
||||
);
|
||||
final activitySurface = find.byKey(
|
||||
const ValueKey('composer-agent-activity-surface'),
|
||||
);
|
||||
final composerWidth = find.byKey(
|
||||
const ValueKey('composer-width-transition'),
|
||||
);
|
||||
final compactActivityWidth = tester.getSize(activitySurface).width;
|
||||
final compactComposerWidth = tester.getSize(composerWidth).width;
|
||||
expect(compactActivityWidth, closeTo(compactComposerWidth, 0.1));
|
||||
expect(
|
||||
tester.getCenter(activitySurface).dx,
|
||||
closeTo(tester.getCenter(composerWidth).dx, 0.1),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Message #general'));
|
||||
await tester.pumpAndSettle();
|
||||
final textField = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(textField.focusNode?.hasFocus, isTrue);
|
||||
final expandedActivityWidth = tester.getSize(activitySurface).width;
|
||||
final expandedComposerWidth = tester.getSize(composerWidth).width;
|
||||
expect(expandedActivityWidth, greaterThan(compactActivityWidth));
|
||||
expect(expandedActivityWidth, closeTo(expandedComposerWidth, 0.1));
|
||||
|
||||
textField.focusNode?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
final dockHeightBeforeActivity = tester.getSize(composerDock).height;
|
||||
final latestMessageBottomBeforeActivity = tester
|
||||
.getBottomLeft(latestMessage)
|
||||
.dy;
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('composer-agent-activity-control')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('composer-agent-activity-panel')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('composer-agent-segmented-container')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.text('Pollen'), findsNothing);
|
||||
expect(
|
||||
tester
|
||||
.getCenter(
|
||||
find.byKey(const ValueKey('composer-agent-activity-panel')),
|
||||
)
|
||||
.dx,
|
||||
closeTo(tester.getCenter(composerWidth).dx, 0.1),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.getSize(
|
||||
find.byKey(const ValueKey('composer-agent-activity-panel')),
|
||||
)
|
||||
.width,
|
||||
closeTo(expandedComposerWidth, 0.1),
|
||||
);
|
||||
expect(find.byType(BottomSheet), findsNothing);
|
||||
expect(textField.focusNode?.hasFocus, isFalse);
|
||||
expect(textField.focusNode?.canRequestFocus, isTrue);
|
||||
await tester.tap(find.text('Message #general'), warnIfMissed: false);
|
||||
await tester.pump();
|
||||
expect(textField.focusNode?.hasFocus, isFalse);
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
expect(
|
||||
tester.getSize(composerDock).height,
|
||||
closeTo(dockHeightBeforeActivity, 0.1),
|
||||
);
|
||||
expect(
|
||||
tester.getBottomLeft(latestMessage).dy,
|
||||
closeTo(latestMessageBottomBeforeActivity, 0.1),
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('channel-jump-to-latest')),
|
||||
findsNothing,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('composer-agent-activity-panel')),
|
||||
findsNothing,
|
||||
);
|
||||
final activatedComposer = tester.widget<TextField>(
|
||||
find.byType(TextField),
|
||||
);
|
||||
expect(activatedComposer.focusNode?.canRequestFocus, isTrue);
|
||||
expect(activatedComposer.focusNode?.hasFocus, isTrue);
|
||||
|
||||
activatedComposer.focusNode?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester
|
||||
.getSize(
|
||||
find.byKey(const ValueKey('composer-agent-activity-surface')),
|
||||
)
|
||||
.width,
|
||||
closeTo(compactActivityWidth, 0.1),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Message #general'));
|
||||
await tester.pumpAndSettle();
|
||||
final reopenedTextField = tester.widget<TextField>(
|
||||
find.byType(TextField),
|
||||
);
|
||||
expect(reopenedTextField.focusNode?.hasFocus, isTrue);
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('composer-agent-activity-control')),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(reopenedTextField.focusNode?.hasFocus, isFalse);
|
||||
expect(
|
||||
find.byKey(const ValueKey('composer-agent-activity-panel')),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
tester.view.viewInsets = FakeViewPadding.zero;
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('composer-agent-activity-panel')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.getSize(
|
||||
find.byKey(const ValueKey('composer-agent-activity-panel')),
|
||||
)
|
||||
.width,
|
||||
closeTo(expandedComposerWidth, 0.1),
|
||||
);
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('composer-agent-activity-collapse')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final restoredTextField = tester.widget<TextField>(
|
||||
find.byType(TextField),
|
||||
);
|
||||
expect(restoredTextField.focusNode?.hasFocus, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'seeds an already-visible Android keyboard into the channel tail layout',
|
||||
(tester) async {
|
||||
|
||||
Reference in New Issue
Block a user