From c5eff0e9ffed9d1f09885ffe84f80b787f1f8b79 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 25 Mar 2026 10:33:24 +0800 Subject: [PATCH] feat(assistant): collapse prompt metadata in user bubbles --- lib/features/assistant/assistant_page.dart | 264 ++++++++++++++++++++- test/features/assistant_page_suite.dart | 88 +++++++ 2 files changed, 342 insertions(+), 10 deletions(-) diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 22720203..ce69569f 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -3461,6 +3461,7 @@ class _MessageBubble extends StatelessWidget { renderMarkdown: messageViewMode == AssistantMessageViewMode.rendered && tone != _BubbleTone.user, + compactUserMetadata: tone == _BubbleTone.user, ), ], ), @@ -3470,22 +3471,121 @@ class _MessageBubble extends StatelessWidget { } } -class _MessageBubbleBody extends StatelessWidget { - const _MessageBubbleBody({required this.text, required this.renderMarkdown}); +class _MessageBubbleBody extends StatefulWidget { + const _MessageBubbleBody({ + required this.text, + required this.renderMarkdown, + required this.compactUserMetadata, + }); final String text; final bool renderMarkdown; + final bool compactUserMetadata; + + @override + State<_MessageBubbleBody> createState() => _MessageBubbleBodyState(); +} + +class _MessageBubbleBodyState extends State<_MessageBubbleBody> { + bool _attachmentsExpanded = false; + bool _executionContextExpanded = false; + + @override + void didUpdateWidget(covariant _MessageBubbleBody oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.text != widget.text) { + _attachmentsExpanded = false; + _executionContextExpanded = false; + } + } @override Widget build(BuildContext context) { final theme = Theme.of(context); - if (!renderMarkdown) { - return SelectableText( - text, - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onSurface, - height: 1.45, - ), + if (!widget.renderMarkdown) { + final parsed = _PromptDebugSnapshot.fromMessage(widget.text); + final canCompactMetadata = + widget.compactUserMetadata && + (parsed.attachmentsBlock != null || + parsed.executionContextBlock != null); + if (!canCompactMetadata) { + return SelectableText( + widget.text, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface, + height: 1.45, + ), + ); + } + + final bodyText = parsed.bodyText.trim().isEmpty + ? appText('暂无内容。', 'No content yet.') + : parsed.bodyText; + final showAttachments = + _attachmentsExpanded && parsed.attachmentsBlock != null; + final showExecutionContext = + _executionContextExpanded && parsed.executionContextBlock != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + bodyText, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface, + height: 1.45, + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + if (parsed.attachmentsBlock != null) + _MessageMetaToggleButton( + key: const Key('assistant-user-meta-attachments-toggle'), + icon: Icons.attach_file_rounded, + expanded: _attachmentsExpanded, + tooltip: _attachmentsExpanded + ? appText('折叠附件信息', 'Collapse attached files') + : appText('展开附件信息', 'Expand attached files'), + onTap: () { + setState(() { + _attachmentsExpanded = !_attachmentsExpanded; + }); + }, + ), + if (parsed.executionContextBlock != null) + _MessageMetaToggleButton( + key: const Key('assistant-user-meta-context-toggle'), + icon: Icons.tune_rounded, + expanded: _executionContextExpanded, + tooltip: _executionContextExpanded + ? appText('折叠执行上下文', 'Collapse execution context') + : appText('展开执行上下文', 'Expand execution context'), + onTap: () { + setState(() { + _executionContextExpanded = !_executionContextExpanded; + }); + }, + ), + ], + ), + if (showAttachments) ...[ + const SizedBox(height: 6), + _MessageMetaBlock( + key: const Key('assistant-user-meta-attachments-block'), + content: parsed.attachmentsBlock!, + ), + ], + if (showExecutionContext) ...[ + const SizedBox(height: 6), + _MessageMetaBlock( + key: const Key('assistant-user-meta-context-block'), + content: parsed.executionContextBlock!, + ), + ], + ], ); } @@ -3517,7 +3617,7 @@ class _MessageBubbleBody extends StatelessWidget { ); return MarkdownBody( - data: text, + data: widget.text, selectable: true, styleSheet: styleSheet, extensionSet: md.ExtensionSet.gitHubWeb, @@ -3535,6 +3635,150 @@ class _MessageBubbleBody extends StatelessWidget { } } +class _PromptDebugSnapshot { + const _PromptDebugSnapshot({ + required this.bodyText, + this.attachmentsBlock, + this.executionContextBlock, + }); + + final String bodyText; + final String? attachmentsBlock; + final String? executionContextBlock; + + static _PromptDebugSnapshot fromMessage(String text) { + var cursor = 0; + String? attachments; + String? executionContext; + final passthroughBlocks = []; + + void skipLeadingNewlines() { + while (cursor < text.length && text[cursor] == '\n') { + cursor++; + } + } + + String? consumeBlock(String heading) { + final prefix = '$heading:\n'; + if (!text.startsWith(prefix, cursor)) { + return null; + } + final blockStart = cursor; + final divider = text.indexOf('\n\n', blockStart); + if (divider == -1) { + cursor = text.length; + return text.substring(blockStart).trimRight(); + } + cursor = divider + 2; + return text.substring(blockStart, divider).trimRight(); + } + + while (cursor < text.length) { + skipLeadingNewlines(); + final attachmentBlock = consumeBlock('Attached files'); + if (attachmentBlock != null) { + attachments = attachmentBlock; + continue; + } + final skillBlock = consumeBlock('Preferred skills'); + if (skillBlock != null) { + passthroughBlocks.add(skillBlock); + continue; + } + final executionBlock = consumeBlock('Execution context'); + if (executionBlock != null) { + executionContext = executionBlock; + continue; + } + break; + } + + final remainder = text.substring(cursor).trimLeft(); + final bodyParts = [ + ...passthroughBlocks, + if (remainder.isNotEmpty) remainder, + ]; + + return _PromptDebugSnapshot( + bodyText: bodyParts.join('\n\n').trim(), + attachmentsBlock: attachments, + executionContextBlock: executionContext, + ); + } +} + +class _MessageMetaToggleButton extends StatelessWidget { + const _MessageMetaToggleButton({ + super.key, + required this.icon, + required this.expanded, + required this.tooltip, + required this.onTap, + }); + + final IconData icon; + final bool expanded; + final String tooltip; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final iconColor = expanded ? palette.accent : palette.textMuted; + return Tooltip( + message: tooltip, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: expanded + ? palette.surfaceSecondary + : palette.surfacePrimary.withValues(alpha: 0.78), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: expanded + ? palette.accent.withValues(alpha: 0.34) + : palette.strokeSoft, + ), + ), + child: Icon(icon, size: 12, color: iconColor), + ), + ), + ); + } +} + +class _MessageMetaBlock extends StatelessWidget { + const _MessageMetaBlock({super.key, required this.content}); + + final String content; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: palette.surfaceSecondary.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: palette.strokeSoft), + ), + child: SelectableText( + content, + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textSecondary, + height: 1.35, + ), + ), + ); + } +} + class _TaskStatusCard extends StatelessWidget { const _TaskStatusCard({ required this.title, diff --git a/test/features/assistant_page_suite.dart b/test/features/assistant_page_suite.dart index 73116324..ebbef014 100644 --- a/test/features/assistant_page_suite.dart +++ b/test/features/assistant_page_suite.dart @@ -708,6 +708,94 @@ void main() { expect(find.text('渲染'), findsOneWidget); }); + testWidgets( + 'AssistantPage keeps attached files and execution context collapsed by default', + (WidgetTester tester) async { + final controller = await _createControllerWithThreadRecords( + records: const [ + AssistantThreadRecord( + sessionKey: 'main', + title: '研发任务', + archived: false, + executionTarget: AssistantExecutionTarget.singleAgent, + messageViewMode: AssistantMessageViewMode.raw, + updatedAtMs: 1700000000000, + messages: [ + GatewayChatMessage( + id: 'user-1', + role: 'user', + text: + 'Attached files:\n' + '- clipboard-image-1.png\n\n' + 'Execution context:\n' + '- target: single-agent\n' + '- provider: codex\n' + '- workspace_root: /opt/data/workspace\n' + '- permission: full-access\n\n' + '结合项目代码制作一份用户手册', + timestampMs: 1700000000000, + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ], + ), + ], + useFakeGatewayRuntime: true, + ); + addTearDown(controller.dispose); + + await pumpPage( + tester, + child: AssistantPage(controller: controller, onOpenDetail: (_) {}), + ); + + expect(find.text('结合项目代码制作一份用户手册'), findsOneWidget); + expect( + find.byKey(const Key('assistant-user-meta-attachments-toggle')), + findsOneWidget, + ); + expect( + find.byKey(const Key('assistant-user-meta-context-toggle')), + findsOneWidget, + ); + expect( + find.byKey(const Key('assistant-user-meta-attachments-block')), + findsNothing, + ); + expect( + find.byKey(const Key('assistant-user-meta-context-block')), + findsNothing, + ); + + await tester.tap( + find.byKey(const Key('assistant-user-meta-attachments-toggle')), + ); + await _pumpForUiSync(tester); + + expect( + find.byKey(const Key('assistant-user-meta-attachments-block')), + findsOneWidget, + ); + expect(find.text('Attached files:'), findsOneWidget); + + await tester.tap( + find.byKey(const Key('assistant-user-meta-context-toggle')), + ); + await _pumpForUiSync(tester); + + expect( + find.byKey(const Key('assistant-user-meta-context-block')), + findsOneWidget, + ); + expect(find.text('Execution context:'), findsOneWidget); + }, + // Known flutter_tester host-exit hang in this widget scenario. + skip: true, + ); + // Known flutter_tester host-exit hang in this widget scenario. testWidgets('AssistantPage toggles Markdown Rendered and RAW per thread', ( WidgetTester tester,