Add inline mobile agent activity

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-16 16:40:23 +01:00
parent f956e6fe06
commit 7af71e9ce7
21 changed files with 3898 additions and 118 deletions

View File

@@ -0,0 +1,313 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../shared/relay/relay.dart';
import 'observer_models.dart';
import 'observer_subscription.dart';
const _defaultLivenessTimeout = Duration(seconds: 30);
const _activeTurnClockInterval = Duration(seconds: 5);
const _maximumLivenessInterval = Duration(hours: 24);
const _livenessTimeoutSlack = Duration(seconds: 30);
/// Lifecycle state reconstructed from owner-scoped observer frames.
enum AgentTurnPhase { working, finished, error }
/// One observed agent turn, including its explicit terminal outcome when known.
@immutable
class AgentTurnState {
final String agentPubkey;
final String channelId;
final String turnId;
final DateTime startedAt;
final DateTime lastActivityAt;
final Duration livenessTimeout;
final AgentTurnPhase phase;
final DateTime? terminalAt;
final String? errorMessage;
final String? triggeringEventId;
const AgentTurnState({
required this.agentPubkey,
required this.channelId,
required this.turnId,
required this.startedAt,
required this.lastActivityAt,
required this.livenessTimeout,
required this.phase,
this.terminalAt,
this.errorMessage,
this.triggeringEventId,
});
bool get isWorking => phase == AgentTurnPhase.working;
AgentTurnState withActivity({required DateTime at, Duration? timeout}) =>
AgentTurnState(
agentPubkey: agentPubkey,
channelId: channelId,
turnId: turnId,
startedAt: startedAt,
lastActivityAt: at,
livenessTimeout: timeout ?? livenessTimeout,
phase: AgentTurnPhase.working,
triggeringEventId: triggeringEventId,
);
AgentTurnState withTerminal({
required AgentTurnPhase phase,
required DateTime at,
String? errorMessage,
}) => AgentTurnState(
agentPubkey: agentPubkey,
channelId: channelId,
turnId: turnId,
startedAt: startedAt,
lastActivityAt: at,
livenessTimeout: livenessTimeout,
phase: phase,
terminalAt: at,
errorMessage: errorMessage,
triggeringEventId: triggeringEventId,
);
}
/// Reconstructs live and explicitly terminal turns from the retained frame
/// buffer. Silent working turns expire instead of being mislabeled finished.
List<AgentTurnState> reduceAgentTurnStates(
Map<String, List<ObserverFrame>> framesByAgent, {
required DateTime now,
}) {
final states = <AgentTurnState>[];
for (final entry in framesByAgent.entries) {
final agentPubkey = entry.key.toLowerCase();
final frames = [...entry.value]..sort(_compareFrames);
final turnsById = <String, AgentTurnState>{};
final terminalOrderById = <String, DateTime>{};
for (final frame in frames) {
final frameOrderAt = _frameTimestamp(frame);
final frameAt = frame.receivedAt ?? frameOrderAt;
switch (frame.kind) {
case 'turn_started':
final channelId = frame.channelId;
if (channelId == null) continue;
final turnId = frame.turnId ?? 'seq-${frame.seq}';
turnsById[turnId] = AgentTurnState(
agentPubkey: agentPubkey,
channelId: channelId,
turnId: turnId,
startedAt: _safeStartedAt(frame, frameAt),
lastActivityAt: frameAt,
livenessTimeout: _livenessTimeout(frame.payload),
phase: AgentTurnPhase.working,
triggeringEventId: _triggeringEventId(frame.payload),
);
case 'turn_completed':
case 'turn_error':
case 'agent_panic':
final terminalPhase = frame.kind == 'turn_completed'
? AgentTurnPhase.finished
: AgentTurnPhase.error;
final turnId = frame.turnId;
if (turnId != null) {
terminalOrderById[turnId] = frameOrderAt;
final existing = turnsById[turnId];
final channelId = existing?.channelId ?? frame.channelId;
if (channelId == null) continue;
turnsById[turnId] =
existing?.withTerminal(
phase: terminalPhase,
at: frameAt,
errorMessage: _turnError(frame.payload),
) ??
AgentTurnState(
agentPubkey: agentPubkey,
channelId: channelId,
turnId: turnId,
startedAt: _safeStartedAt(frame, frameAt),
lastActivityAt: frameAt,
livenessTimeout: _livenessTimeout(frame.payload),
phase: terminalPhase,
terminalAt: frameAt,
errorMessage: _turnError(frame.payload),
);
continue;
}
final channelId = frame.channelId;
if (channelId == null) continue;
final matching = turnsById.values
.where((turn) => turn.channelId == channelId && turn.isWorking)
.fold<AgentTurnState?>(
null,
(latest, turn) =>
latest == null ||
turn.lastActivityAt.isAfter(latest.lastActivityAt)
? turn
: latest,
);
if (matching == null) continue;
terminalOrderById[matching.turnId] = frameOrderAt;
turnsById[matching.turnId] = matching.withTerminal(
phase: terminalPhase,
at: frameAt,
errorMessage: _turnError(frame.payload),
);
case 'acp_read':
case 'acp_write':
case 'turn_liveness':
final turnId = frame.turnId;
if (turnId == null) continue;
final existing = turnsById[turnId];
if (existing?.isWorking == true) {
turnsById[turnId] = existing!.withActivity(
at: frameAt,
timeout: frame.kind == 'turn_liveness'
? _livenessTimeout(frame.payload)
: null,
);
continue;
}
final terminalOrder = terminalOrderById[turnId];
if (terminalOrder != null && !frameOrderAt.isAfter(terminalOrder)) {
continue;
}
final channelId = frame.channelId;
if (channelId == null) continue;
turnsById[turnId] = AgentTurnState(
agentPubkey: agentPubkey,
channelId: channelId,
turnId: turnId,
startedAt: _safeStartedAt(frame, frameAt),
lastActivityAt: frameAt,
livenessTimeout: _livenessTimeout(frame.payload),
phase: AgentTurnPhase.working,
);
}
}
states.addAll(
turnsById.values.where(
(turn) =>
!turn.isWorking ||
now.difference(turn.lastActivityAt) <= turn.livenessTimeout,
),
);
}
states.sort((a, b) {
final started = a.startedAt.compareTo(b.startedAt);
if (started != 0) return started;
final agent = a.agentPubkey.compareTo(b.agentPubkey);
if (agent != 0) return agent;
return a.turnId.compareTo(b.turnId);
});
return List.unmodifiable(states);
}
/// Most recent state for one agent in one channel.
AgentTurnState? latestAgentTurnState(
Iterable<AgentTurnState> states, {
required String agentPubkey,
required String channelId,
String? turnId,
}) {
final normalizedAgent = agentPubkey.toLowerCase();
AgentTurnState? latest;
for (final state in states) {
if (state.agentPubkey != normalizedAgent ||
state.channelId != channelId ||
(turnId != null && state.turnId != turnId)) {
continue;
}
if (latest == null || state.lastActivityAt.isAfter(latest.lastActivityAt)) {
latest = state;
}
}
return latest;
}
final _activeAgentTurnClockProvider = StreamProvider<DateTime>((ref) async* {
yield DateTime.now().toUtc();
yield* Stream<DateTime>.periodic(
_activeTurnClockInterval,
(_) => DateTime.now().toUtc(),
);
});
/// Observer-derived turn states, including buffered terminal outcomes.
final agentTurnStatesProvider = Provider<List<AgentTurnState>>((ref) {
final observerState = ref.watch(observerRelayProvider);
if (observerState.framesByAgent.isEmpty) return const [];
ref.watch(appLifecycleProvider);
final now =
ref.watch(_activeAgentTurnClockProvider).value ?? DateTime.now().toUtc();
return reduceAgentTurnStates(observerState.framesByAgent, now: now);
});
/// Observer-derived turns that are still live according to local receipt time.
final activeAgentTurnsProvider = Provider<List<AgentTurnState>>((ref) {
return [
for (final turn in ref.watch(agentTurnStatesProvider))
if (turn.isWorking) turn,
];
});
DateTime _frameTimestamp(ObserverFrame frame) =>
DateTime.tryParse(frame.timestamp)?.toUtc() ??
DateTime.fromMillisecondsSinceEpoch(frame.seq, isUtc: true);
DateTime _safeStartedAt(ObserverFrame frame, DateTime frameAt) {
final hostFrameAt = DateTime.tryParse(frame.timestamp)?.toUtc();
final hostStartedAt = DateTime.tryParse(frame.startedAt ?? '')?.toUtc();
if (hostFrameAt == null ||
hostStartedAt == null ||
hostStartedAt.isAfter(hostFrameAt)) {
return frameAt;
}
return frameAt.subtract(hostFrameAt.difference(hostStartedAt));
}
String? _triggeringEventId(dynamic payload) {
if (payload is! Map) return null;
final eventIds = payload['triggeringEventIds'];
if (eventIds is! List) return null;
for (final eventId in eventIds) {
if (eventId is String && eventId.isNotEmpty) return eventId;
}
return null;
}
String? _turnError(dynamic payload) {
if (payload is! Map) return null;
final error = payload['error'];
return error is String && error.trim().isNotEmpty ? error.trim() : null;
}
Duration _livenessTimeout(dynamic payload) {
final rawInterval = payload is Map ? payload['livenessIntervalSecs'] : null;
if (rawInterval is! num) return _defaultLivenessTimeout;
final intervalSeconds = rawInterval.toInt();
if (intervalSeconds <= 0) {
return _maximumLivenessInterval + _livenessTimeoutSlack;
}
final boundedInterval = intervalSeconds.clamp(
5,
_maximumLivenessInterval.inSeconds,
);
final timeoutSeconds = boundedInterval + _livenessTimeoutSlack.inSeconds;
return Duration(
seconds: timeoutSeconds < _defaultLivenessTimeout.inSeconds
? _defaultLivenessTimeout.inSeconds
: timeoutSeconds,
);
}
int _compareFrames(ObserverFrame a, ObserverFrame b) {
final timestamp = _frameTimestamp(a).compareTo(_frameTimestamp(b));
return timestamp != 0 ? timestamp : a.seq.compareTo(b.seq);
}

View File

