diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index 1cf084bb8..e5f00488e 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -5018,6 +5018,37 @@ Map _buildOpenWebUiBackgroundTasks({ }; } +bool _shouldGenerateQueuedTitle( + List messages, { + required String assistantMessageId, + required bool isTemporary, +}) { + if (isTemporary) return false; + final assistantIndex = messages.indexWhere( + (message) => message.id == assistantMessageId, + ); + if (assistantIndex < 0) return false; + return messages + .take(assistantIndex) + .where((message) => message.role == 'user') + .length == + 1; +} + +/// Exposes [_shouldGenerateQueuedTitle] for focused regression tests. +@visibleForTesting +bool shouldGenerateQueuedTitleForTest( + List messages, { + required String assistantMessageId, + required bool isTemporary, +}) { + return _shouldGenerateQueuedTitle( + messages, + assistantMessageId: assistantMessageId, + isTemporary: isTemporary, + ); +} + /// Exposes [_buildOpenWebUiBackgroundTasks] for focused unit tests. @visibleForTesting Map buildOpenWebUiBackgroundTasksForTest({ @@ -8133,7 +8164,11 @@ Future runQueuedCompletion( final bgTasks = _buildOpenWebUiBackgroundTasks( userSettings: userSettingsData, - shouldGenerateTitle: false, + shouldGenerateTitle: _shouldGenerateQueuedTitle( + messages, + assistantMessageId: assistantMessageId, + isTemporary: isTemporary, + ), webSearchEnabled: enableWebSearch, imageGenerationEnabled: enableImageGeneration, ); @@ -8377,7 +8412,11 @@ Future runHeadlessCompletion( final bgTasks = _buildOpenWebUiBackgroundTasks( userSettings: userSettingsData, - shouldGenerateTitle: false, + shouldGenerateTitle: _shouldGenerateQueuedTitle( + messages, + assistantMessageId: assistantMessageId, + isTemporary: false, + ), webSearchEnabled: enableWebSearch, imageGenerationEnabled: enableImageGeneration, ); diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 7d9823b1e..995f68aea 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -2541,8 +2541,9 @@ class _ChatPageState extends ConsumerState { } void _copyMessage(String content) { - // Strip reasoning blocks and annotations from copied content - final cleanedContent = ConduitMarkdownPreprocessor.sanitize(content); + final cleanedContent = ConduitMarkdownPreprocessor.sanitizeForClipboard( + content, + ); Clipboard.setData(ClipboardData(text: cleanedContent)); } diff --git a/lib/features/notes/views/note_editor_page.dart b/lib/features/notes/views/note_editor_page.dart index ead49ecc2..9e3056cae 100644 --- a/lib/features/notes/views/note_editor_page.dart +++ b/lib/features/notes/views/note_editor_page.dart @@ -48,6 +48,104 @@ import '../widgets/audio_player_dialog.dart'; import '../widgets/audio_recording_overlay.dart'; import '../widgets/note_file_attachment.dart'; +/// Builds the rich-note editor theme from Conduit's semantic color tokens. +/// +/// Fleather's fallback block styles derive their colors from a separate +/// [DefaultTextStyle]. Every block type must therefore be mapped explicitly so +/// headings and lists remain readable in both brightness modes. +FleatherThemeData buildNoteEditorFleatherTheme(BuildContext context) { + final theme = context.conduitTheme; + final fallback = FleatherThemeData.fallback(context); + final base = AppTypography.bodyLargeStyle.copyWith( + color: theme.textPrimary, + height: 1.8, + ); + + TextStyle blockStyle(TextStyle source, {Color? color}) { + return base + .merge(source) + .copyWith( + color: color ?? theme.textPrimary, + fontFamily: AppTypography.fontFamily, + ); + } + + TextBlockTheme themedBlock( + TextBlockTheme source, { + TextStyle? style, + BoxDecoration? decoration, + }) { + return TextBlockTheme( + style: style ?? blockStyle(source.style), + spacing: source.spacing, + lineSpacing: source.lineSpacing, + decoration: decoration ?? source.decoration, + ); + } + + TextStyle inlineCodeStyle(TextStyle? source) { + return (source ?? fallback.inlineCode.style).copyWith( + color: theme.codeText, + fontFamily: AppTypography.monospaceFontFamily, + ); + } + + return fallback.copyWith( + paragraph: TextBlockTheme( + style: base, + spacing: const VerticalSpacing(top: 0, bottom: 6), + ), + heading1: themedBlock(fallback.heading1), + heading2: themedBlock(fallback.heading2), + heading3: themedBlock(fallback.heading3), + heading4: themedBlock(fallback.heading4), + heading5: themedBlock(fallback.heading5), + heading6: themedBlock(fallback.heading6), + lists: themedBlock(fallback.lists, style: base), + quote: themedBlock( + fallback.quote, + style: blockStyle(fallback.quote.style, color: theme.textSecondary), + decoration: BoxDecoration( + border: BorderDirectional( + start: BorderSide(width: 4, color: theme.dividerColor), + ), + ), + ), + code: themedBlock( + fallback.code, + style: fallback.code.style.copyWith( + color: theme.codeText, + fontFamily: AppTypography.monospaceFontFamily, + ), + decoration: BoxDecoration( + color: theme.codeBackground, + border: Border.all(color: theme.codeBorder), + borderRadius: + fallback.code.decoration?.borderRadius ?? BorderRadius.circular(2), + ), + ), + inlineCode: InlineCodeThemeData( + style: inlineCodeStyle(fallback.inlineCode.style), + heading1: inlineCodeStyle(fallback.inlineCode.heading1), + heading2: inlineCodeStyle(fallback.inlineCode.heading2), + heading3: inlineCodeStyle(fallback.inlineCode.heading3), + backgroundColor: theme.codeBackground, + radius: fallback.inlineCode.radius, + ), + horizontalRuleThemeData: HorizontalRuleThemeData( + height: fallback.horizontalRule.height, + thickness: fallback.horizontalRule.thickness, + color: theme.dividerColor, + ), + bold: const TextStyle(fontWeight: FontWeight.bold), + italic: const TextStyle(fontStyle: FontStyle.italic), + link: TextStyle( + color: theme.buttonPrimary, + decoration: TextDecoration.underline, + ), + ); +} + /// Page for editing a note with OpenWebUI-style layout. class NoteEditorPage extends ConsumerStatefulWidget { final String noteId; @@ -2264,24 +2362,7 @@ class _NoteEditorPageState extends ConsumerState { /// Builds a Fleather theme derived from the app's typography and colours so /// the rich-text editor matches the rest of the note UI. FleatherThemeData _buildFleatherTheme(BuildContext context) { - final theme = context.conduitTheme; - final base = AppTypography.bodyLargeStyle.copyWith( - color: theme.textPrimary, - height: 1.8, - ); - final fallback = FleatherThemeData.fallback(context); - return fallback.copyWith( - paragraph: TextBlockTheme( - style: base, - spacing: const VerticalSpacing(top: 0, bottom: 6), - ), - bold: const TextStyle(fontWeight: FontWeight.bold), - italic: const TextStyle(fontStyle: FontStyle.italic), - link: TextStyle( - color: theme.buttonPrimary, - decoration: TextDecoration.underline, - ), - ); + return buildNoteEditorFleatherTheme(context); } /// Play an audio file attachment. diff --git a/lib/shared/widgets/markdown/markdown_preprocessor.dart b/lib/shared/widgets/markdown/markdown_preprocessor.dart index 5013eab82..984c6af43 100644 --- a/lib/shared/widgets/markdown/markdown_preprocessor.dart +++ b/lib/shared/widgets/markdown/markdown_preprocessor.dart @@ -64,6 +64,7 @@ class ConduitMarkdownPreprocessor { multiLine: true, dotAll: true, ); + static final _codeSpanOrFence = RegExp(r'(`+)([\s\S]*?)\1'); static final _allDetailsBlocks = RegExp( r']*>[\s\S]*?', multiLine: true, @@ -179,11 +180,28 @@ class ConduitMarkdownPreprocessor { return input .replaceAll('\r\n', '\n') .transform(_stripLinkReferenceDefinitions) - .replaceAll(_reasoningBlocks, '') + .transform( + (content) => _removeMatchesOutsideCode(content, _reasoningBlocks), + ) .replaceAll(_multipleNewlines, '\n\n') .trim(); } + /// Sanitizes message content for copying while omitting internal tool calls. + /// + /// Tool-call markup inside code spans is preserved so documentation and + /// examples are copied faithfully. Ordinary `
` blocks are also left + /// untouched. + static String sanitizeForClipboard(String input) { + if (input.isEmpty) return input; + + final sanitized = sanitize(input); + return _removeMatchesOutsideCode( + sanitized, + _toolCallBlocks, + ).replaceAll(_multipleNewlines, '\n\n').trim(); + } + /// Converts markdown to plain text for text-to-speech. static String toPlainText(String input) { if (input.trim().isEmpty) return ''; @@ -228,10 +246,7 @@ class ConduitMarkdownPreprocessor { static String removeAllDetails(String input) { if (input.isEmpty) return input; - return _replaceOutsideCode( - input, - (segment) => segment.replaceAll(_allDetailsBlocks, ''), - ); + return _removeMatchesOutsideCode(input, _allDetailsBlocks); } /// Breaks long inline code spans for better wrapping. @@ -304,10 +319,7 @@ class ConduitMarkdownPreprocessor { static String _stripLinkReferenceDefinitions(String input) { if (!input.contains(']:')) return input; - final stripped = _replaceOutsideCode( - input, - (segment) => segment.replaceAll(_linkReferenceDefinition, ''), - ); + final stripped = _removeMatchesOutsideCode(input, _linkReferenceDefinition); return stripped.replaceAll(_multipleNewlines, '\n\n').trim(); } @@ -315,15 +327,26 @@ class ConduitMarkdownPreprocessor { return _openWebUiRemoveFormattings(_removeEmojis(input.trim())); } - static String _replaceOutsideCode( - String input, - String Function(String segment) replacer, - ) { - return input.splitMapJoin( - RegExp(r'```[\s\S]*?```|`[\s\S]*?`'), - onMatch: (match) => match[0] ?? '', - onNonMatch: replacer, - ); + static String _removeMatchesOutsideCode(String input, RegExp pattern) { + final codeSpans = []; + var marker = '\u0000conduit-code-span-'; + while (input.contains(marker)) { + marker = '\u0000$marker'; + } + + final masked = input.replaceAllMapped(_codeSpanOrFence, (match) { + final index = codeSpans.length; + codeSpans.add(match[0] ?? ''); + return '$marker$index\u0000'; + }); + var output = masked.replaceAll(pattern, ''); + + // Placeholders inside removed matches no longer exist, so only code from + // the retained content is restored. + for (var index = 0; index < codeSpans.length; index++) { + output = output.replaceAll('$marker$index\u0000', codeSpans[index]); + } + return output; } static String _removeEmojis(String input) { diff --git a/test/regressions/issues_511_576_577_test.dart b/test/regressions/issues_511_576_577_test.dart new file mode 100644 index 000000000..af3d404b7 --- /dev/null +++ b/test/regressions/issues_511_576_577_test.dart @@ -0,0 +1,179 @@ +import 'package:checks/checks.dart'; +import 'package:conduit/core/models/chat_message.dart'; +import 'package:conduit/features/chat/providers/chat_providers.dart'; +import 'package:conduit/features/notes/views/note_editor_page.dart'; +import 'package:conduit/shared/theme/app_theme.dart'; +import 'package:conduit/shared/theme/theme_extensions.dart'; +import 'package:conduit/shared/theme/tweakcn_themes.dart'; +import 'package:conduit/shared/widgets/markdown/markdown_preprocessor.dart'; +import 'package:fleather/fleather.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('issue 577 - clipboard content', () { + test('removes tool-call details but preserves the response markdown', () { + const input = '''Before + +
+Tool call +
private tool payload
+
+ +**After**'''; + + check( + ConduitMarkdownPreprocessor.sanitizeForClipboard(input), + ).equals('Before\n\n**After**'); + }); + + test('preserves ordinary details and literal tool-call examples', () { + const input = '''
Keep meBody
+ +`
standard inline
` + +``` +
standard fence
+``` + +``
`example`
`` + +```` +
example
+````'''; + + check( + ConduitMarkdownPreprocessor.sanitizeForClipboard(input), + ).equals(input); + }); + + test('removes tool-call details that contain code spans', () { + const input = '''Before + +
+Tool call +``private `inline` payload`` + +```` +private fenced payload +```` +
+ +After'''; + + check( + ConduitMarkdownPreprocessor.sanitizeForClipboard(input), + ).equals('Before\n\nAfter'); + }); + }); + + group('issue 576 - queued title generation', () { + ChatMessage message(String id, String role) => ChatMessage( + id: id, + role: role, + content: role, + timestamp: DateTime(2026), + ); + + test('enables first-turn title and tag tasks', () { + final messages = [message('user-1', 'user'), message('a-1', 'assistant')]; + final shouldGenerate = shouldGenerateQueuedTitleForTest( + messages, + assistantMessageId: 'a-1', + isTemporary: false, + ); + + check(shouldGenerate).isTrue(); + final backgroundTasks = buildOpenWebUiBackgroundTasksForTest( + userSettings: null, + shouldGenerateTitle: shouldGenerate, + ); + check(backgroundTasks['title_generation']).equals(true); + check(backgroundTasks['tags_generation']).equals(true); + }); + + test('does not regenerate titles for later turns or temporary chats', () { + final laterTurn = [ + message('user-1', 'user'), + message('a-1', 'assistant'), + message('user-2', 'user'), + message('a-2', 'assistant'), + ]; + + check( + shouldGenerateQueuedTitleForTest( + laterTurn, + assistantMessageId: 'a-2', + isTemporary: false, + ), + ).isFalse(); + check( + shouldGenerateQueuedTitleForTest( + [message('user-1', 'user'), message('a-1', 'assistant')], + assistantMessageId: 'a-1', + isTemporary: true, + ), + ).isFalse(); + }); + + test('anchors first-turn eligibility to the queued assistant', () { + final queuedTurns = [ + message('user-1', 'user'), + message('a-1', 'assistant'), + message('user-2', 'user'), + message('a-2', 'assistant'), + ]; + + check( + shouldGenerateQueuedTitleForTest( + queuedTurns, + assistantMessageId: 'a-1', + isTemporary: false, + ), + ).isTrue(); + }); + }); + + group('issue 511 - rich note colors', () { + for (final brightness in Brightness.values) { + testWidgets('uses readable block colors in ${brightness.name} mode', ( + tester, + ) async { + FleatherThemeData? fleatherTheme; + ConduitThemeExtension? conduitTheme; + final appTheme = brightness == Brightness.dark + ? AppTheme.dark(TweakcnThemes.conduit) + : AppTheme.light(TweakcnThemes.conduit); + + await tester.pumpWidget( + MaterialApp( + theme: appTheme, + home: Builder( + builder: (context) { + conduitTheme = context.conduitTheme; + fleatherTheme = buildNoteEditorFleatherTheme(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + final editor = fleatherTheme!; + final colors = conduitTheme!; + check(editor.paragraph.style.color).equals(colors.textPrimary); + check(editor.heading1.style.color).equals(colors.textPrimary); + check(editor.heading2.style.color).equals(colors.textPrimary); + check(editor.heading3.style.color).equals(colors.textPrimary); + check(editor.heading4.style.color).equals(colors.textPrimary); + check(editor.heading5.style.color).equals(colors.textPrimary); + check(editor.heading6.style.color).equals(colors.textPrimary); + check(editor.lists.style.color).equals(colors.textPrimary); + check(editor.lists.style.fontFamily).equals(AppTypography.fontFamily); + check(editor.code.style.color).equals(colors.codeText); + check(editor.code.decoration?.color).equals(colors.codeBackground); + check(editor.inlineCode.style.color).equals(colors.codeText); + check(editor.inlineCode.backgroundColor).equals(colors.codeBackground); + }); + } + }); +} diff --git a/test/shared/widgets/markdown/markdown_preprocessor_test.dart b/test/shared/widgets/markdown/markdown_preprocessor_test.dart index dc79facd2..597f92324 100644 --- a/test/shared/widgets/markdown/markdown_preprocessor_test.dart +++ b/test/shared/widgets/markdown/markdown_preprocessor_test.dart @@ -91,12 +91,50 @@ void main() { check(result).contains('after'); }); + test('preserves literal reasoning blocks inside code spans and fences', () { + const input = '''before remove me + +```inline example``` + +```` +
fenced example
+```` + +after'''; + + final result = ConduitMarkdownPreprocessor.sanitize(input); + check(result).not((value) => value.contains('remove me')); + check(result).contains('```inline example```'); + check(result).contains( + '````\n
fenced example
\n````', + ); + }); + test('collapses 3+ newlines to double newline', () { final result = ConduitMarkdownPreprocessor.sanitize('a\n\n\n\nb'); check(result).equals('a\n\nb'); }); }); + group('ConduitMarkdownPreprocessor.stripLinkReferenceDefinitions', () { + test('preserves definitions inside variable-length code delimiters', () { + const input = '''[remove]: https://example.com/remove + +``[inline]: `literal` `` + +```` +[fenced]: https://example.com/keep +````'''; + + final result = ConduitMarkdownPreprocessor.stripLinkReferenceDefinitions( + input, + ); + check(result).not((value) => value.contains('[remove]')); + check(result).contains('``[inline]: `literal` ``'); + check(result).contains('````\n[fenced]: https://example.com/keep\n````'); + }); + }); + group('ConduitMarkdownPreprocessor.toPlainText', () { test('whitespace-only returns empty', () { check(ConduitMarkdownPreprocessor.toPlainText(' ')).equals(''); @@ -271,6 +309,23 @@ void main() { check(result).contains('```
code
```'); check(result).not((s) => s.contains(' hide
x
')); }); + + test('removes details containing code without splitting the block', () { + const input = '''before +
Hidden +``inline `secret` `` +```` +fenced secret +```` +
+after'''; + + final result = ConduitMarkdownPreprocessor.removeAllDetails(input); + check(result).not((value) => value.contains('Hidden')); + check(result).not((value) => value.contains('secret')); + check(result).contains('before'); + check(result).contains('after'); + }); }); group('ConduitMarkdownPreprocessor.softenInlineCode', () {