Fix retained mobile activity labels

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-17 15:13:37 +01:00
parent 3ff56f93a9
commit 64ea2d430a
5 changed files with 196 additions and 4 deletions
@@ -495,6 +495,7 @@ class ComposerAgentActivityIndicator extends HookConsumerWidget {
observerState: observerState,
transcript: transcript,
profiles: profiles,
signals: viewableWorking,
selectorAgents: selectorAgents,
selectedAgent: effectiveSelectedAgent,
nameFor: nameFor,
@@ -619,6 +620,7 @@ class _InlineActivityPanel extends StatelessWidget {
final ObserverState? observerState;
final List<TranscriptItem> transcript;
final Map<String, UserProfile> profiles;
final List<WorkingAgentSignal> signals;
final List<String> selectorAgents;
final String selectedAgent;
final String Function(String) nameFor;
@@ -639,6 +641,7 @@ class _InlineActivityPanel extends StatelessWidget {
required this.observerState,
required this.transcript,
required this.profiles,
required this.signals,
required this.selectorAgents,
required this.selectedAgent,
required this.nameFor,
@@ -655,6 +658,7 @@ class _InlineActivityPanel extends StatelessWidget {
final headline = _selectedActivityHeadline(selectedTurn, transcript);
final compactLabel = _agentActivityLabel(
pubkeys: selectorAgents,
signals: signals,
selectedTurn: selectedTurn,
transcript: transcript,
nameFor: nameFor,
@@ -229,6 +229,7 @@ class _AgentActivityControl extends StatelessWidget {
: [?selectedAgent];
final label = _agentActivityLabel(
pubkeys: pubkeys,
signals: signals,
selectedTurn: selectedTurn,
transcript: transcript,
nameFor: nameFor,
@@ -393,16 +394,47 @@ _ActivityStatus _activityStatus(AgentTurnState? turn, bool isFallbackWorking) {
({String visibleLabel, String semanticLabel}) _agentActivityLabel({
required List<String> pubkeys,
required List<WorkingAgentSignal> signals,
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 && signals.length == pubkeys.length) {
final working = signals.where((signal) => signal.isWorking).length;
final errors = signals
.where((signal) => signal.phase == AgentTurnPhase.error)
.length;
final finished = signals.length - working - errors;
if (working == signals.length) {
return (
visibleLabel: '${signals.length} agents are working…',
semanticLabel: '${signals.length} agents are working. $action',
);
}
final visibleParts = [
if (working > 0) '$working working',
if (finished > 0) '$finished finished',
if (errors > 0) '$errors ${errors == 1 ? 'error' : 'errors'}',
];
final semanticParts = [
if (working > 0)
'$working ${working == 1 ? 'agent is' : 'agents are'} working',
if (finished > 0)
'$finished ${finished == 1 ? 'agent has' : 'agents have'} finished',
if (errors > 0)
'$errors ${errors == 1 ? 'agent stopped' : 'agents stopped'} with ${errors == 1 ? 'an error' : 'errors'}',
];
return (
visibleLabel: visibleParts.join(' · '),
semanticLabel: '${semanticParts.join(', ')}. $action',
);
}
if (pubkeys.length > 1) {
return (
visibleLabel: '${pubkeys.length} agents are working…',
semanticLabel: '${pubkeys.length} agents are working. $action',
visibleLabel: '${pubkeys.length} agents have activity',
semanticLabel: '${pubkeys.length} agents have activity. $action',
);
}
final name = pubkeys.isEmpty ? 'Agent' : nameFor(pubkeys.single);
@@ -22,6 +22,9 @@ class WorkingAgentSignal {
/// Whether this signal represents live work rather than a retained outcome.
final bool isWorking;
/// Known observer phase; typing-only signals have no lifecycle phase yet.
final AgentTurnPhase? phase;
final String? turnId;
final DateTime? startedAt;
@@ -30,6 +33,7 @@ class WorkingAgentSignal {
required this.source,
required this.canViewActivity,
this.isWorking = true,
this.phase,
this.turnId,
this.startedAt,
});
@@ -105,6 +109,7 @@ final composerActivityStateProvider = Provider.autoDispose
source: AgentWorkingSource.observer,
canViewActivity: canView(entry.key),
isWorking: turn.isWorking,
phase: turn.phase,
turnId: turn.turnId,
startedAt: turn.startedAt,
);
@@ -121,13 +126,15 @@ final composerActivityStateProvider = Provider.autoDispose
}
if (signals.containsKey(pubkey)) continue;
final turn = activeByAgent[pubkey];
final liveTurn = turn?.isWorking == true ? turn : null;
signals[pubkey] = WorkingAgentSignal(
pubkey: pubkey,
source: AgentWorkingSource.typing,
canViewActivity: canView(pubkey),
isWorking: true,
turnId: turn?.turnId,
startedAt: turn?.startedAt,
phase: liveTurn?.phase,
turnId: liveTurn?.turnId,
startedAt: liveTurn?.startedAt,
);
}
@@ -206,6 +206,105 @@ void main() {
expect(find.text('Thinking'), findsOneWidget);
});
testWidgets('summarizes mixed working and terminal agent states', (
tester,
) async {
final container = ProviderContainer(
overrides: [
composerActivityStateProvider(_scope).overrideWithValue(
const ComposerActivityState(
agents: [
WorkingAgentSignal(
pubkey: _agentPubkey,
source: AgentWorkingSource.observer,
canViewActivity: true,
phase: AgentTurnPhase.working,
turnId: _turnId,
),
WorkingAgentSignal(
pubkey: _secondAgentPubkey,
source: AgentWorkingSource.observer,
canViewActivity: true,
isWorking: false,
phase: AgentTurnPhase.error,
turnId: _secondTurnId,
),
],
humanTyping: [],
),
),
agentTurnStatesProvider.overrideWithValue([
_turn(AgentTurnPhase.working),
_turnFor(
pubkey: _secondAgentPubkey,
turnId: _secondTurnId,
phase: AgentTurnPhase.error,
),
]),
observerTurnSubscriptionProvider(
_turnKey,
).overrideWithValue(_observerState),
userCacheProvider.overrideWith(_FakeUserCacheNotifier.new),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(_app(container));
await tester.pump();
expect(find.text('1 working · 1 error'), findsOneWidget);
expect(find.text('2 agents are working…'), findsNothing);
});
testWidgets('summarizes multiple retained terminal outcomes', (tester) async {
final container = ProviderContainer(
overrides: [
composerActivityStateProvider(_scope).overrideWithValue(
const ComposerActivityState(
agents: [
WorkingAgentSignal(
pubkey: _agentPubkey,
source: AgentWorkingSource.observer,
canViewActivity: true,
isWorking: false,
phase: AgentTurnPhase.finished,
turnId: _turnId,
),
WorkingAgentSignal(
pubkey: _secondAgentPubkey,
source: AgentWorkingSource.observer,
canViewActivity: true,
isWorking: false,
phase: AgentTurnPhase.error,
turnId: _secondTurnId,
),
],
humanTyping: [],
),
),
agentTurnStatesProvider.overrideWithValue([
_turn(AgentTurnPhase.finished),
_turnFor(
pubkey: _secondAgentPubkey,
turnId: _secondTurnId,
phase: AgentTurnPhase.error,
),
]),
observerTurnSubscriptionProvider(
_turnKey,
).overrideWithValue(_observerState),
userCacheProvider.overrideWith(_FakeUserCacheNotifier.new),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(_app(container));
await tester.pump();
expect(find.text('1 finished · 1 error'), findsOneWidget);
expect(find.text('2 agents are working…'), findsNothing);
});
testWidgets('reduced motion makes inline size changes immediate', (
tester,
) async {
@@ -114,6 +114,56 @@ void main() {
},
);
test('thread typing does not inherit a retained terminal turn', () {
final failedTurn = _turn('agent-a', phase: AgentTurnPhase.error);
final container = ProviderContainer(
overrides: [
currentPubkeyProvider.overrideWith((ref) => 'owner'),
channelMembersProvider(
_channelId,
).overrideWith((ref) async => const <ChannelMember>[]),
channelTypingProvider(_channelId).overrideWith(
() => _FakeTypingNotifier(const [
TypingEntry(
pubkey: 'agent-a',
threadHeadId: 'thread-1',
expiresAtMs: 9999999999999,
),
]),
),
agentMentionPubkeysProvider(
_channelId,
).overrideWith((ref) => const {'agent-a'}),
agentOwnersProvider.overrideWithValue(
const AsyncData({'agent-a': 'owner'}),
),
userCacheProvider.overrideWith(_FakeUserCacheNotifier.new),
observerRelayProvider.overrideWith(
() => _FakeObserverRelayNotifier({
'agent-a': [_observerFrame('agent-a')],
}),
),
composerAgentTurnStatesProvider.overrideWithValue([failedTurn]),
],
);
addTearDown(container.dispose);
final signal = container
.read(
composerActivityStateProvider((
channelId: _channelId,
threadHeadId: 'thread-1',
)),
)
.agents
.single;
expect(signal.source, AgentWorkingSource.typing);
expect(signal.isWorking, isTrue);
expect(signal.turnId, isNull);
expect(signal.startedAt, isNull);
});
test('keeps a recent owned error reachable after typing stops', () {
final failedTurn = _turn('agent-a', phase: AgentTurnPhase.error);
final container = ProviderContainer(