@@ -0,0 +1,958 @@
import 'dart:math' as math;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../shared/theme/theme.dart';
import '../../../shared/utils/string_utils.dart';
import '../../../shared/widgets/buzz_loading_indicator.dart';
import '../../../shared/widgets/frosted_app_bar.dart';
import '../../profile/user_cache_provider.dart';
import '../../profile/user_profile.dart';
import '../channel_typing_indicator.dart';
import '../small_avatar.dart';
import 'active_agent_turns.dart';
import 'observer_models.dart';
import 'observer_subscription.dart';
import 'working_bots_provider.dart';
part 'composer_agent_activity_indicator/agent_activity_controls.dart';
part 'composer_agent_activity_indicator/compact_activity_item.dart';
/// Composer-adjacent agent status that expands upward into a bounded activity
/// stream without moving or covering the composer itself.
class ComposerAgentActivityIndicator extends HookConsumerWidget {
static const _compactSurfaceHeight = 52.0;
static const _expandedTargetHeight = 328.0;
static const _surfaceMorphDuration = Duration(milliseconds: 220);
static const _surfaceMorphCurve = Cubic(0.77, 0, 0.175, 1);
final String channelId;
final String? threadHeadId;
final bool animated;
final double horizontalInset;
final double? overlayTopBoundary;
final double compactWidthFactor;
final Animation<double>? composerWidthAnimation;
final FocusNode? composerFocusNode;
final ValueNotifier<bool>? composerInteractionLock;
final ValueNotifier<int>? composerActivationRequests;
final VoidCallback? onRestoreComposerFocus;
const ComposerAgentActivityIndicator({
super.key,
required this.channelId,
this.threadHeadId,
this.animated = true,
this.horizontalInset = Grid.twelve,
this.overlayTopBoundary,
this.compactWidthFactor = 1,
this.composerWidthAnimation,
this.composerFocusNode,
this.composerInteractionLock,
this.composerActivationRequests,
this.onRestoreComposerFocus,
}) : assert(compactWidthFactor > 0 && compactWidthFactor <= 1);
@override
Widget build(BuildContext context, WidgetRef ref) {
final activity = ref.watch(
composerActivityStateProvider((
channelId: channelId,
threadHeadId: threadHeadId,
)),
);
final turnStates = ref.watch(agentTurnStatesProvider);
final profiles = ref.watch(userCacheProvider);
final expanded = useState(false);
final pendingExpansion = useState(false);
final selectedAgent = useState<String?>(null);
final pinnedTurnId = useState<String?>(null);
final rememberedAgents = useState<List<String>>(const []);
final activityAnchorKey = useMemoized(GlobalKey.new);
final activityOverlayController = useMemoized(OverlayPortalController.new);
final compactActivityWidth = useState(0.0);
final expandedHeight = useState(_expandedTargetHeight);
final expansionController = useAnimationController(
duration: _surfaceMorphDuration,
);
final scrollController = useScrollController();
final followsTail = useRef(true);
final composerFocusToRestore = useRef<FocusNode?>(null);
final restoreComposerFocus = useRef(false);
final previousTranscriptVersion = useRef('');
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final motionDisabled = !animated || reducedMotion;
final mediaSize = MediaQuery.sizeOf(context);
final viewInsets = MediaQuery.viewInsetsOf(context);
final viewPadding = MediaQuery.viewPaddingOf(context);
final platformView = View.of(context);
final keyboardInsetBottom = math.max(
viewInsets.bottom,
platformView.viewInsets.bottom / platformView.devicePixelRatio,
);
final widthAnimation = composerWidthAnimation ?? kAlwaysDismissedAnimation;
useListenable(widthAnimation);
final composerWidthProgress = widthAnimation.value
.clamp(0.0, 1.0)
.toDouble();
void updateOverlayGeometry() {
final renderObject = activityAnchorKey.currentContext?.findRenderObject();
if (renderObject is! RenderBox || !renderObject.hasSize) return;
final anchorBottom = renderObject
.localToGlobal(Offset(0, renderObject.size.height))
.dy;
final topBoundary = overlayTopBoundary ?? frostedAppBarHeight(context);
final availableHeight = math.max(
_compactSurfaceHeight,
anchorBottom - topBoundary - Grid.xxs,
);
final nextExpandedHeight = math.min(
_expandedTargetHeight,
availableHeight,
);
final nextCompactWidth = renderObject.size.width;
if ((compactActivityWidth.value - nextCompactWidth).abs() >= 0.5) {
compactActivityWidth.value = nextCompactWidth;
}
if ((expandedHeight.value - nextExpandedHeight).abs() >= 0.5) {
expandedHeight.value = nextExpandedHeight;
}
}
void releaseComposerFocusLock() {
final interactionLock = composerInteractionLock;
if (interactionLock != null && interactionLock.value) {
interactionLock.value = false;
}
}
void restoreComposerAfterCollapse() {
final focusNode = composerFocusToRestore.value;
final shouldRestore = restoreComposerFocus.value;
composerFocusToRestore.value = null;
restoreComposerFocus.value = false;
releaseComposerFocusLock();
if (!shouldRestore || focusNode == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
if (onRestoreComposerFocus != null) {
onRestoreComposerFocus!();
} else if (focusNode.canRequestFocus) {
focusNode.requestFocus();
}
});
}
useEffect(() {
return () {
final interactionLock = composerInteractionLock;
if (interactionLock != null && interactionLock.value) {
interactionLock.value = false;
}
};
}, [composerInteractionLock]);
void hideActivityOverlay() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted && activityOverlayController.isShowing) {
activityOverlayController.hide();
}
});
}
useEffect(
() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) updateOverlayGeometry();
});
return null;
},
[
mediaSize.height,
keyboardInsetBottom,
viewPadding.top,
overlayTopBoundary,
expanded.value,
],
);
useEffect(() {
if (!pendingExpansion.value ||
keyboardInsetBottom > 0.5 ||
composerWidthProgress > 0.001) {
return null;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted || !pendingExpansion.value) return;
updateOverlayGeometry();
activityOverlayController.show();
pendingExpansion.value = false;
expanded.value = true;
});
return null;
}, [pendingExpansion.value, keyboardInsetBottom, composerWidthProgress]);
useEffect(() {
if (motionDisabled) {
expansionController.value = expanded.value ? 1 : 0;
} else if (expanded.value) {
expansionController.forward();
} else {
expansionController.reverse();
}
return null;
}, [expanded.value, motionDisabled, expansionController]);
useEffect(() {
void clearSelectionAfterCollapse(AnimationStatus status) {
if (status != AnimationStatus.dismissed || expanded.value) return;
selectedAgent.value = null;
pinnedTurnId.value = null;
rememberedAgents.value = const [];
hideActivityOverlay();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted ||
expanded.value ||
!expansionController.isDismissed) {
return;
}
restoreComposerAfterCollapse();
});
}
expansionController.addStatusListener(clearSelectionAfterCollapse);
return () =>
expansionController.removeStatusListener(clearSelectionAfterCollapse);
}, [expansionController]);
final viewableWorking = [
for (final signal in activity.agents)
if (signal.canViewActivity) signal,
];
final statusOnlyAgents = [
for (final signal in activity.agents)
if (!signal.canViewActivity) signal,
];
final effectiveSelectedAgent =
selectedAgent.value ?? viewableWorking.firstOrNull?.pubkey;
final selectedSignal = viewableWorking
.where((signal) => signal.pubkey == effectiveSelectedAgent)
.firstOrNull;
final effectiveTurnId = pinnedTurnId.value ?? selectedSignal?.turnId;
final selectedTurn =
effectiveSelectedAgent == null || effectiveTurnId == null
? null
: latestAgentTurnState(
turnStates,
agentPubkey: effectiveSelectedAgent,
channelId: channelId,
turnId: effectiveTurnId,
);
final ObserverState? observerState;
if (effectiveSelectedAgent == null || effectiveTurnId == null) {
observerState = null;
} else {
observerState = ref.watch(
observerTurnSubscriptionProvider((
channelId: channelId,
agentPubkey: effectiveSelectedAgent,
turnId: effectiveTurnId,
)),
);
}
final transcript = compactActivityItems(
observerState?.transcript ?? const [],
);
String nameFor(String pubkey) =>
profiles[pubkey.toLowerCase()]?.label ?? shortPubkey(pubkey);
useEffect(() {
final pubkeys = {
...activity.agents.map((signal) => signal.pubkey),
?effectiveSelectedAgent,
};
if (pubkeys.isNotEmpty) {
ref.read(userCacheProvider.notifier).preload(pubkeys.toList());
}
return null;
}, [activity.agents, effectiveSelectedAgent]);
useEffect(() {
final turnId = selectedSignal?.turnId;
if (!expanded.value || pinnedTurnId.value != null || turnId == null) {
return null;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted && expanded.value && pinnedTurnId.value == null) {
pinnedTurnId.value = turnId;
}
});
return null;
}, [expanded.value, selectedSignal?.turnId]);
final transcriptVersion = _transcriptVersion(transcript);
useEffect(() {
if (transcriptVersion == previousTranscriptVersion.value) return null;
previousTranscriptVersion.value = transcriptVersion;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients && followsTail.value) {
scrollController.jumpTo(scrollController.position.maxScrollExtent);
}
});
return null;
}, [transcriptVersion, effectiveSelectedAgent]);
void collapse() {
pendingExpansion.value = false;
expanded.value = false;
followsTail.value = true;
if (expansionController.isDismissed) {
selectedAgent.value = null;
pinnedTurnId.value = null;
rememberedAgents.value = const [];
hideActivityOverlay();
restoreComposerAfterCollapse();
}
}
useEffect(
() {
final requests = composerActivationRequests;
if (requests == null) return null;
void handOffToComposer() {
if (!expanded.value && !pendingExpansion.value) return;
if (composerInteractionLock != null) {
composerInteractionLock!.value = true;
}
final focusNode = composerFocusNode;
if (focusNode != null) {
restoreComposerFocus.value = true;
composerFocusToRestore.value = focusNode;
}
collapse();
}
requests.addListener(handOffToComposer);
return () => requests.removeListener(handOffToComposer);
},
[
composerActivationRequests,
composerFocusNode,
expanded.value,
pendingExpansion.value,
],
);
void selectAgent(String pubkey) {
final signal = viewableWorking
.where((candidate) => candidate.pubkey == pubkey)
.firstOrNull;
final latestTurn = latestAgentTurnState(
turnStates,
agentPubkey: pubkey,
channelId: channelId,
);
selectedAgent.value = pubkey;
pinnedTurnId.value = signal?.turnId ?? latestTurn?.turnId;
followsTail.value = true;
}
void toggleExpanded() {
if (expanded.value || pendingExpansion.value) {
collapse();
return;
}
final initial = effectiveSelectedAgent;
if (initial == null) return;
selectedAgent.value = initial;
pinnedTurnId.value = selectedSignal?.turnId;
rememberedAgents.value = [
for (final signal in viewableWorking) signal.pubkey,
];
followsTail.value = true;
final wasKeyboardOpen = keyboardInsetBottom > 0.5;
final focusedNode =
composerFocusNode ?? FocusManager.instance.primaryFocus;
restoreComposerFocus.value =
wasKeyboardOpen && focusedNode?.hasFocus == true;
composerFocusToRestore.value = restoreComposerFocus.value
? focusedNode
: null;
if (composerInteractionLock != null) {
composerInteractionLock!.value = true;
}
if (composerFocusNode != null) {
composerFocusNode!.unfocus(
disposition: UnfocusDisposition.previouslyFocusedChild,
);
} else if (focusedNode != null) {
focusedNode.unfocus(
disposition: UnfocusDisposition.previouslyFocusedChild,
);
}
if (wasKeyboardOpen || composerWidthProgress > 0.001) {
pendingExpansion.value = true;
} else {
updateOverlayGeometry();
activityOverlayController.show();
expanded.value = true;
}
}
final selectorAgents = <String>{
...rememberedAgents.value,
...viewableWorking.map((signal) => signal.pubkey),
?effectiveSelectedAgent,
}.toList();
final hasContent =
expanded.value ||
rememberedAgents.value.isNotEmpty ||
activity.agents.isNotEmpty ||
activity.humanTyping.isNotEmpty;
final child = hasContent
? Column(
key: const ValueKey('composer-activity-region'),
mainAxisSize: MainAxisSize.min,
children: [
if ((viewableWorking.isNotEmpty ||
rememberedAgents.value.isNotEmpty) &&
effectiveSelectedAgent != null)
OverlayPortal.overlayChildLayoutBuilder(
controller: activityOverlayController,
overlayChildBuilder: (context, layoutInfo) {
final anchorOrigin = MatrixUtils.transformPoint(
layoutInfo.childPaintTransform,
Offset.zero,
);
final availableWidth = math.max(
layoutInfo.childSize.width,
layoutInfo.overlaySize.width - Grid.twelve * 2,
);
final expandedActivityWidth = math.max(
layoutInfo.childSize.width,
math.min(
availableWidth,
layoutInfo.childSize.width / compactWidthFactor,
),
);
return Stack(
children: [
AnimatedBuilder(
animation: expansionController,
builder: (context, _) {
final progress = _surfaceMorphCurve.transform(
expansionController.value,
);
final panelViewportHeight =
_compactSurfaceHeight +
(expandedHeight.value - _compactSurfaceHeight) *
progress;
final panelWidth =
compactActivityWidth.value +
(expandedActivityWidth -
compactActivityWidth.value) *
progress;
if (panelWidth <= 0) {
return const SizedBox.shrink();
}
return Positioned(
left:
(layoutInfo.overlaySize.width - panelWidth) /
2,
bottom:
layoutInfo.overlaySize.height -
anchorOrigin.dy -
layoutInfo.childSize.height,
child: SizedBox(
key: const ValueKey(
'composer-agent-activity-overlay',
),
width: panelWidth,
height: panelViewportHeight,
child: IgnorePointer(
ignoring: expansionController.isDismissed,
child: ExcludeSemantics(
excluding: expansionController.isDismissed,
child: ClipRect(
child: _InlineActivityPanel(
height: panelViewportHeight,
expandedHeight: expandedHeight.value,
morphProgress: progress,
targetExpanded: expanded.value,
agentName: nameFor(
effectiveSelectedAgent,
),
selectedTurn: selectedTurn,
isFallbackWorking:
selectedSignal != null,
observerState: observerState,
transcript: transcript,
profiles: profiles,
selectorAgents: selectorAgents,
selectedAgent: effectiveSelectedAgent,
nameFor: nameFor,
onSelectAgent: selectAgent,
onToggleExpanded: toggleExpanded,
horizontalInset: horizontalInset,
scrollController: scrollController,
onScroll: (notification) {
if (notification
is ScrollUpdateNotification &&
notification.dragDetails !=
null) {
followsTail.value =
notification
.metrics
.maxScrollExtent -
notification
.metrics
.pixels <=
24;
}
return false;
},
),
),
),
),
),
);
},
),
],
);
},
child: SizedBox(
key: activityAnchorKey,
width: double.infinity,
height: _compactSurfaceHeight,
child: Semantics(
key: const ValueKey('composer-agent-activity-surface'),
container: true,
child: AnimatedBuilder(
animation: expansionController,
builder: (context, _) {
final progress = _surfaceMorphCurve.transform(
expansionController.value,
);
if (progress >= 1 || viewableWorking.isEmpty) {
return const SizedBox.expand();
}
return Opacity(
opacity: 1 - progress,
child: IgnorePointer(
ignoring:
expanded.value ||
!expansionController.isDismissed,
child: _AgentActivityControl(
signals: viewableWorking,
selectedAgent: effectiveSelectedAgent,
selectedTurn: selectedTurn,
transcript: transcript,
profiles: profiles,
nameFor: nameFor,
onTap: toggleExpanded,
horizontalInset: horizontalInset,
),
),
);
},
),
),
),
),
if (statusOnlyAgents.isNotEmpty)
_AgentStatusRow(
signals: statusOnlyAgents,
profiles: profiles,
nameFor: nameFor,
horizontalInset: horizontalInset,
),
if (activity.humanTyping.isNotEmpty)
ChannelTypingIndicator(entries: activity.humanTyping),
],
)
: const SizedBox.shrink();
if (motionDisabled) return child;
return AnimatedSize(
duration: _surfaceMorphDuration,
curve: _surfaceMorphCurve,
alignment: Alignment.bottomCenter,
child: child,
);
}
}
@visibleForTesting
List<TranscriptItem> compactActivityItems(List<TranscriptItem> transcript) {
final useful = <TranscriptItem>[];
for (final item in transcript) {
if (item is MetadataItem) continue;
if (item is LifecycleItem &&
(item.title == 'Turn started' || item.title == 'Session ready')) {
continue;
}
useful.add(item);
}
return List.unmodifiable(
useful.length <= 40 ? useful : useful.sublist(useful.length - 40),
);
}
class _InlineActivityPanel extends StatelessWidget {
static const _footerHeight = 44.0;
final double height;
final double expandedHeight;
final double morphProgress;
final bool targetExpanded;
final String agentName;
final AgentTurnState? selectedTurn;
final bool isFallbackWorking;
final ObserverState? observerState;
final List<TranscriptItem> transcript;
final Map<String, UserProfile> profiles;
final List<String> selectorAgents;
final String selectedAgent;
final String Function(String) nameFor;
final ValueChanged<String> onSelectAgent;
final VoidCallback onToggleExpanded;
final double horizontalInset;
final ScrollController scrollController;
final bool Function(ScrollNotification) onScroll;
const _InlineActivityPanel({
required this.height,
required this.expandedHeight,
required this.morphProgress,
required this.targetExpanded,
required this.agentName,
required this.selectedTurn,
required this.isFallbackWorking,
required this.observerState,
required this.transcript,
required this.profiles,
required this.selectorAgents,
required this.selectedAgent,
required this.nameFor,
required this.onSelectAgent,
required this.onToggleExpanded,
required this.horizontalInset,
required this.scrollController,
required this.onScroll,
});
@override
Widget build(BuildContext context) {
final status = _activityStatus(selectedTurn, isFallbackWorking);
final headline = _selectedActivityHeadline(selectedTurn, transcript);
final compactLabel = _agentActivityLabel(
pubkeys: selectorAgents,
selectedTurn: selectedTurn,
transcript: transcript,
nameFor: nameFor,
expanded: false,
).visibleLabel;
final topInset = Grid.xxs * morphProgress;
final panelHeight = math.max(_footerHeight, height - Grid.xxs - topInset);
final expandedDetailHeight = math.max(
0.0,
expandedHeight - Grid.xxs * 2 - _footerHeight,
);
final detailsOpacity = Curves.easeOut.transform(
((morphProgress - 0.16) / 0.84).clamp(0.0, 1.0),
);
return Padding(
padding: EdgeInsets.fromLTRB(
0,
topInset,
0,
Grid.xxs,
).add(EdgeInsets.symmetric(horizontal: horizontalInset)),
child: Semantics(
container: true,
label: 'Live activity for $agentName',
child: Material(
key: const ValueKey('composer-agent-activity-panel'),
color: context.colors.surfaceContainerHighest,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.dialog),
side: BorderSide(color: Colors.black.withValues(alpha: 0.04)),
),
clipBehavior: Clip.antiAlias,
child: SizedBox(
height: panelHeight,
width: double.infinity,
child: Stack(
children: [
Positioned.fill(
bottom: _footerHeight,
child: ClipRect(
child: IgnorePointer(
ignoring: morphProgress < 0.95,
child: Opacity(
key: const ValueKey('composer-agent-activity-details'),
opacity: detailsOpacity,
child: OverflowBox(
alignment: Alignment.topCenter,
minHeight: expandedDetailHeight,
maxHeight: expandedDetailHeight,
child: Column(
children: [
if (selectorAgents.length > 1)
_AgentSegmentedControl(
agents: selectorAgents,
selectedAgent: selectedAgent,
nameFor: nameFor,
onSelectAgent: onSelectAgent,
)
else
const SizedBox(height: Grid.xxs),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,
),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Live activity may be partial.',
style: context.textTheme.labelSmall
?.copyWith(
color:
context.colors.onSurfaceVariant,
),
),
),
),
Expanded(
child: transcript.isEmpty
? _ActivityEmptyState(
status: status,
observerState: observerState,
)
: NotificationListener<ScrollNotification>(
onNotification: onScroll,
child: ListView.builder(
key: const ValueKey(
'composer-agent-activity-transcript',
),
controller: scrollController,
padding: const EdgeInsets.fromLTRB(
Grid.xxs,
Grid.half,
Grid.xxs,
Grid.xxs,
),
itemCount: transcript.length,
itemBuilder: (context, index) =>
_CompactActivityItem(
item: transcript[index],
),
),
),
),
],
),
),
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: _footerHeight,
child: Semantics(
button: true,
label:
'$agentName $headline. ${targetExpanded ? 'Collapse' : 'Expand'} live activity.',
child: ExcludeSemantics(
child: InkWell(
key: const ValueKey('composer-agent-activity-collapse'),
onTap: onToggleExpanded,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,
),
child: Row(
children: [
_AgentAvatarStack(
pubkeys: selectorAgents,
profiles: profiles,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Stack(
alignment: Alignment.centerLeft,
children: [
Opacity(
opacity: 1 - morphProgress,
child: Text(
compactLabel,
style: context.textTheme.labelSmall
?.copyWith(
color: context
.colors
.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
Opacity(
opacity: morphProgress,
child: Text(
'Live activity',
style: context.textTheme.labelSmall
?.copyWith(
color: context
.colors
.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
ClipRect(
child: Align(
widthFactor: morphProgress,
child: Opacity(
opacity: morphProgress,
child: _ActivityStatusBadge(status: status),
),
),
),
SizedBox(width: Grid.half * morphProgress),
Transform.rotate(
angle: math.pi * morphProgress,
child: Icon(
LucideIcons.chevronUp,
size: 18,
color: context.colors.onSurfaceVariant,
),
),
],
),
),
),
),
),
),
],
),
),
),
),
);
}
}
class _ActivityStatusBadge extends StatelessWidget {
final _ActivityStatus status;
const _ActivityStatusBadge({required this.status});
@override
Widget build(BuildContext context) {
final color = switch (status) {
_ActivityStatus.working => context.appColors.success,
_ActivityStatus.finished => context.colors.onSurfaceVariant,
_ActivityStatus.error => context.colors.error,
_ActivityStatus.waiting => context.appColors.warning,
};
final label = switch (status) {
_ActivityStatus.working => 'Working',
_ActivityStatus.finished => 'Finished',
_ActivityStatus.error => 'Error',
_ActivityStatus.waiting => 'Waiting',
};
return Container(
key: const ValueKey('composer-agent-activity-status'),
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,
vertical: Grid.quarter,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: context.textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
);
}
}
class _ActivityEmptyState extends StatelessWidget {
final _ActivityStatus status;
final ObserverState? observerState;
const _ActivityEmptyState({
required this.status,
required this.observerState,
});
@override
Widget build(BuildContext context) {
if (status == _ActivityStatus.error) {
return Center(
child: Text(
'The agent stopped with an error.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
);
}
if (observerState?.connection == ObserverConnectionState.error) {
return Center(
child: Text(
'Live activity is unavailable.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
);
}
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (status == _ActivityStatus.working ||
status == _ActivityStatus.waiting)
BuzzLoadingIndicator(
size: 18,
color: context.colors.onSurfaceVariant,
semanticLabel: 'Waiting for agent activity',
),
const SizedBox(height: Grid.half),
Text(
status == _ActivityStatus.finished
? 'No activity rows were captured for this turn.'
: 'Waiting for live activity…',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -0,0 +1,458 @@
part of '../composer_agent_activity_indicator.dart';
class _AgentSegmentedControl extends StatelessWidget {
static const _inset = Grid.xxs;
static const _cornerRadius = Radii.dialog - _inset;
final List<String> agents;
final String selectedAgent;
final String Function(String) nameFor;
final ValueChanged<String> onSelectAgent;
const _AgentSegmentedControl({
required this.agents,
required this.selectedAgent,
required this.nameFor,
required this.onSelectAgent,
});
@override
Widget build(BuildContext context) {
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
return SizedBox(
height: Grid.xl,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: _inset,
vertical: Grid.half + Grid.quarter,
),
child: LayoutBuilder(
builder: (context, constraints) {
final control = isIos
? ClipRSuperellipse(
key: const ValueKey('composer-agent-segmented-frame'),
borderRadius: BorderRadius.circular(_cornerRadius),
clipBehavior: Clip.antiAlias,
child: DecoratedBox(
decoration: ShapeDecoration(
color: context.colors.surface,
shape: RoundedSuperellipseBorder(
borderRadius: BorderRadius.circular(_cornerRadius),
side: BorderSide(
color: context.colors.outlineVariant,
),
),
),
child: CupertinoSlidingSegmentedControl<String>(
key: const ValueKey(
'composer-agent-segmented-container',
),
groupValue: selectedAgent,
thumbColor: context.colors.primary,
backgroundColor: Colors.transparent,
proportionalWidth: false,
children: {
for (final agent in agents)
agent: _IosAgentSegment(
pubkey: agent,
label: nameFor(agent),
selected: agent == selectedAgent,
),
},
onValueChanged: (agent) {
if (agent == null || agent == selectedAgent) return;
onSelectAgent(agent);
},
),
),
)
: Material(
key: const ValueKey('composer-agent-segmented-container'),
color: context.colors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
side: BorderSide(color: context.colors.outlineVariant),
),
clipBehavior: Clip.antiAlias,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var index = 0; index < agents.length; index++) ...[
if (index > 0)
ColoredBox(
color: context.colors.outlineVariant,
child: const SizedBox(width: 1),
),
_AgentSegment(
pubkey: agents[index],
label: nameFor(agents[index]),
selected: agents[index] == selectedAgent,
onTap: () => onSelectAgent(agents[index]),
),
],
],
),
);
return SingleChildScrollView(
key: const ValueKey('composer-agent-activity-selector'),
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: control,
),
);
},
),
),
);
}
}
class _IosAgentSegment extends StatelessWidget {
final String pubkey;
final String label;
final bool selected;
const _IosAgentSegment({
required this.pubkey,
required this.label,
required this.selected,
});
@override
Widget build(BuildContext context) {
return ConstrainedBox(
key: ValueKey('composer-agent-segment-$pubkey'),
constraints: const BoxConstraints(minWidth: 96, minHeight: Grid.md),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
child: Center(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelMedium?.copyWith(
color: selected
? context.colors.onPrimary
: context.colors.onSurfaceVariant,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
),
),
);
}
}
class _AgentSegment extends StatelessWidget {
final String pubkey;
final String label;
final bool selected;
final VoidCallback onTap;
const _AgentSegment({
required this.pubkey,
required this.label,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
label: label,
child: ExcludeSemantics(
child: Material(
key: ValueKey('composer-agent-segment-$pubkey'),
color: selected ? context.colors.primary : Colors.transparent,
child: InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 96,
minHeight: Grid.md,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
child: Center(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelMedium?.copyWith(
color: selected
? context.colors.onPrimary
: context.colors.onSurfaceVariant,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
),
),
),
),
),
),
);
}
}
class _AgentActivityControl extends StatelessWidget {
final List<WorkingAgentSignal> signals;
final String? selectedAgent;
final AgentTurnState? selectedTurn;
final List<TranscriptItem> transcript;
final Map<String, UserProfile> profiles;
final String Function(String) nameFor;
final VoidCallback onTap;
final double horizontalInset;
const _AgentActivityControl({
required this.signals,
required this.selectedAgent,
required this.selectedTurn,
required this.transcript,
required this.profiles,
required this.nameFor,
required this.onTap,
required this.horizontalInset,
});
@override
Widget build(BuildContext context) {
final pubkeys = signals.isNotEmpty
? [for (final signal in signals) signal.pubkey]
: [?selectedAgent];
final label = _agentActivityLabel(
pubkeys: pubkeys,
selectedTurn: selectedTurn,
transcript: transcript,
nameFor: nameFor,
expanded: false,
);
return Padding(
padding: const EdgeInsets.only(
left: 0,
right: 0,
bottom: Grid.xxs,
).add(EdgeInsets.symmetric(horizontal: horizontalInset)),
child: Semantics(
button: true,
toggled: false,
label: label.semanticLabel,
child: ExcludeSemantics(
child: Material(
color: context.colors.surfaceContainerHighest,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.dialog),
side: BorderSide(color: Colors.black.withValues(alpha: 0.04)),
),
child: InkWell(
key: const ValueKey('composer-agent-activity-control'),
onTap: onTap,
borderRadius: BorderRadius.circular(Radii.dialog),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 44),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,
vertical: Grid.half,
),
child: Row(
children: [
_AgentAvatarStack(pubkeys: pubkeys, profiles: profiles),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
label.visibleLabel,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
Icon(
LucideIcons.chevronUp,
size: 16,
color: context.colors.onSurfaceVariant,
),
],
),
),
),
),
),
),
),
);
}
}
class _AgentStatusRow extends StatelessWidget {
final List<WorkingAgentSignal> signals;
final Map<String, UserProfile> profiles;
final String Function(String) nameFor;
final double horizontalInset;
const _AgentStatusRow({
required this.signals,
required this.profiles,
required this.nameFor,
required this.horizontalInset,
});
@override
Widget build(BuildContext context) {
final pubkeys = [for (final signal in signals) signal.pubkey];
final text = switch (signals.length) {
1 => '${nameFor(signals.single.pubkey)} is working…',
_ => '${signals.length} agents are working…',
};
return Padding(
padding: const EdgeInsets.only(
left: 0,
right: 0,
bottom: Grid.xxs,
).add(EdgeInsets.symmetric(horizontal: horizontalInset)),
child: Container(
key: const ValueKey('composer-agent-status-only'),
width: double.infinity,
constraints: const BoxConstraints(minHeight: 44),
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
border: Border.all(color: Colors.black.withValues(alpha: 0.04)),
),
child: Row(
children: [
_AgentAvatarStack(pubkeys: pubkeys, profiles: profiles),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
text,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}
class _AgentAvatarStack extends StatelessWidget {
final List<String> pubkeys;
final Map<String, UserProfile> profiles;
const _AgentAvatarStack({required this.pubkeys, required this.profiles});
@override
Widget build(BuildContext context) {
final visible = pubkeys.take(3).toList();
return SizedBox(
width: 24.0 + math.max(0, visible.length - 1) * 14.0,
height: 24,
child: Stack(
children: [
for (var index = 0; index < visible.length; index++)
Positioned(
left: index * 14.0,
child: SmallAvatar(
pubkey: visible[index],
userCache: profiles,
size: 24,
),
),
],
),
);
}
}
enum _ActivityStatus { working, finished, error, waiting }
_ActivityStatus _activityStatus(AgentTurnState? turn, bool isFallbackWorking) {
return switch (turn?.phase) {
AgentTurnPhase.working => _ActivityStatus.working,
AgentTurnPhase.finished => _ActivityStatus.finished,
AgentTurnPhase.error => _ActivityStatus.error,
null =>
isFallbackWorking ? _ActivityStatus.working : _ActivityStatus.waiting,
};
}
({String visibleLabel, String semanticLabel}) _agentActivityLabel({
required List<String> pubkeys,
required AgentTurnState? selectedTurn,
required List<TranscriptItem> transcript,
required String Function(String) nameFor,
required bool expanded,
}) {
final action = expanded ? 'Collapse live activity.' : 'Show live activity.';
if (pubkeys.length > 1) {
return (
visibleLabel: '${pubkeys.length} agents are working…',
semanticLabel: '${pubkeys.length} agents are working. $action',
);
}
final name = pubkeys.isEmpty ? 'Agent' : nameFor(pubkeys.single);
final status = _selectedActivityHeadline(selectedTurn, transcript);
return (
visibleLabel: '$name $status',
semanticLabel: '$name $status. $action',
);
}
String _selectedActivityHeadline(
AgentTurnState? selectedTurn,
List<TranscriptItem> transcript,
) => switch (selectedTurn?.phase) {
AgentTurnPhase.finished => 'finished',
AgentTurnPhase.error => 'stopped with an error',
_ =>
transcript.isNotEmpty ? _compactHeadline(transcript.last) : 'is working…',
};
String _compactHeadline(TranscriptItem item) => switch (item) {
final ToolItem tool => _toolActivityLabel(tool),
final ThoughtItem thought => thought.title.toLowerCase(),
final MessageItem message =>
message.role == 'assistant' ? 'is responding…' : 'is reading the prompt…',
final LifecycleItem lifecycle => lifecycle.title.toLowerCase(),
MetadataItem() => 'is working…',
};
String _toolActivityLabel(ToolItem tool) {
final title = tool.title.trim().isEmpty ? tool.toolName : tool.title.trim();
return switch (tool.status) {
ToolStatus.completed => 'Finished $title',
ToolStatus.failed => '$title failed',
ToolStatus.pending => 'Queued $title',
ToolStatus.executing => 'Running $title',
};
}
String _oneLine(String value) => value.replaceAll(RegExp(r'\s+'), ' ').trim();
String _transcriptVersion(List<TranscriptItem> transcript) {
if (transcript.isEmpty) return 'empty';
final last = transcript.last;
final contentLength = switch (last) {
final MessageItem item => item.text.length,
final ThoughtItem item => item.text.length,
final LifecycleItem item => item.text.length,
final MetadataItem item => item.sections.length,
final ToolItem item => item.result.length + item.args.length,
};
return '${transcript.length}:${last.id}:$contentLength';
}

View File

@@ -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),
),
),
],
),
);
}
}

