From e87745de5aa1281dd215ee9805ac271f64c62ae3 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 21 Oct 2026 20:08:27 -0700 Subject: [PATCH] fix(mobile): hide structured assistant citations in conversation display Signed-off-by: Tom Brow --- .../transcript_item_widget.dart | 6 +- .../channel_detail_page/message_bubble.dart | 1 + .../features/channels/message_content.dart | 9 ++ .../thread_detail_page/thread_message.dart | 1 + .../shared/markdown/assistant_citations.dart | 66 +++++++++++++++ .../assistant_citation_rendering_test.dart | 83 +++++++++++++++++++ .../bubble_tests.dart | 58 +++++++++++++ .../markdown/assistant_citations_test.dart | 65 +++++++++++++++ 8 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 mobile/lib/shared/markdown/assistant_citations.dart create mode 100644 mobile/test/features/channels/assistant_citation_rendering_test.dart create mode 100644 mobile/test/shared/markdown/assistant_citations_test.dart diff --git a/mobile/lib/features/channels/agent_activity/transcript_item_widget.dart b/mobile/lib/features/channels/agent_activity/transcript_item_widget.dart index 68339c9180..195a6912d1 100644 --- a/mobile/lib/features/channels/agent_activity/transcript_item_widget.dart +++ b/mobile/lib/features/channels/agent_activity/transcript_item_widget.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import '../../../shared/markdown/assistant_citations.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -34,6 +35,7 @@ class _MessageItemWidget extends StatelessWidget { @override Widget build(BuildContext context) { final isAssistant = item.role == 'assistant'; + final text = isAssistant ? assistantDisplayContent(item.text) : item.text; final badgeColor = isAssistant ? context.colors.primary : context.colors.onSurfaceVariant; @@ -66,9 +68,9 @@ class _MessageItemWidget extends StatelessWidget { ], ), const SizedBox(height: Grid.half), - if (item.text.isNotEmpty) + if (text.isNotEmpty) GptMarkdown( - item.text, + text, style: context.textTheme.bodyMedium?.copyWith( color: context.colors.onSurface, ), diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 3f8ec5442a..c0ce9a3ba2 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -346,6 +346,7 @@ class _MessageBubble extends HookConsumerWidget { animate: typingHandoff, child: MessageContent( key: swipeContentKey, + isAssistant: isAgent, content: message.content, bubble: true, hasReactions: diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 1c7d4df483..49e44883b9 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../../shared/markdown/assistant_citations.dart'; import '../../shared/widgets/ios_media_carousel.dart'; import '../../shared/widgets/message_attachment_corners.dart'; import 'dart:io'; @@ -95,6 +96,9 @@ String _safeDownloadedFilename(String filename) { class MessageContent extends HookConsumerWidget { final String content; + /// Whether the sender is a known agent; enables display-only metadata hiding. + final bool isAssistant; + /// Paint conversation text bubbles while keeping attachments on the page. final bool bubble; @@ -172,6 +176,7 @@ class MessageContent extends HookConsumerWidget { const MessageContent({ super.key, required this.content, + this.isAssistant = false, this.bubble = false, this.ownMessage = false, this.trimTrailingSpacing = false, @@ -197,6 +202,10 @@ class MessageContent extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final content = useMemoized( + () => isAssistant ? assistantDisplayContent(this.content) : this.content, + [this.content, isAssistant], + ); final markdownTheme = GptMarkdownTheme.of(context); final chatColors = ChatColors.of(context); final ownTextColor = chatColors.foreground; diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart index 89bf0ef415..27be228564 100644 --- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -312,6 +312,7 @@ class _ThreadMessage extends HookConsumerWidget { ), MessageContent( key: swipeContentKey, + isAssistant: isAgent, content: message.content, bubble: true, hasReactions: diff --git a/mobile/lib/shared/markdown/assistant_citations.dart b/mobile/lib/shared/markdown/assistant_citations.dart new file mode 100644 index 0000000000..676fa2053d --- /dev/null +++ b/mobile/lib/shared/markdown/assistant_citations.dart @@ -0,0 +1,66 @@ +// Recognize the provider's envelope, not arbitrary XML or angle-bracket text. +final _citationBlock = RegExp( + r' {0,3}\s*' + r'[^<>]*\s*' + r'(?:[^<>]*\s*)?' + r'[ \t]*(?:\r?\n|$)', +); +final _fence = RegExp(r'^ {0,3}(`{3,}|~{3,})(.*)$'); +final _ticks = RegExp(r'`+'); + +/// Hides complete provider memory citations in assistant prose for display only. +/// +/// The caller must establish assistant authorship. Signed content is untouched. +/// Standalone envelopes are recognized regardless of payload or position, while +/// literal code, quotes, indented examples, and incomplete/unknown markup stay. +String assistantDisplayContent(String content) { + if (!content.contains('')) return content; + final output = StringBuffer(); + var offset = 0; + String? fenceCharacter; + var fenceLength = 0; + var inlineLength = 0; + while (offset < content.length) { + final newline = content.indexOf('\n', offset); + final end = newline < 0 ? content.length : newline + 1; + final line = content.substring(offset, end); + final fence = _fence.firstMatch(line.trimRight()); + if (fenceCharacter != null) { + if (fence != null && + fence[1]![0] == fenceCharacter && + fence[1]!.length >= fenceLength && + fence[2]!.trim().isEmpty) { + fenceCharacter = null; + } + } else if (inlineLength == 0 && fence != null) { + fenceCharacter = fence[1]![0]; + fenceLength = fence[1]!.length; + } else { + if (inlineLength == 0) { + final citation = _citationBlock.matchAsPrefix(content, offset); + if (citation != null) { + offset = citation.end; + continue; + } + } + // Keep multiline inline-code examples literal too. Conservatively retain + // the rest after an unmatched backtick rather than hide possible code. + for (final tick in _ticks.allMatches(line)) { + var slashes = 0; + for (var i = tick.start - 1; i >= 0 && line[i] == r'\'; i--) { + slashes++; + } + if (inlineLength == 0 && slashes.isOdd) continue; + final length = tick.end - tick.start; + if (inlineLength == 0) { + inlineLength = length; + } else if (inlineLength == length) { + inlineLength = 0; + } + } + } + output.write(line); + offset = end; + } + return output.toString(); +} diff --git a/mobile/test/features/channels/assistant_citation_rendering_test.dart b/mobile/test/features/channels/assistant_citation_rendering_test.dart new file mode 100644 index 0000000000..58563aa506 --- /dev/null +++ b/mobile/test/features/channels/assistant_citation_rendering_test.dart @@ -0,0 +1,83 @@ +import 'package:buzz/features/channels/message_content.dart'; +import 'package:buzz/features/channels/agent_activity/transcript_item_widget.dart'; +import 'package:buzz/features/channels/agent_activity/observer_models.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/markdown/assistant_citations_test.dart' show citation; + +Widget host(Widget child) => ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: AppMarkdownTheme( + child: Scaffold(body: SingleChildScrollView(child: child)), + ), + ), +); + +void main() { + testWidgets('activity filters assistant text only and leaves source intact', ( + tester, + ) async { + for (final role in ['assistant', 'user']) { + final item = MessageItem( + id: 'message', + role: role, + title: 'Message', + text: 'Answer\n$citation', + timestamp: '2026-10-21T22:32:42Z', + ); + await tester.pumpWidget(host(TranscriptItemWidget(item: item))); + final data = tester.widget(find.byType(GptMarkdown)).data; + expect(data.contains('oai-mem-citation'), role == 'user'); + expect(item.text, 'Answer\n$citation'); + } + }); + + testWidgets( + 'agent reply hides metadata and retains surrounding text and attachment', + (tester) async { + const image = 'https://example.test/status.png'; + const source = + 'Online, Away, and Offline now use filled dots.\n\n$citation\n![Status]($image)'; + await tester.pumpWidget( + host(const MessageContent(content: source, isAssistant: true)), + ); + final text = tester + .widgetList(find.byType(RichText)) + .map((w) => w.text.toPlainText()) + .join('\n'); + expect(text, contains('Online, Away, and Offline now use filled dots.')); + expect(text, isNot(contains('oai-mem-citation'))); + expect(text, isNot(contains('MEMORY.md'))); + expect( + find.byKey(const ValueKey('message-media-image-preview:$image')), + findsOneWidget, + ); + }, + ); + testWidgets('human and code example content reaches markdown unchanged', ( + tester, + ) async { + for (final agent in [false, true]) { + final source = agent ? '```xml\n$citation\n```' : citation; + await tester.pumpWidget( + host(MessageContent(content: source, isAssistant: agent)), + ); + expect(tester.widget(find.byType(GptMarkdown)).data, source); + } + }); + testWidgets('new content and author classification recompute display', ( + tester, + ) async { + for (final agent in [false, true, false]) { + await tester.pumpWidget( + host(MessageContent(content: 'Answer\n$citation', isAssistant: agent)), + ); + final data = tester.widget(find.byType(GptMarkdown)).data; + expect(data.contains('oai-mem-citation'), !agent); + } + }); +} diff --git a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart index de4bc69616..5deb786490 100644 --- a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart @@ -215,6 +215,64 @@ void bubbleLayoutTests() { expect(tester.takeException(), isNull); }); + for (final surface in ['channel', 'solo', 'thread']) { + for (final agent in [false, true]) { + testWidgets( + 'citation metadata on $surface uses agent=$agent classification', + (tester) async { + const metadata = + '\nMEMORY.md:1-2|note=[test]\n'; + final events = [ + _textMsg( + id: 'citation-message', + pubkey: 'alice', + content: 'Visible answer\n\n$metadata', + createdAt: 1000, + ), + ]; + final messages = formatTimeline(events); + await tester.pumpWidget( + _buildTestable( + messages: events, + personalChat: surface == 'solo', + knownAgentPubkeys: agent ? {'alice'} : {}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + home: surface == 'thread' + ? ThreadDetailPage( + threadHead: messages.single, + allMessages: messages, + channelId: _channelId, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ) + : null, + ), + ); + await tester.pumpAndSettle(); + final content = tester.widget( + find.byType(MessageContent).first, + ); + expect(content.isAssistant, agent); + expect(content.content, contains(metadata)); + final text = tester + .widgetList( + find.descendant( + of: find.byType(MessageContent).first, + matching: find.byType(RichText), + ), + ) + .map((w) => w.text.toPlainText()) + .join('\n'); + expect(text, contains('Visible answer')); + expect(text.contains('oai-mem-citation'), !agent); + }, + ); + } + } + testWidgets('large activity members build and fetch only visible rows', ( tester, ) async { diff --git a/mobile/test/shared/markdown/assistant_citations_test.dart b/mobile/test/shared/markdown/assistant_citations_test.dart new file mode 100644 index 0000000000..0ebd7ed2ee --- /dev/null +++ b/mobile/test/shared/markdown/assistant_citations_test.dart @@ -0,0 +1,65 @@ +import 'package:buzz/shared/markdown/assistant_citations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const citation = ''' + +MEMORY.md:34-34|note=[Pinned mobile toolchain guidance verified locally] + + + +'''; + +void main() { + test('removes metadata while preserving all surrounding bytes', () { + const image = '![STATUS\\_MENU.png]()'; + const source = 'Reply.\n\n$citation\n$image'; + expect(assistantDisplayContent(source), 'Reply.\n\n$image'); + expect(assistantDisplayContent('$citation\nAnswer\n$citation'), 'Answer\n'); + }); + test('accepts varied payloads, whitespace and optional rollout ids', () { + for (final block in [ + citation.replaceAll('\n', '\r\n'), + citation + .replaceAll('MEMORY.md:34-34', 'memory/other.md:1-99') + .replaceAll('\n', '\nfirst\nsecond\n'), + ' other ', + ' $citation', + ]) { + expect(assistantDisplayContent('$block\nAnswer'), 'Answer'); + } + }); + final literals = { + 'inline': '`$citation`', + 'double backticks': '``$citation``', + 'backtick fence': '```xml\n$citation\n```', + 'tilde fence': '~~~xml\n$citation\n~~~', + 'long fence': '````\n```\n$citation\n````', + 'unclosed fence': '```xml\n$citation', + 'unclosed inline': '`example\n$citation', + 'indented code': citation.split('\n').map((l) => ' $l').join('\n'), + 'quoted example': citation.split('\n').map((l) => '> $l').join('\n'), + 'escaped': '\\$citation', + 'literal mention': 'The tag is .', + 'unknown envelope': 'Useful prose', + 'incomplete': citation.replaceAll('', ''), + 'unknown inner tag': citation.replaceAll('citation_entries', 'example'), + 'ordinary HTML': '
Useful prose
', + 'same line prose': 'Explain $citation please', + }; + for (final entry in literals.entries) { + test('preserves ${entry.key}', () { + expect(assistantDisplayContent(entry.value), entry.value); + }); + } + test('resumes filtering after code closes', () { + final example = '~~~xml\n$citation\n~~~\n'; + expect( + assistantDisplayContent('$example$citation\nAnswer'), + '${example}Answer', + ); + }); + test('does not repeatedly scan nested malformed openers', () { + final malformed = '\n' * 10000; + expect(assistantDisplayContent(malformed), malformed); + }); +}