From 7af71e9ce710cfa5f57f9989abfd6f606be18fdc Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 16 Aug 2026 16:40:23 +0100 Subject: [PATCH] Add inline mobile agent activity Signed-off-by: kenny lopez --- .../agent_activity/active_agent_turns.dart | 313 +++++ .../composer_agent_activity_indicator.dart | 958 +++++++++++++++ .../agent_activity_controls.dart | 458 +++++++ .../compact_activity_item.dart | 66 + .../agent_activity/observer_models.dart | 11 +- .../agent_activity/observer_subscription.dart | 38 +- .../agent_activity/transcript_builder.dart | 14 + .../agent_activity/working_bots_provider.dart | 163 ++- .../channels/channel_detail_page.dart | 60 +- mobile/lib/features/channels/compose_bar.dart | 10 + .../compose_bar/activity_handoff.dart | 36 + .../compose_bar/compose_bar_widget.dart | 81 +- .../features/channels/compose_bar/dock.dart | 21 +- .../channels/thread_detail_helpers.dart | 48 +- .../features/channels/thread_detail_page.dart | 42 +- .../active_agent_turns_test.dart | 126 ++ ...omposer_agent_activity_indicator_test.dart | 1064 +++++++++++++++++ .../observer_subscription_test.dart | 80 ++ .../transcript_builder_test.dart | 23 + .../working_bots_provider_test.dart | 163 +++ .../channels/channel_detail_page_test.dart | 241 ++++ 21 files changed, 3898 insertions(+), 118 deletions(-) create mode 100644 mobile/lib/features/channels/agent_activity/active_agent_turns.dart create mode 100644 mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart create mode 100644 mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart create mode 100644 mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/compact_activity_item.dart create mode 100644 mobile/lib/features/channels/compose_bar/activity_handoff.dart create mode 100644 mobile/test/features/channels/agent_activity/active_agent_turns_test.dart create mode 100644 mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart create mode 100644 mobile/test/features/channels/agent_activity/working_bots_provider_test.dart diff --git a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart new file mode 100644 index 0000000000..0de0cd8b48 --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart @@ -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 reduceAgentTurnStates( + Map> framesByAgent, { + required DateTime now, +}) { + final states = []; + + for (final entry in framesByAgent.entries) { + final agentPubkey = entry.key.toLowerCase(); + final frames = [...entry.value]..sort(_compareFrames); + final turnsById = {}; + final terminalOrderById = {}; + + 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( + 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 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((ref) async* { + yield DateTime.now().toUtc(); + yield* Stream.periodic( + _activeTurnClockInterval, + (_) => DateTime.now().toUtc(), + ); +}); + +/// Observer-derived turn states, including buffered terminal outcomes. +final agentTurnStatesProvider = Provider>((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>((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); +} diff --git a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart new file mode 100644 index 0000000000..48eb57bf9e --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart @@ -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? composerWidthAnimation; + final FocusNode? composerFocusNode; + final ValueNotifier? composerInteractionLock; + final ValueNotifier? 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(null); + final pinnedTurnId = useState(null); + final rememberedAgents = useState>(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(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 = { + ...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 compactActivityItems(List transcript) { + final useful = []; + 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 transcript; + final Map profiles; + final List selectorAgents; + final String selectedAgent; + final String Function(String) nameFor; + final ValueChanged 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( + 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, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart new file mode 100644 index 0000000000..dfda077f48 --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart @@ -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 agents; + final String selectedAgent; + final String Function(String) nameFor; + final ValueChanged 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( + 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 signals; + final String? selectedAgent; + final AgentTurnState? selectedTurn; + final List transcript; + final Map 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 signals; + final Map 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 pubkeys; + final Map 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 pubkeys, + required AgentTurnState? selectedTurn, + required List 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 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 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'; +} diff --git a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/compact_activity_item.dart b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/compact_activity_item.dart new file mode 100644 index 0000000000..8ed3799159 --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/compact_activity_item.dart @@ -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), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/agent_activity/observer_models.dart b/mobile/lib/features/channels/agent_activity/observer_models.dart index b1cc8fe22c..4ad8f5f39d 100644 --- a/mobile/lib/features/channels/agent_activity/observer_models.dart +++ b/mobile/lib/features/channels/agent_activity/observer_models.dart @@ -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 json) => ObserverFrame( + factory ObserverFrame.fromJson( + Map 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'], ); } diff --git a/mobile/lib/features/channels/agent_activity/observer_subscription.dart b/mobile/lib/features/channels/agent_activity/observer_subscription.dart index bc469c5b32..2d6f4bd9a4 100644 --- a/mobile/lib/features/channels/agent_activity/observer_subscription.dart +++ b/mobile/lib/features/channels/agent_activity/observer_subscription.dart @@ -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 { ); final plaintext = nip44Decrypt(conversationKey, event.content); final json = jsonDecode(plaintext) as Map; - 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 { return [ for (final inner in events) - ObserverFrame.fromJson(inner as Map), + ObserverFrame.fromJson( + inner as Map, + 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((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, + ); + }); diff --git a/mobile/lib/features/channels/agent_activity/transcript_builder.dart b/mobile/lib/features/channels/agent_activity/transcript_builder.dart index 2435353dec..c4b3031c5e 100644 --- a/mobile/lib/features/channels/agent_activity/transcript_builder.dart +++ b/mobile/lib/features/channels/agent_activity/transcript_builder.dart @@ -586,6 +586,20 @@ List buildTranscript(List 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; } diff --git a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart index 8730908ada..741535b32a 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -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 agents; + final List 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((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 []; + final channelAgents = { + ...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 {}; + final activeTurns = ref.watch(activeAgentTurnsProvider); + final activeByAgent = {}; + 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 = {}; + // 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 = []; + 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, String>((ref, channelId) { - final typingEntries = ref.watch(channelTypingProvider(channelId)); - final membersAsync = ref.watch(channelMembersProvider(channelId)); - final allMembers = membersAsync.asData?.value ?? const []; - - final botPubkeys = { - for (final m in allMembers) - if (m.isBot) m.pubkey.toLowerCase(), - }; - - return { - 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)); }); diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 8cef1a78ff..a350ee6778 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -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, diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 9c29c67a99..83ab45b041 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -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 mentionPubkeys, { List> mediaTags, }); + +typedef ComposeBarActivityIndicatorBuilder = + Widget Function( + Animation composerWidthAnimation, + FocusNode composerFocusNode, + ValueNotifier composerInteractionLock, + ValueNotifier composerActivationRequests, + VoidCallback restoreComposerFocus, + ); diff --git a/mobile/lib/features/channels/compose_bar/activity_handoff.dart b/mobile/lib/features/channels/compose_bar/activity_handoff.dart new file mode 100644 index 0000000000..b4c04deda5 --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/activity_handoff.dart @@ -0,0 +1,36 @@ +part of '../compose_bar.dart'; + +void _useComposerActivityFocusHandoff({ + required FocusNode focusNode, + required ValueNotifier activityInteractionLock, + required ValueNotifier composerActivationRequests, + required ValueNotifier 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 activityInteractionLock, + ValueNotifier composerActivationRequests, +) { + composerActivationRequests.value += 1; + return activityInteractionLock.value; +} diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index c6a26cc59c..13932cdaaf 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -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(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(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({}); - // Channel autocomplete state ---------------------------------------------- final channelQuery = useState(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, diff --git a/mobile/lib/features/channels/compose_bar/dock.dart b/mobile/lib/features/channels/compose_bar/dock.dart index 7a25f4cfe5..3939d4c20d 100644 --- a/mobile/lib/features/channels/compose_bar/dock.dart +++ b/mobile/lib/features/channels/compose_bar/dock.dart @@ -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, + ), + ), + ), + ], + ), ); } } diff --git a/mobile/lib/features/channels/thread_detail_helpers.dart b/mobile/lib/features/channels/thread_detail_helpers.dart index 4e13e1f392..9fba4df78d 100644 --- a/mobile/lib/features/channels/thread_detail_helpers.dart +++ b/mobile/lib/features/channels/thread_detail_helpers.dart @@ -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 entries; + final String channelId; + final String threadHeadId; final bool animated; + final double horizontalInset; + final double? overlayTopBoundary; + final double compactWidthFactor; + final Animation? composerWidthAnimation; + final FocusNode? composerFocusNode; + final ValueNotifier? composerInteractionLock; + final ValueNotifier? 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, ); } } diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 6ab3ddd9b6..d3e328b268 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -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, diff --git a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart new file mode 100644 index 0000000000..3061465d5c --- /dev/null +++ b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart @@ -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 {}, +}) { + 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, + ); +} diff --git a/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart b/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart new file mode 100644 index 0000000000..c87e529758 --- /dev/null +++ b/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart @@ -0,0 +1,1064 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +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/composer_agent_activity_indicator.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_typing_provider.dart'; +import 'package:buzz/features/profile/user_cache_provider.dart'; +import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/shared/theme/theme.dart'; + +const _channelId = 'channel-1'; +const _agentPubkey = 'agent-a'; +const _turnId = 'turn-a'; +const _secondAgentPubkey = 'agent-b'; +const _secondTurnId = 'turn-b'; +const _scope = (channelId: _channelId, threadHeadId: null); +const _turnKey = ( + channelId: _channelId, + agentPubkey: _agentPubkey, + turnId: _turnId, +); +const _secondTurnKey = ( + channelId: _channelId, + agentPubkey: _secondAgentPubkey, + turnId: _secondTurnId, +); + +void main() { + testWidgets( + 'expands inline, keeps human typing separate, and preserves completion', + (tester) async { + final container = ProviderContainer( + overrides: [ + composerActivityStateProvider( + _scope, + ).overrideWith((ref) => ref.watch(_testComposerActivityProvider)), + agentTurnStatesProvider.overrideWith( + (ref) => ref.watch(_testTurnStatesProvider), + ), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + container + .read(_testComposerActivityProvider.notifier) + .setState( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + turnId: _turnId, + ), + ], + humanTyping: [ + TypingEntry(pubkey: 'human-a', expiresAtMs: 9999999999999), + ], + ), + ); + container.read(_testTurnStatesProvider.notifier).setStates([ + _turn(AgentTurnPhase.working), + ]); + + await tester.pumpWidget(_app(container)); + await tester.pump(); + + expect( + find.byKey(const ValueKey('composer-agent-activity-control')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('channel-typing-indicator')), + findsOneWidget, + ); + expect(find.byType(BottomSheet), findsNothing); + + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 240)); + + final panel = find.byKey(const ValueKey('composer-agent-activity-panel')); + final control = find.byKey( + const ValueKey('composer-agent-activity-control'), + ); + final collapse = find.byKey( + const ValueKey('composer-agent-activity-collapse'), + ); + final composer = find.byKey(const ValueKey('fake-composer')); + expect(panel, findsOneWidget); + expect(control, findsNothing); + expect( + find.byKey(const ValueKey('composer-agent-activity-surface')), + findsOneWidget, + ); + expect(find.byType(BottomSheet), findsNothing); + expect( + tester.getRect(panel).bottom, + lessThanOrEqualTo(tester.getRect(composer).top), + ); + expect( + tester.getRect(collapse).bottom, + closeTo(tester.getRect(panel).bottom, 0.1), + ); + expect(find.text('Live activity may be partial.'), findsOneWidget); + expect( + find.byKey(const ValueKey('composer-agent-segmented-container')), + findsNothing, + ); + expect(find.text('Pollen'), findsNothing); + expect(find.text('Thinking'), findsOneWidget); + expect(find.text('private chain of thought'), findsNothing); + expect(find.textContaining('/private/path'), findsNothing); + + await tester.tap(collapse); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 240)); + await tester.pump(); + expect(panel, findsNothing); + expect(control, findsOneWidget); + + await tester.tap(control); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 240)); + expect(panel, findsOneWidget); + expect(control, findsNothing); + + container + .read(_testComposerActivityProvider.notifier) + .setState(const ComposerActivityState(agents: [], humanTyping: [])); + container.read(_testTurnStatesProvider.notifier).setStates([ + _turn(AgentTurnPhase.finished), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 240)); + + expect(panel, findsOneWidget); + expect(find.text('Finished'), findsWidgets); + + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-collapse')), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 240)); + await tester.pump(); + expect(panel, findsNothing); + expect(control, findsNothing); + }, + ); + + testWidgets('reduced motion makes inline size changes immediate', ( + tester, + ) async { + final container = ProviderContainer( + overrides: [ + composerActivityStateProvider(_scope).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + turnId: _turnId, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue([ + _turn(AgentTurnPhase.working), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + _app(container, disableAnimations: true, compactWidthFactor: 0.85), + ); + await tester.pump(); + + expect( + find.descendant( + of: find.byType(ComposerAgentActivityIndicator), + matching: find.byType(AnimatedSize), + ), + findsNothing, + ); + final compactWidth = tester + .getSize(find.byKey(const ValueKey('composer-agent-activity-surface'))) + .width; + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pump(); + await tester.pump(); + expect( + tester + .getSize( + find.byKey(const ValueKey('composer-agent-activity-overlay')), + ) + .width, + closeTo(376, 0.1), + ); + expect( + tester + .getSize( + find.byKey(const ValueKey('composer-agent-activity-overlay')), + ) + .width, + greaterThan(compactWidth), + ); + }); + + testWidgets('width and height morph together and reverse mid-expansion', ( + tester, + ) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = ProviderContainer( + overrides: [ + composerActivityStateProvider(_scope).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + turnId: _turnId, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue([ + _turn(AgentTurnPhase.working), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget(_app(container, compactWidthFactor: 0.85)); + await tester.pump(); + final compactWidth = tester + .getSize(find.byKey(const ValueKey('composer-agent-activity-surface'))) + .width; + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 60)); + + final overlay = find.byKey( + const ValueKey('composer-agent-activity-overlay'), + ); + final panel = find.byKey(const ValueKey('composer-agent-activity-panel')); + final expandingSize = tester.getSize(overlay); + final expandingPanelSize = tester.getSize(panel); + expect(tester.getCenter(overlay).dx, closeTo(200, 0.1)); + expect(expandingSize.width, greaterThan(compactWidth)); + expect(expandingSize.width, lessThan(376)); + expect(expandingSize.height, greaterThan(52)); + expect(expandingSize.height, lessThan(328)); + expect( + expandingPanelSize.width, + closeTo(expandingSize.width - Grid.twelve * 2, 0.1), + ); + expect(expandingPanelSize.height, greaterThan(44)); + expect(expandingPanelSize.height, lessThan(312)); + + tester + .widget( + find.byKey(const ValueKey('composer-agent-activity-collapse')), + ) + .onTap!(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getCenter(overlay).dx, closeTo(200, 0.1)); + expect(tester.getSize(overlay).width, lessThan(expandingSize.width)); + expect(tester.getSize(overlay).height, lessThan(expandingSize.height)); + expect(tester.getSize(panel).height, lessThan(expandingPanelSize.height)); + + final reversingSize = tester.getSize(overlay); + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-collapse')), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(overlay).width, greaterThan(reversingSize.width)); + expect(tester.getSize(overlay).height, greaterThan(reversingSize.height)); + + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-collapse')), + ); + await tester.pump(); + + await tester.pumpAndSettle(); + expect(overlay, findsNothing); + expect( + tester + .getSize( + find.byKey(const ValueKey('composer-agent-activity-surface')), + ) + .width, + closeTo(compactWidth, 0.1), + ); + }); + + testWidgets('multiple agents use a top segmented selector', (tester) async { + tester.view.physicalSize = const Size(220, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = _multiAgentContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget(_app(container)); + await tester.pump(); + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('composer-agent-activity-selector')), + findsOneWidget, + ); + final selector = find.byKey( + const ValueKey('composer-agent-segmented-container'), + ); + expect(selector, findsOneWidget); + expect(find.text('Pollen'), findsOneWidget); + expect(find.text('Sprig'), findsOneWidget); + expect(find.text('Running Search messages'), findsNothing); + final sprigSegment = find.byKey( + const ValueKey('composer-agent-segment-agent-b'), + ); + final selectorScrollView = find.descendant( + of: find.byKey(const ValueKey('composer-agent-activity-selector')), + matching: find.byType(Scrollable), + ); + expect( + tester + .state(selectorScrollView) + .position + .maxScrollExtent, + greaterThan(0), + ); + expect( + tester + .getSize(find.byKey(const ValueKey('composer-agent-segment-agent-a'))) + .height, + closeTo(tester.getSize(sprigSegment).height, 0.1), + ); + expect( + tester + .widget( + find.byKey(const ValueKey('composer-agent-segment-agent-a')), + ) + .color, + AppTheme.light().colorScheme.primary, + ); + expect(find.bySemanticsLabel('Pollen'), findsOneWidget); + expect( + tester.getTopLeft(selector).dy, + lessThan( + tester + .getTopLeft( + find.byKey(const ValueKey('composer-agent-activity-transcript')), + ) + .dy, + ), + ); + + await tester.ensureVisible(sprigSegment); + await tester.tap(sprigSegment); + await tester.pumpAndSettle(); + + expect( + tester.widget(sprigSegment).color, + AppTheme.light().colorScheme.primary, + ); + expect(find.text('Sprig'), findsOneWidget); + expect(find.text('Running Search messages'), findsOneWidget); + expect(find.text('Running Read file'), findsNothing); + }); + + testWidgets('iOS uses the native sliding segmented control', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(400, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = _multiAgentContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget(_app(container)); + await tester.pump(); + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pumpAndSettle(); + + final selector = find.byKey( + const ValueKey('composer-agent-segmented-container'), + ); + final control = tester.widget>( + selector, + ); + final frame = find.byKey(const ValueKey('composer-agent-segmented-frame')); + final frameWidget = tester.widget(frame); + final panel = find.byKey(const ValueKey('composer-agent-activity-panel')); + expect(control.groupValue, _agentPubkey); + expect(control.thumbColor, AppTheme.light().colorScheme.primary); + expect( + frameWidget.borderRadius, + BorderRadius.circular(Radii.dialog - Grid.xxs), + ); + expect( + tester.getRect(frame).left, + closeTo(tester.getRect(panel).left + Grid.xxs, 0.1), + ); + expect( + tester.getRect(frame).right, + closeTo(tester.getRect(panel).right - Grid.xxs, 0.1), + ); + expect(find.bySemanticsLabel('Pollen'), findsOneWidget); + + final sprigSegment = find.byKey( + const ValueKey('composer-agent-segment-agent-b'), + ); + await tester.ensureVisible(sprigSegment); + await tester.tap(sprigSegment); + await tester.pumpAndSettle(); + + expect( + tester + .widget>(selector) + .groupValue, + _secondAgentPubkey, + ); + expect(find.text('Running Search messages'), findsOneWidget); + expect(find.text('Running Read file'), findsNothing); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets( + 'overlays upward at the taller target without changing layout height', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = ProviderContainer( + overrides: [ + composerActivityStateProvider(_scope).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + turnId: _turnId, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue([ + _turn(AgentTurnPhase.working), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + _app(container, disableAnimations: true, overlayTopBoundary: 100), + ); + await tester.pump(); + final indicator = find.byType(ComposerAgentActivityIndicator); + final compactHeight = tester.getSize(indicator).height; + final composerTop = tester.getTopLeft( + find.byKey(const ValueKey('fake-composer')), + ); + + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pump(); + + final overlay = find.byKey( + const ValueKey('composer-agent-activity-overlay'), + ); + expect(tester.getSize(indicator).height, closeTo(compactHeight, 0.1)); + expect( + tester.getTopLeft(find.byKey(const ValueKey('fake-composer'))), + composerTop, + ); + expect(tester.getSize(overlay).height, closeTo(328, 0.1)); + expect(tester.getRect(overlay).top, greaterThanOrEqualTo(100)); + expect( + find.byKey(const ValueKey('composer-agent-activity-transcript')), + findsOneWidget, + ); + }, + ); + + testWidgets('caps the overlay below the supplied top navigation boundary', ( + tester, + ) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = ProviderContainer( + overrides: [ + composerActivityStateProvider(_scope).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + turnId: _turnId, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue([ + _turn(AgentTurnPhase.working), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + _app(container, disableAnimations: true, overlayTopBoundary: 520), + ); + await tester.pump(); + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pump(); + + final overlay = find.byKey( + const ValueKey('composer-agent-activity-overlay'), + ); + expect(tester.getRect(overlay).top, greaterThanOrEqualTo(520)); + expect(tester.getSize(overlay).height, lessThan(328)); + }); + + testWidgets( + 'dismisses an open keyboard and restores it after collapse with draft intact', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final focusNode = FocusNode(); + final draftController = TextEditingController(text: 'Keep this draft'); + final interactionLock = ValueNotifier(false); + addTearDown(focusNode.dispose); + addTearDown(draftController.dispose); + addTearDown(interactionLock.dispose); + final container = ProviderContainer( + overrides: [ + composerActivityStateProvider(_scope).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + turnId: _turnId, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue([ + _turn(AgentTurnPhase.working), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + _app( + container, + disableAnimations: true, + composerFocusNode: focusNode, + composerInteractionLock: interactionLock, + draftController: draftController, + viewInsets: const EdgeInsets.only(bottom: 300), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + expect( + MediaQuery.viewInsetsOf( + tester.element(find.byType(ComposerAgentActivityIndicator)), + ).bottom, + 300, + ); + + tester + .widget( + find.byKey(const ValueKey('composer-agent-activity-control')), + ) + .onTap!(); + await tester.pump(); + expect(focusNode.hasFocus, isFalse); + expect(interactionLock.value, isTrue); + expect( + find.byKey(const ValueKey('composer-agent-activity-panel')), + findsNothing, + ); + + await tester.pumpWidget( + _app( + container, + disableAnimations: true, + composerFocusNode: focusNode, + composerInteractionLock: interactionLock, + draftController: draftController, + ), + ); + await tester.pump(); + expect( + find.byKey(const ValueKey('composer-agent-activity-panel')), + findsOneWidget, + ); + expect(focusNode.canRequestFocus, isTrue); + + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-collapse')), + ); + await tester.pump(); + await tester.pump(); + expect(focusNode.canRequestFocus, isTrue); + expect(focusNode.hasFocus, isTrue); + expect(interactionLock.value, isFalse); + expect(draftController.text, 'Keep this draft'); + }, + ); + + testWidgets('collapse leaves a previously closed keyboard closed', ( + tester, + ) async { + final focusNode = FocusNode(); + final interactionLock = ValueNotifier(false); + addTearDown(focusNode.dispose); + addTearDown(interactionLock.dispose); + final container = ProviderContainer( + overrides: [ + composerActivityStateProvider(_scope).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + turnId: _turnId, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue([ + _turn(AgentTurnPhase.working), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + _app( + container, + disableAnimations: true, + composerFocusNode: focusNode, + composerInteractionLock: interactionLock, + ), + ); + await tester.pump(); + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pump(); + expect(focusNode.canRequestFocus, isTrue); + expect(interactionLock.value, isTrue); + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-collapse')), + ); + await tester.pump(); + await tester.pump(); + + expect(focusNode.canRequestFocus, isTrue); + expect(focusNode.hasFocus, isFalse); + expect(interactionLock.value, isFalse); + }); + + testWidgets( + 'composer activation hands off from activity before restoring focus', + (tester) async { + final focusNode = FocusNode(); + final interactionLock = ValueNotifier(false); + final activationRequests = ValueNotifier(0); + addTearDown(focusNode.dispose); + addTearDown(interactionLock.dispose); + addTearDown(activationRequests.dispose); + final container = ProviderContainer( + overrides: [ + composerActivityStateProvider(_scope).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + turnId: _turnId, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue([ + _turn(AgentTurnPhase.working), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + _app( + container, + disableAnimations: true, + composerFocusNode: focusNode, + composerInteractionLock: interactionLock, + composerActivationRequests: activationRequests, + ), + ); + await tester.pump(); + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pump(); + expect( + find.byKey(const ValueKey('composer-agent-activity-panel')), + findsOneWidget, + ); + expect(interactionLock.value, isTrue); + expect(focusNode.hasFocus, isFalse); + + activationRequests.value += 1; + await tester.pump(); + await tester.pump(); + + expect( + find.byKey(const ValueKey('composer-agent-activity-panel')), + findsNothing, + ); + expect(interactionLock.value, isFalse); + expect(focusNode.hasFocus, isTrue); + }, + ); + + testWidgets('an unowned agent is status-only and cannot open activity', ( + tester, + ) async { + final container = ProviderContainer( + overrides: [ + composerActivityStateProvider(_scope).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _secondAgentPubkey, + source: AgentWorkingSource.typing, + canViewActivity: false, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue(const []), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget(_app(container)); + await tester.pump(); + + expect( + find.byKey(const ValueKey('composer-agent-status-only')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('composer-agent-activity-control')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('composer-agent-activity-panel')), + findsNothing, + ); + }); +} + +ProviderContainer _multiAgentContainer() => ProviderContainer( + overrides: [ + composerActivityStateProvider(_scope).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + turnId: _turnId, + ), + WorkingAgentSignal( + pubkey: _secondAgentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + turnId: _secondTurnId, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue([ + _turn(AgentTurnPhase.working), + _turnFor( + pubkey: _secondAgentPubkey, + turnId: _secondTurnId, + phase: AgentTurnPhase.working, + ), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + observerTurnSubscriptionProvider( + _secondTurnKey, + ).overrideWithValue(_secondObserverState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], +); + +Widget _app( + ProviderContainer container, { + bool disableAnimations = false, + double? overlayTopBoundary, + FocusNode? composerFocusNode, + TextEditingController? draftController, + EdgeInsets viewInsets = EdgeInsets.zero, + double compactWidthFactor = 1, + Animation? composerWidthAnimation, + ValueNotifier? composerInteractionLock, + ValueNotifier? composerActivationRequests, +}) { + Widget activityIndicator = ComposerAgentActivityIndicator( + channelId: _channelId, + overlayTopBoundary: overlayTopBoundary, + compactWidthFactor: compactWidthFactor, + composerWidthAnimation: composerWidthAnimation, + composerFocusNode: composerFocusNode, + composerInteractionLock: composerInteractionLock, + composerActivationRequests: composerActivationRequests, + ); + if (compactWidthFactor < 1) { + activityIndicator = Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), + child: Align( + child: FractionallySizedBox( + widthFactor: compactWidthFactor, + child: activityIndicator, + ), + ), + ); + } + Widget fakeComposer = SizedBox( + key: const ValueKey('fake-composer'), + height: 64, + child: TextField(focusNode: composerFocusNode, controller: draftController), + ); + if (composerInteractionLock != null) { + fakeComposer = ValueListenableBuilder( + valueListenable: composerInteractionLock, + builder: (context, interactionLocked, child) => + AbsorbPointer(absorbing: interactionLocked, child: child), + child: fakeComposer, + ); + } + return UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: AppTheme.light(), + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith( + disableAnimations: disableAnimations, + viewInsets: viewInsets, + ), + child: Scaffold( + resizeToAvoidBottomInset: false, + body: Column( + children: [ + const Expanded(child: SizedBox()), + activityIndicator, + fakeComposer, + ], + ), + ), + ), + ), + ), + ); +} + +AgentTurnState _turn(AgentTurnPhase phase) => 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: phase, + terminalAt: phase == AgentTurnPhase.working + ? null + : DateTime.utc(2026, 8, 16, 12, 0, 6), +); + +AgentTurnState _turnFor({ + required String pubkey, + required String turnId, + required AgentTurnPhase phase, +}) => AgentTurnState( + agentPubkey: pubkey, + 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: phase, +); + +final _observerState = ObserverState( + connection: ObserverConnectionState.open, + transcript: [ + ThoughtItem( + id: 'thought-1', + title: 'Thinking', + text: 'private chain of thought', + timestamp: '2026-08-16T12:00:01Z', + ), + ToolItem( + id: 'tool-1', + title: 'Read file', + toolName: 'read_file', + status: ToolStatus.executing, + args: const {'path': '/private/path'}, + result: '', + isError: false, + timestamp: '2026-08-16T12:00:02Z', + ), + ], +); + +final _secondObserverState = ObserverState( + connection: ObserverConnectionState.open, + transcript: [ + ToolItem( + id: 'tool-2', + title: 'Search messages', + toolName: 'search', + status: ToolStatus.executing, + args: const {}, + result: '', + isError: false, + timestamp: '2026-08-16T12:00:02Z', + ), + ], +); + +final _testComposerActivityProvider = + NotifierProvider<_TestComposerActivityNotifier, ComposerActivityState>( + _TestComposerActivityNotifier.new, + ); + +class _TestComposerActivityNotifier extends Notifier { + @override + ComposerActivityState build() => + const ComposerActivityState(agents: [], humanTyping: []); + + void setState(ComposerActivityState next) => state = next; +} + +final _testTurnStatesProvider = + NotifierProvider<_TestTurnStatesNotifier, List>( + _TestTurnStatesNotifier.new, + ); + +class _TestTurnStatesNotifier extends Notifier> { + @override + List build() => const []; + + void setStates(List next) => state = next; +} + +class _FakeUserCacheNotifier extends UserCacheNotifier { + @override + Map build() => const { + _agentPubkey: UserProfile( + pubkey: _agentPubkey, + displayName: 'Pollen', + ownerPubkey: 'owner', + ), + 'human-a': UserProfile(pubkey: 'human-a', displayName: 'Alice'), + _secondAgentPubkey: UserProfile( + pubkey: _secondAgentPubkey, + displayName: 'Sprig', + ownerPubkey: 'owner', + ), + }; + + @override + void preload(List pubkeys) {} +} diff --git a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart index f54b0594ef..a9a55d080f 100644 --- a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart +++ b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart @@ -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().single.text, 'First turn'); + expect( + state.transcript.whereType().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()); 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 _observerFrameJson({ required int seq, required String channelId, @@ -614,6 +682,18 @@ class _RecordingRelaySession extends RelaySessionNotifier { } } +class _StaticObserverRelay extends ObserverRelayNotifier { + final Map> frames; + + _StaticObserverRelay(this.frames); + + @override + ObserverRelayState build() => ObserverRelayState( + connection: ObserverConnectionState.open, + framesByAgent: frames, + ); +} + class _FakeRelayConfigNotifier extends RelayConfigNotifier { String? _nsec; diff --git a/mobile/test/features/channels/agent_activity/transcript_builder_test.dart b/mobile/test/features/channels/agent_activity/transcript_builder_test.dart index fd907353f9..46abdafcb6 100644 --- a/mobile/test/features/channels/agent_activity/transcript_builder_test.dart +++ b/mobile/test/features/channels/agent_activity/transcript_builder_test.dart @@ -122,6 +122,29 @@ void main() { expect(items[1], isA()); 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()); + expect((items.single as LifecycleItem).title, 'Turn error'); + expect((items.single as LifecycleItem).text, 'failed: Permission denied'); + }); } ObserverFrame _updateFrame({ diff --git a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart new file mode 100644 index 0000000000..eed024eb1b --- /dev/null +++ b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart @@ -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 []), + 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 []), + 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 entries; + + _FakeTypingNotifier(this.entries) : super(_channelId); + + @override + List build() => entries; +} + +class _FakeUserCacheNotifier extends UserCacheNotifier { + @override + Map build() => const {}; + + @override + void preload(List pubkeys) {} +} + +class _FakeObserverRelayNotifier extends ObserverRelayNotifier { + final Map> frames; + + _FakeObserverRelayNotifier(this.frames); + + @override + ObserverRelayState build() => ObserverRelayState( + connection: ObserverConnectionState.open, + framesByAgent: frames, + ); +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 79e385452b..df44a0efb5 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -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 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(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( + 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( + 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( + find.byType(TextField), + ); + expect(restoredTextField.focusNode?.hasFocus, isTrue); + }, + ); + testWidgets( 'seeds an already-visible Android keyboard into the channel tail layout', (tester) async {