View File

@@ -16,6 +16,8 @@ class ObserverFrame {
final String? channelId;
final String? sessionId;
final String? turnId;
final String? startedAt;
final DateTime? receivedAt;
final dynamic payload;
const ObserverFrame({
@@ -26,10 +28,15 @@ class ObserverFrame {
this.channelId,
this.sessionId,
this.turnId,
this.startedAt,
this.receivedAt,
this.payload,
});
factory ObserverFrame.fromJson(Map<String, dynamic> json) => ObserverFrame(
factory ObserverFrame.fromJson(
Map<String, dynamic> json, {
DateTime? receivedAt,
}) => ObserverFrame(
seq: json['seq'] as int? ?? 0,
timestamp: json['timestamp'] as String? ?? '',
kind: json['kind'] as String? ?? '',
@@ -37,6 +44,8 @@ class ObserverFrame {
channelId: json['channelId'] as String?,
sessionId: json['sessionId'] as String?,
turnId: json['turnId'] as String?,
startedAt: json['startedAt'] as String?,
receivedAt: receivedAt,
payload: json['payload'],
);
}

View File

@@ -16,6 +16,13 @@ const _observerBatchKind = 'batch';
/// Key for channel-scoped transcript reads.
typedef ObserverKey = ({String channelId, String agentPubkey});
/// Key for a transcript constrained to one agent turn.
typedef ObserverTurnKey = ({
String channelId,
String agentPubkey,
String turnId,
});
/// State emitted by the channel-scoped observer transcript provider.
@immutable
class ObserverState {
@@ -246,7 +253,8 @@ class ObserverRelayNotifier extends Notifier<ObserverRelayState> {
);
final plaintext = nip44Decrypt(conversationKey, event.content);
final json = jsonDecode(plaintext) as Map<String, dynamic>;
final frame = ObserverFrame.fromJson(json);
final receivedAt = DateTime.now().toUtc();
final frame = ObserverFrame.fromJson(json, receivedAt: receivedAt);
if (frame.kind != _observerBatchKind) {
return [frame];
}
@@ -261,7 +269,10 @@ class ObserverRelayNotifier extends Notifier<ObserverRelayState> {
return [
for (final inner in events)
ObserverFrame.fromJson(inner as Map<String, dynamic>),
ObserverFrame.fromJson(
inner as Map<String, dynamic>,
receivedAt: receivedAt,
),
];
} catch (error) {
_errorMessage = 'Observer event decrypt failed: $error';
@@ -371,3 +382,26 @@ final observerSubscriptionProvider =
errorMessage: relayState.errorMessage,
);
});
/// Live transcript for one exact turn.
///
/// The narrower key keeps compact composer activity from merging concurrent or
/// unrelated turns that happen to share an agent and channel.
final observerTurnSubscriptionProvider =
Provider.family<ObserverState, ObserverTurnKey>((ref, key) {
final relayState = ref.watch(observerRelayProvider);
final normalizedAgent = key.agentPubkey.toLowerCase();
final frames = relayState.framesByAgent[normalizedAgent] ?? const [];
final turnFrames = [
for (final frame in frames)
if ((frame.channelId == key.channelId || frame.channelId == null) &&
frame.turnId == key.turnId)
frame,
];
return ObserverState(
connection: relayState.connection,
transcript: buildTranscript(turnFrames),
errorMessage: relayState.errorMessage,
);
});

View File

@@ -586,6 +586,20 @@ List<TranscriptItem> buildTranscript(List<ObserverFrame> events) {
continue;
}
if (event.kind == 'turn_error' || event.kind == 'agent_panic') {
final payload = _asRecord(event.payload);
final error = _asString(payload['error']) ?? 'Unknown error';
final outcome = _asString(payload['outcome']) ?? 'error';
upsertTextItem(
'${event.kind}:${event.turnId ?? event.seq}',
'lifecycle',
event.kind == 'agent_panic' ? 'Agent error' : 'Turn error',
'$outcome: $error',
event.timestamp,
);
continue;
}
if (event.kind != 'acp_read' && event.kind != 'acp_write') {
continue;
}

View File

@@ -1,27 +1,152 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../shared/mentions/agent_identity_provider.dart';
import '../../profile/user_cache_provider.dart';
import '../channel_management_provider.dart';
import '../channel_typing_provider.dart';
import 'active_agent_turns.dart';
import 'observer_subscription.dart';
/// Derived provider that computes which bot members in a channel are currently
/// typing (i.e. "working"). Returns a set of lowercase pubkeys.
///
/// Used by both the members button badge and the members sheet to avoid
/// duplicating the bot-typing cross-reference logic.
/// Composer activity is scoped either to a channel or to one thread.
typedef ComposerActivityKey = ({String channelId, String? threadHeadId});
enum AgentWorkingSource { observer, typing }
/// One agent currently surfaced beside the composer.
@immutable
class WorkingAgentSignal {
final String pubkey;
final AgentWorkingSource source;
final bool canViewActivity;
final String? turnId;
final DateTime? startedAt;
const WorkingAgentSignal({
required this.pubkey,
required this.source,
required this.canViewActivity,
this.turnId,
this.startedAt,
});
}
/// Agent work and human typing are intentionally separate presentation lanes.
@immutable
class ComposerActivityState {
final List<WorkingAgentSignal> agents;
final List<TypingEntry> humanTyping;
const ComposerActivityState({
required this.agents,
required this.humanTyping,
});
}
/// Unified composer state. Observer activity is authoritative in channels;
/// kind:20002 typing fills gaps and is the only thread-scoped signal because
/// observer frames do not carry a thread id.
final composerActivityStateProvider = Provider.autoDispose
.family<ComposerActivityState, ComposerActivityKey>((ref, key) {
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
final typing = [
for (final entry in ref.watch(channelTypingProvider(key.channelId)))
if (entry.threadHeadId == key.threadHeadId &&
entry.pubkey.toLowerCase() != currentPubkey)
entry,
];
final profiles = ref.watch(userCacheProvider);
final channelMembers =
ref.watch(channelMembersProvider(key.channelId)).asData?.value ??
const <ChannelMember>[];
final channelAgents = <String>{
...ref.watch(agentMentionPubkeysProvider(key.channelId)),
for (final member in channelMembers)
if (member.isBot) member.pubkey.toLowerCase(),
for (final entry in profiles.entries)
if (entry.value.ownerPubkey != null) entry.key.toLowerCase(),
};
final observerState = ref.watch(observerRelayProvider);
final ownerByAgent =
ref.watch(agentOwnersProvider).asData?.value ??
const <String, String>{};
final activeTurns = ref.watch(activeAgentTurnsProvider);
final activeByAgent = <String, AgentTurnState>{};
for (final turn in activeTurns) {
if (turn.channelId != key.channelId) continue;
final existing = activeByAgent[turn.agentPubkey];
if (existing == null ||
turn.lastActivityAt.isAfter(existing.lastActivityAt)) {
activeByAgent[turn.agentPubkey] = turn;
}
}
bool canView(String pubkey) {
if (currentPubkey == null) return false;
final normalized = pubkey.toLowerCase();
return ownerByAgent[normalized]?.toLowerCase() == currentPubkey ||
profiles[normalized]?.ownerPubkey?.toLowerCase() == currentPubkey;
}
final signals = <String, WorkingAgentSignal>{};
// A channel can trust observer activity directly. A thread cannot: the
// observer protocol has no thread id, so a thread requires typing first.
if (key.threadHeadId == null) {
for (final entry in activeByAgent.entries) {
if (!channelAgents.contains(entry.key)) continue;
final turn = entry.value;
signals[entry.key] = WorkingAgentSignal(
pubkey: entry.key,
source: AgentWorkingSource.observer,
canViewActivity: canView(entry.key),
turnId: turn.turnId,
startedAt: turn.startedAt,
);
}
}
final humans = <TypingEntry>[];
for (final entry in typing) {
final pubkey = entry.pubkey.toLowerCase();
if (!channelAgents.contains(pubkey) &&
!observerState.framesByAgent.containsKey(pubkey)) {
humans.add(entry);
continue;
}
if (signals.containsKey(pubkey)) continue;
final turn = activeByAgent[pubkey];
signals[pubkey] = WorkingAgentSignal(
pubkey: pubkey,
source: AgentWorkingSource.typing,
canViewActivity: canView(pubkey),
turnId: turn?.turnId,
startedAt: turn?.startedAt,
);
}
final agents = signals.values.toList()
..sort((a, b) {
final aAt = a.startedAt;
final bAt = b.startedAt;
if (aAt != null && bAt != null) return aAt.compareTo(bAt);
if (aAt != null) return -1;
if (bAt != null) return 1;
return a.pubkey.compareTo(b.pubkey);
});
return ComposerActivityState(
agents: List.unmodifiable(agents),
humanTyping: List.unmodifiable(humans),
);
});
/// Agent pubkeys working in a channel, for the members badge and sheet.
final workingBotPubkeysProvider = Provider.autoDispose
.family<Set<String>, String>((ref, channelId) {
final typingEntries = ref.watch(channelTypingProvider(channelId));
final membersAsync = ref.watch(channelMembersProvider(channelId));
final allMembers = membersAsync.asData?.value ?? const <ChannelMember>[];
final botPubkeys = <String>{
for (final m in allMembers)
if (m.isBot) m.pubkey.toLowerCase(),
};
return <String>{
for (final e in typingEntries)
if (botPubkeys.contains(e.pubkey.toLowerCase()))
e.pubkey.toLowerCase(),
};
final activity = ref.watch(
composerActivityStateProvider((
channelId: channelId,
threadHeadId: null,
)),
);
return Set.unmodifiable(activity.agents.map((agent) => agent.pubkey));
});

View File

@@ -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,

View File

@@ -57,6 +57,7 @@ part 'compose_bar/camera_preview.dart';
part 'compose_bar/send_button.dart';
part 'compose_bar/layout.dart';
part 'compose_bar/dock.dart';
part 'compose_bar/activity_handoff.dart';
part 'compose_bar/compose_bar_widget.dart';
/// Callback used by channels and threads to submit composer content.
@@ -66,3 +67,12 @@ typedef ComposeBarOnSend =
List<String> mentionPubkeys, {
List<List<String>> mediaTags,
});
typedef ComposeBarActivityIndicatorBuilder =
Widget Function(
Animation<double> composerWidthAnimation,
FocusNode composerFocusNode,
ValueNotifier<bool> composerInteractionLock,
ValueNotifier<int> composerActivationRequests,
VoidCallback restoreComposerFocus,
);

View File

@@ -0,0 +1,36 @@
part of '../compose_bar.dart';
void _useComposerActivityFocusHandoff({
required FocusNode focusNode,
required ValueNotifier<bool> activityInteractionLock,
required ValueNotifier<int> composerActivationRequests,
required ValueNotifier<bool> isEmojiPickerOpen,
required VoidCallback collapseComposer,
}) {
useEffect(() {
void coordinateFocus() {
if (focusNode.hasFocus) {
composerActivationRequests.value += 1;
if (activityInteractionLock.value) {
focusNode.unfocus(
disposition: UnfocusDisposition.previouslyFocusedChild,
);
}
} else if (!focusNode.hasFocus && !isEmojiPickerOpen.value) {
collapseComposer();
}
}
focusNode.addListener(coordinateFocus);
WidgetsBinding.instance.addPostFrameCallback((_) => coordinateFocus());
return () => focusNode.removeListener(coordinateFocus);
}, [focusNode, activityInteractionLock, composerActivationRequests]);
}
bool _requestComposerHandoff(
ValueNotifier<bool> activityInteractionLock,
ValueNotifier<int> composerActivationRequests,
) {
composerActivationRequests.value += 1;
return activityInteractionLock.value;
}

View File

@@ -5,9 +5,9 @@ class ComposeBar extends HookConsumerWidget {
final String channelName;
final String? hintText;
final ComposeBarOnSend onSend;
final ComposeBarActivityIndicatorBuilder? activityIndicatorBuilder;
/// Runs immediately before the editor requests focus, allowing a parent to
/// prepare focus-dependent layout (for example, following a thread tail).
/// Runs before editor focus so a parent can prepare focus-dependent layout.
final VoidCallback? onFocusRequested;
/// Optional thread IDs for thread-scoped typing indicators.
@@ -21,6 +21,7 @@ class ComposeBar extends HookConsumerWidget {
this.threadHeadId,
this.rootId,
this.onFocusRequested,
this.activityIndicatorBuilder,
required this.onSend,
});
@override
@@ -31,15 +32,8 @@ class ComposeBar extends HookConsumerWidget {
() => controller.text,
);
useEffect(() => controller.dispose, [controller]);
// Restore and persist unsent text as a local draft so the Activity
// inbox Drafts filter reflects real composer state.
//
// The effect is additionally keyed on the active relay + pubkey identity:
// provider-level namespacing alone cannot protect a composer that stays
// mounted through an in-place community/account switch — the controller
// would retain the old identity's text and the next edit would persist it
// into the new identity's store. On identity change we replace the
// controller content with the new identity's own saved draft (or clear).
// Key drafts by relay + pubkey so in-place identity switches cannot
// persist the previous identity's mounted controller text.
final draftKey = composeDraftKey(channelId, threadHeadId: threadHeadId);
final draftRevision = useRef(0);
final draftIdentity =
@@ -51,6 +45,8 @@ class ComposeBar extends HookConsumerWidget {
);
final androidImeFallbackTimer = useRef<Timer?>(null);
final focusNode = useFocusNode();
final activityInteractionLock = useState(false);
final composerActivationRequests = useState(0);
useEffect(
() =>
() => androidImeFallbackTimer.value?.cancel(),
@@ -112,16 +108,13 @@ class ComposeBar extends HookConsumerWidget {
isComposerExpanded.value = false;
}
useEffect(() {
void collapseWhenUnfocused() {
if (!focusNode.hasFocus && !isEmojiPickerOpen.value) {
collapseComposer();
}
}
focusNode.addListener(collapseWhenUnfocused);
return () => focusNode.removeListener(collapseWhenUnfocused);
}, [focusNode]);
_useComposerActivityFocusHandoff(
focusNode: focusNode,
activityInteractionLock: activityInteractionLock,
composerActivationRequests: composerActivationRequests,
isEmojiPickerOpen: isEmojiPickerOpen,
collapseComposer: collapseComposer,
);
final appView = View.of(context);
useEffect(() {
@@ -205,7 +198,6 @@ class ComposeBar extends HookConsumerWidget {
};
}, [focusNode]);
// Mention state --------------------------------------------------------
final mentionQuery = useState<String?>(null);
final mentionStartIdx = useState(-1);
// Map of displayName → selected mention candidate built as the user selects
@@ -213,7 +205,6 @@ class ComposeBar extends HookConsumerWidget {
// selected non-member agents before the message is published.
final mentionMap = useRef(<String, MentionCandidate>{});
// Channel autocomplete state ----------------------------------------------
final channelQuery = useState<String?>(null);
final channelStartIdx = useState(-1);
final channelsAsync = ref.watch(channelsProvider);
@@ -852,16 +843,24 @@ class ComposeBar extends HookConsumerWidget {
return null;
}, [suggestionOverlayController]);
void expandComposer() => _expandComposer(
context: context,
isExpanded: isComposerExpanded,
attachmentSurface: attachmentSurface,
onFocusRequested: onFocusRequested,
focusNode: focusNode,
view: appView,
androidImeTransitionStarted: androidImeTransitionStarted,
androidImeFallbackTimer: androidImeFallbackTimer,
);
void expandComposer() {
if (_requestComposerHandoff(
activityInteractionLock,
composerActivationRequests,
)) {
return;
}
_expandComposer(
context: context,
isExpanded: isComposerExpanded,
attachmentSurface: attachmentSurface,
onFocusRequested: onFocusRequested,
focusNode: focusNode,
view: appView,
androidImeTransitionStarted: androidImeTransitionStarted,
androidImeFallbackTimer: androidImeFallbackTimer,
);
}
final suggestionPanel = _composerSuggestionPanel(
channelSuggestions: channelSuggestions,
@@ -912,13 +911,20 @@ class ComposeBar extends HookConsumerWidget {
);
}
// Suggestions and attachments live in the overlay.
final hasPendingUploads = uploadingCount.value > 0;
final activityIndicator = activityIndicatorBuilder?.call(
composerExpansionController,
focusNode,
activityInteractionLock,
composerActivationRequests,
expandComposer,
);
return _ComposerDockFrame(
expansionAnimation: composerExpansionController,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
?activityIndicator,
_UploadProgressMotion(
visible: hasPendingUploads,
progress: uploadProgress.value,
@@ -934,10 +940,11 @@ class ComposeBar extends HookConsumerWidget {
controller: suggestionOverlayController,
attachmentSurface: attachmentSurface,
reducedMotion: reducedMotion,
interactionLocked: activityInteractionLock.value,
onInteractionLockedTap: () => composerActivationRequests.value += 1,
buildOverlayPanel: buildOverlayPanel,
onDismissAttachmentSurface: () {
attachmentSurface.value = _AttachmentSurface.closed;
},
onDismissAttachmentSurface: () =>
attachmentSurface.value = _AttachmentSurface.closed,
child: _ComposeBarLayout(
attachments: attachments.value,
onRemoveAttachment: removeAttachment,

View File

@@ -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,
),
),
),
],
),
);
}
}

