feat(assistant): collapse prompt metadata in user bubbles

This commit is contained in:
Haitao Pan 2026-03-25 10:33:24 +08:00
parent 2196af7acf
commit c5eff0e9ff
2 changed files with 342 additions and 10 deletions

View File

@ -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 = <String>[];
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 = <String>[
...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,

View File

@ -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>[
AssistantThreadRecord(
sessionKey: 'main',
title: '研发任务',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.raw,
updatedAtMs: 1700000000000,
messages: <GatewayChatMessage>[
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,