View File

@@ -90,24 +90,48 @@ class _ThreadTailIntent {
}
}
/// Thread-scoped typing status with optional size animation.
/// Thread-scoped composer activity with optional size animation.
class _ThreadTypingIndicator extends StatelessWidget {
final List<TypingEntry> entries;
final String channelId;
final String threadHeadId;
final bool animated;
final double horizontalInset;
final double? overlayTopBoundary;
final double compactWidthFactor;
final Animation<double>? composerWidthAnimation;
final FocusNode? composerFocusNode;
final ValueNotifier<bool>? composerInteractionLock;
final ValueNotifier<int>? composerActivationRequests;
final VoidCallback? onRestoreComposerFocus;
const _ThreadTypingIndicator({required this.entries, this.animated = true});
const _ThreadTypingIndicator({
required this.channelId,
required this.threadHeadId,
this.animated = true,
this.horizontalInset = Grid.twelve,
this.overlayTopBoundary,
this.compactWidthFactor = 1,
this.composerWidthAnimation,
this.composerFocusNode,
this.composerInteractionLock,
this.composerActivationRequests,
this.onRestoreComposerFocus,
});
@override
Widget build(BuildContext context) {
final child = entries.isEmpty
? const SizedBox.shrink()
: ChannelTypingIndicator(entries: entries);
if (!animated || MediaQuery.disableAnimationsOf(context)) return child;
return AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: child,
return ComposerAgentActivityIndicator(
channelId: channelId,
threadHeadId: threadHeadId,
animated: animated,
horizontalInset: horizontalInset,
overlayTopBoundary: overlayTopBoundary,
compactWidthFactor: compactWidthFactor,
composerWidthAnimation: composerWidthAnimation,
composerFocusNode: composerFocusNode,
composerInteractionLock: composerInteractionLock,
composerActivationRequests: composerActivationRequests,
onRestoreComposerFocus: onRestoreComposerFocus,
);
}
}

View File

@@ -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,

View File

@@ -0,0 +1,126 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/features/channels/agent_activity/active_agent_turns.dart';
import 'package:buzz/features/channels/agent_activity/observer_models.dart';
void main() {
test('tracks and refreshes a live turn using device receipt time', () {
final turns = reduceAgentTurnStates({
'AGENT-A': [
_frame(
seq: 1,
second: 1,
kind: 'turn_started',
receivedSecond: 10,
payload: {
'triggeringEventIds': ['message-1'],
},
),
_frame(seq: 2, second: 11, kind: 'turn_liveness', receivedSecond: 20),
],
}, now: DateTime.utc(2026, 8, 16, 12, 0, 25));
expect(turns, hasLength(1));
expect(turns.single.agentPubkey, 'agent-a');
expect(turns.single.phase, AgentTurnPhase.working);
expect(turns.single.triggeringEventId, 'message-1');
expect(turns.single.lastActivityAt, DateTime.utc(2026, 8, 16, 12, 0, 20));
});
test('preserves explicit completion and error outcomes', () {
final turns = reduceAgentTurnStates({
'agent-a': [
_frame(seq: 1, second: 1, kind: 'turn_started'),
_frame(seq: 2, second: 2, kind: 'turn_completed'),
],
'agent-b': [
_frame(seq: 1, second: 1, kind: 'turn_started', turnId: 'turn-b'),
_frame(
seq: 2,
second: 2,
kind: 'turn_error',
turnId: 'turn-b',
payload: {'error': 'Tool permission denied'},
),
],
}, now: DateTime.utc(2026, 8, 16, 12, 1));
expect(turns, hasLength(2));
expect(turns[0].phase, AgentTurnPhase.finished);
expect(turns[1].phase, AgentTurnPhase.error);
expect(turns[1].errorMessage, 'Tool permission denied');
});
test('expires silence without claiming the turn finished', () {
final turns = reduceAgentTurnStates({
'agent-a': [_frame(seq: 1, second: 1, kind: 'turn_started')],
}, now: DateTime.utc(2026, 8, 16, 12, 0, 32));
expect(turns, isEmpty);
});
test('recovers a missed start and rejects stale post-terminal liveness', () {
final turns = reduceAgentTurnStates({
'agent-a': [
_frame(
seq: 1,
second: 1,
kind: 'turn_liveness',
startedAt: DateTime.utc(2026, 8, 16, 11, 59).toIso8601String(),
),
_frame(seq: 2, second: 2, kind: 'turn_completed'),
_frame(
seq: 0,
second: 1,
kind: 'turn_liveness',
startedAt: DateTime.utc(2026, 8, 16, 11, 59).toIso8601String(),
),
],
}, now: DateTime.utc(2026, 8, 16, 12, 0, 20));
expect(turns, hasLength(1));
expect(turns.single.phase, AgentTurnPhase.finished);
expect(turns.single.startedAt, DateTime.utc(2026, 8, 16, 11, 59));
});
test('terminal without a turn id updates the latest turn in its channel', () {
final turns = reduceAgentTurnStates({
'agent-a': [
_frame(seq: 1, second: 1, kind: 'turn_started'),
_frame(
seq: 2,
second: 2,
kind: 'agent_panic',
turnId: null,
payload: {'error': 'Process exited'},
),
],
}, now: DateTime.utc(2026, 8, 16, 12, 0, 20));
expect(turns.single.phase, AgentTurnPhase.error);
expect(turns.single.errorMessage, 'Process exited');
});
}
ObserverFrame _frame({
required int seq,
required int second,
required String kind,
String? turnId = 'turn-1',
String channelId = 'channel-1',
int? receivedSecond,
String? startedAt,
dynamic payload = const <String, dynamic>{},
}) {
return ObserverFrame(
seq: seq,
timestamp: DateTime.utc(2026, 8, 16, 12, 0, second).toIso8601String(),
kind: kind,
channelId: channelId,
turnId: turnId,
startedAt: startedAt,
receivedAt: receivedSecond == null
? null
: DateTime.utc(2026, 8, 16, 12, 0, receivedSecond),
payload: payload,
);
}

View File

@@ -10,6 +10,43 @@ import 'package:buzz/shared/crypto/nip44.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
test('turn-scoped provider does not merge concurrent agent turns', () {
final container = ProviderContainer(
overrides: [
observerRelayProvider.overrideWith(
() => _StaticObserverRelay({
'agent-a': [
_turnMessageFrame(seq: 1, turnId: 'turn-1', text: 'First turn'),
_turnMessageFrame(seq: 2, turnId: 'turn-2', text: 'Second turn'),
ObserverFrame(
seq: 3,
timestamp: '2026-04-30T12:00:03.000Z',
kind: 'agent_panic',
turnId: 'turn-1',
payload: const {'error': 'Process exited'},
),
],
}),
),
],
);
addTearDown(container.dispose);
final state = container.read(
observerTurnSubscriptionProvider((
channelId: 'test-channel',
agentPubkey: 'agent-a',
turnId: 'turn-1',
)),
);
expect(state.transcript.whereType<MessageItem>().single.text, 'First turn');
expect(
state.transcript.whereType<LifecycleItem>().single.title,
'Agent error',
);
});
test('provider initializes without circular dependency error', () {
// Regression test: reading the provider should NOT throw
// "Bad state: Tried to read the state of an uninitialized provider".
@@ -254,6 +291,7 @@ void main() {
'kind': 'turn_started',
'channelId': channelId,
'turnId': 'turn-1',
'startedAt': '2026-04-30T11:59:00.000Z',
'payload': {
'triggeringEventIds': ['0123456789abcdef'],
},
@@ -279,6 +317,12 @@ void main() {
final item = state.transcript.single;
expect(item, isA<LifecycleItem>());
expect((item as LifecycleItem).title, 'Turn started');
final storedFrame = container
.read(observerRelayProvider)
.framesByAgent[agentKeychain.public]!
.single;
expect(storedFrame.startedAt, '2026-04-30T11:59:00.000Z');
expect(storedFrame.receivedAt, isNotNull);
final otherChannelState = container.read(
observerSubscriptionProvider((
@@ -518,6 +562,30 @@ void main() {
);
}
ObserverFrame _turnMessageFrame({
required int seq,
required String turnId,
required String text,
}) => ObserverFrame(
seq: seq,
timestamp: '2026-04-30T12:00:0$seq.000Z',
kind: 'acp_read',
channelId: 'test-channel',
turnId: turnId,
payload: {
'method': 'session/update',
'params': {
'update': {
'sessionUpdate': 'agent_message_chunk',
'messageId': 'message-$turnId',
'content': [
{'type': 'text', 'text': text},
],
},
},
},
);
Map<String, dynamic> _observerFrameJson({
required int seq,
required String channelId,
@@ -614,6 +682,18 @@ class _RecordingRelaySession extends RelaySessionNotifier {
}
}
class _StaticObserverRelay extends ObserverRelayNotifier {
final Map<String, List<ObserverFrame>> frames;
_StaticObserverRelay(this.frames);
@override
ObserverRelayState build() => ObserverRelayState(
connection: ObserverConnectionState.open,
framesByAgent: frames,
);
}
class _FakeRelayConfigNotifier extends RelayConfigNotifier {
String? _nsec;

View File

@@ -122,6 +122,29 @@ void main() {
expect(items[1], isA<MetadataItem>());
expect((items[1] as MetadataItem).sections, hasLength(2));
});
test('surfaces explicit turn errors without inventing completion rows', () {
final items = buildTranscript([
ObserverFrame(
seq: 1,
timestamp: _timestamp(1),
kind: 'turn_error',
turnId: 'turn-1',
payload: const {'outcome': 'failed', 'error': 'Permission denied'},
),
ObserverFrame(
seq: 2,
timestamp: _timestamp(2),
kind: 'turn_completed',
turnId: 'turn-2',
),
]);
expect(items, hasLength(1));
expect(items.single, isA<LifecycleItem>());
expect((items.single as LifecycleItem).title, 'Turn error');
expect((items.single as LifecycleItem).text, 'failed: Permission denied');
});
}
ObserverFrame _updateFrame({

View File

@@ -0,0 +1,163 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/channels/agent_activity/active_agent_turns.dart';
import 'package:buzz/features/channels/agent_activity/observer_models.dart';
import 'package:buzz/features/channels/agent_activity/observer_subscription.dart';
import 'package:buzz/features/channels/agent_activity/working_bots_provider.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/channel_typing_provider.dart';
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
const _channelId = 'channel-1';
void main() {
test('prefers observer work and keeps human typing separate', () {
final observerTurn = _turn('agent-a');
final container = ProviderContainer(
overrides: [
currentPubkeyProvider.overrideWith((ref) => 'owner'),
channelMembersProvider(
_channelId,
).overrideWith((ref) async => const <ChannelMember>[]),
channelTypingProvider(_channelId).overrideWith(
() => _FakeTypingNotifier(const [
TypingEntry(pubkey: 'agent-a', expiresAtMs: 9999999999999),
TypingEntry(pubkey: 'agent-b', expiresAtMs: 9999999999999),
TypingEntry(pubkey: 'human', expiresAtMs: 9999999999999),
]),
),
agentMentionPubkeysProvider(
_channelId,
).overrideWith((ref) => const {'agent-a', 'agent-b'}),
agentOwnersProvider.overrideWithValue(
const AsyncData({'agent-a': 'owner', 'agent-b': 'owner'}),
),
userCacheProvider.overrideWith(_FakeUserCacheNotifier.new),
observerRelayProvider.overrideWith(
() => _FakeObserverRelayNotifier({
'agent-a': [_observerFrame('agent-a')],
}),
),
activeAgentTurnsProvider.overrideWithValue([observerTurn]),
],
);
addTearDown(container.dispose);
final state = container.read(
composerActivityStateProvider((
channelId: _channelId,
threadHeadId: null,
)),
);
expect(state.agents, hasLength(2));
expect(state.agents[0].pubkey, 'agent-a');
expect(state.agents[0].source, AgentWorkingSource.observer);
expect(state.agents[0].canViewActivity, isTrue);
expect(state.agents[1].source, AgentWorkingSource.typing);
expect(state.agents[1].canViewActivity, isTrue);
expect(state.humanTyping.single.pubkey, 'human');
});
test(
'thread scope requires typing and does not surface observer-only work',
() {
final container = ProviderContainer(
overrides: [
currentPubkeyProvider.overrideWith((ref) => 'owner'),
channelMembersProvider(
_channelId,
).overrideWith((ref) async => const <ChannelMember>[]),
channelTypingProvider(_channelId).overrideWith(
() => _FakeTypingNotifier(const [
TypingEntry(
pubkey: 'agent-b',
threadHeadId: 'thread-1',
expiresAtMs: 9999999999999,
),
TypingEntry(
pubkey: 'human',
threadHeadId: 'thread-1',
expiresAtMs: 9999999999999,
),
]),
),
agentMentionPubkeysProvider(
_channelId,
).overrideWith((ref) => const {'agent-a', 'agent-b'}),
agentOwnersProvider.overrideWithValue(
const AsyncData({'agent-b': 'owner'}),
),
userCacheProvider.overrideWith(_FakeUserCacheNotifier.new),
observerRelayProvider.overrideWith(
() => _FakeObserverRelayNotifier({
'agent-a': [_observerFrame('agent-a')],
}),
),
activeAgentTurnsProvider.overrideWithValue([_turn('agent-a')]),
],
);
addTearDown(container.dispose);
final state = container.read(
composerActivityStateProvider((
channelId: _channelId,
threadHeadId: 'thread-1',
)),
);
expect(state.agents.single.pubkey, 'agent-b');
expect(state.agents.single.source, AgentWorkingSource.typing);
expect(state.humanTyping.single.pubkey, 'human');
},
);
}
AgentTurnState _turn(String pubkey) => AgentTurnState(
agentPubkey: pubkey,
channelId: _channelId,
turnId: 'turn-$pubkey',
startedAt: DateTime.utc(2026, 8, 16, 12),
lastActivityAt: DateTime.utc(2026, 8, 16, 12),
livenessTimeout: const Duration(seconds: 30),
phase: AgentTurnPhase.working,
);
ObserverFrame _observerFrame(String pubkey) => ObserverFrame(
seq: 1,
timestamp: DateTime.utc(2026, 8, 16, 12).toIso8601String(),
kind: 'turn_started',
channelId: _channelId,
turnId: 'turn-$pubkey',
);
class _FakeTypingNotifier extends ChannelTypingNotifier {
final List<TypingEntry> entries;
_FakeTypingNotifier(this.entries) : super(_channelId);
@override
List<TypingEntry> build() => entries;
}
class _FakeUserCacheNotifier extends UserCacheNotifier {
@override
Map<String, UserProfile> build() => const {};
@override
void preload(List<String> pubkeys) {}
}
class _FakeObserverRelayNotifier extends ObserverRelayNotifier {
final Map<String, List<ObserverFrame>> frames;
_FakeObserverRelayNotifier(this.frames);
@override
ObserverRelayState build() => ObserverRelayState(
connection: ObserverConnectionState.open,
framesByAgent: frames,
);
}

View File

@@ -7,11 +7,16 @@ import 'package:flutter/rendering.dart' show ScrollDirection;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/agent_activity/active_agent_turns.dart';
import 'package:buzz/features/channels/agent_activity/observer_models.dart';
import 'package:buzz/features/channels/agent_activity/observer_subscription.dart';
import 'package:buzz/features/channels/agent_activity/working_bots_provider.dart';
import 'package:buzz/features/channels/channel_detail_page.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/channel_messages_provider.dart';
@@ -193,6 +198,7 @@ Widget _buildTestable({
bool disableAnimations = false,
RelaySessionNotifier? relaySessionNotifier,
http.Client? mediaClient,
List<Override> extraOverrides = const [],
}) {
final resolvedChannel = channel ?? _testChannel;
final fakeChannelsNotifier =
@@ -254,6 +260,7 @@ Widget _buildTestable({
relaySessionProvider.overrideWith(() => relaySessionNotifier),
// Compose bar drafts persist through SharedPreferences.
savedPrefsProvider.overrideWithValue(_testPrefs),
...extraOverrides,
],
child: MaterialApp(
theme: AppTheme.light(),
@@ -2305,6 +2312,240 @@ void main() {
},
);
testWidgets(
'agent activity follows composer width and overlays without reflowing the tail',
(tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
const agentPubkey = 'agent-a';
const turnId = 'turn-a';
final messages = [
for (var i = 0; i < 20; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i,
),
];
final turn = AgentTurnState(
agentPubkey: agentPubkey,
channelId: _channelId,
turnId: turnId,
startedAt: DateTime.utc(2026, 8, 16, 12),
lastActivityAt: DateTime.utc(2026, 8, 16, 12, 0, 5),
livenessTimeout: const Duration(seconds: 30),
phase: AgentTurnPhase.working,
);
await tester.pumpWidget(
_buildTestable(
messages: messages,
disableAnimations: false,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
agentPubkey: UserProfile(
pubkey: agentPubkey,
displayName: 'Pollen',
ownerPubkey: 'self',
),
},
extraOverrides: [
composerActivityStateProvider((
channelId: _channelId,
threadHeadId: null,
)).overrideWithValue(
const ComposerActivityState(
agents: [
WorkingAgentSignal(
pubkey: agentPubkey,
source: AgentWorkingSource.observer,
canViewActivity: true,
turnId: turnId,
),
],
humanTyping: [],
),
),
agentTurnStatesProvider.overrideWithValue([turn]),
observerTurnSubscriptionProvider((
channelId: _channelId,
agentPubkey: agentPubkey,
turnId: turnId,
)).overrideWithValue(
ObserverState(
connection: ObserverConnectionState.open,
transcript: [
ToolItem(
id: 'tool-1',
title: 'Read channel',
toolName: 'get_messages',
status: ToolStatus.executing,
args: const {},
result: '',
isError: false,
timestamp: '2026-08-16T12:00:05Z',
),
],
),
),
],
),
);
await tester.pumpAndSettle();
final latestMessage = find.byKey(
const ValueKey('channel-message-group-msg19'),
);
final composerDock = find.byKey(
const ValueKey('channel-composer-dock'),
);
final activitySurface = find.byKey(
const ValueKey('composer-agent-activity-surface'),
);
final composerWidth = find.byKey(
const ValueKey('composer-width-transition'),
);
final compactActivityWidth = tester.getSize(activitySurface).width;
final compactComposerWidth = tester.getSize(composerWidth).width;
expect(compactActivityWidth, closeTo(compactComposerWidth, 0.1));
expect(
tester.getCenter(activitySurface).dx,
closeTo(tester.getCenter(composerWidth).dx, 0.1),
);
await tester.tap(find.text('Message #general'));
await tester.pumpAndSettle();
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.focusNode?.hasFocus, isTrue);
final expandedActivityWidth = tester.getSize(activitySurface).width;
final expandedComposerWidth = tester.getSize(composerWidth).width;
expect(expandedActivityWidth, greaterThan(compactActivityWidth));
expect(expandedActivityWidth, closeTo(expandedComposerWidth, 0.1));
textField.focusNode?.unfocus();
await tester.pumpAndSettle();
final dockHeightBeforeActivity = tester.getSize(composerDock).height;
final latestMessageBottomBeforeActivity = tester
.getBottomLeft(latestMessage)
.dy;
await tester.tap(
find.byKey(const ValueKey('composer-agent-activity-control')),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('composer-agent-activity-panel')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('composer-agent-segmented-container')),
findsNothing,
);
expect(find.text('Pollen'), findsNothing);
expect(
tester
.getCenter(
find.byKey(const ValueKey('composer-agent-activity-panel')),
)
.dx,
closeTo(tester.getCenter(composerWidth).dx, 0.1),
);
expect(
tester
.getSize(
find.byKey(const ValueKey('composer-agent-activity-panel')),
)
.width,
closeTo(expandedComposerWidth, 0.1),
);
expect(find.byType(BottomSheet), findsNothing);
expect(textField.focusNode?.hasFocus, isFalse);
expect(textField.focusNode?.canRequestFocus, isTrue);
await tester.tap(find.text('Message #general'), warnIfMissed: false);
await tester.pump();
expect(textField.focusNode?.hasFocus, isFalse);
expect(find.byType(TextField), findsNothing);
expect(
tester.getSize(composerDock).height,
closeTo(dockHeightBeforeActivity, 0.1),
);
expect(
tester.getBottomLeft(latestMessage).dy,
closeTo(latestMessageBottomBeforeActivity, 0.1),
);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsNothing,
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('composer-agent-activity-panel')),
findsNothing,
);
final activatedComposer = tester.widget<TextField>(
find.byType(TextField),
);
expect(activatedComposer.focusNode?.canRequestFocus, isTrue);
expect(activatedComposer.focusNode?.hasFocus, isTrue);
activatedComposer.focusNode?.unfocus();
await tester.pumpAndSettle();
expect(
tester
.getSize(
find.byKey(const ValueKey('composer-agent-activity-surface')),
)
.width,
closeTo(compactActivityWidth, 0.1),
);
await tester.tap(find.text('Message #general'));
await tester.pumpAndSettle();
final reopenedTextField = tester.widget<TextField>(
find.byType(TextField),
);
expect(reopenedTextField.focusNode?.hasFocus, isTrue);
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('composer-agent-activity-control')),
);
await tester.pump();
expect(reopenedTextField.focusNode?.hasFocus, isFalse);
expect(
find.byKey(const ValueKey('composer-agent-activity-panel')),
findsNothing,
);
tester.view.viewInsets = FakeViewPadding.zero;
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('composer-agent-activity-panel')),
findsOneWidget,
);
expect(
tester
.getSize(
find.byKey(const ValueKey('composer-agent-activity-panel')),
)
.width,
closeTo(expandedComposerWidth, 0.1),
);
await tester.tap(
find.byKey(const ValueKey('composer-agent-activity-collapse')),
);
await tester.pumpAndSettle();
final restoredTextField = tester.widget<TextField>(
find.byType(TextField),
);
expect(restoredTextField.focusNode?.hasFocus, isTrue);
},
);
testWidgets(
'seeds an already-visible Android keyboard into the channel tail layout',
(tester) async {