mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +00:00
Message Markdown on mobile moves from gpt_markdown 1.2.1's legacy regex
pipeline to the CommonMark parser that 1.3 added ("plusparse"). Before
this, passing `inlineComponents` (now deprecated, removed in 2.0.0) kept
every message on the legacy pipeline.
### What changes for users
Rendering moves closer to desktop (react-markdown + remark-gfm):
- `_italic_` and `__bold__` work, backslash escapes and HTML entities
decode, and `[a [b] c](url)` labels are correct.
- Inline code follows CommonMark pairing: `"```npm install```"` in a
sentence is inline code, and four-backtick fences work.
- Setext headings, reference links, footnotes, `~~~` fences, `1)` lists
and CommonMark list/quote continuation render.
- HTML comments are hidden, as on desktop.
- `\[`, `\]`, `\(` and `\)` stay literal, as on desktop. Plusparse
hard-codes them as LaTeX delimiters, so they're written as entities
before rendering.
- Side effect: an unfenced `__init__` now renders as bold "init", and a
line over `---` becomes a heading, same as desktop.
Device benchmark (iPhone SE, profile build, 600 synthetic messages, 3
launches per build): scrolling spends 9% less time building, and frames
with more than 8 ms of build drop from 63 to 22. Jumping onto unbuilt
messages is 11% cheaper, and raster time is unchanged.
App size: the release app grows about 4.6 MB uncompressed (80.6 to 85.2
MB), mostly Dart code from the new math and highlighting packages.
### Before and after
Synthetic messages that reproduce differences found in real channel
history, rendered through `MessageContent` on main and on this branch:








### Demo
A scroll through synthetic messages covering common formatting
(headings, lists, quotes, links, highlighted code, a table, a task list,
escapes), recorded on an iPhone 17 Pro simulator running this branch:
https://github.com/user-attachments/assets/bdc9680c-d20b-41ec-8e1a-a5b51c602f9d
### How
- Mentions, custom emoji and `#channel` tokens are `InlinePattern`s.
Links go through `inlineLinkBuilder` into the existing `_buildLink`, so
chips, media links and taps are unchanged.
- Removed workarounds the parser makes unnecessary:
- the render-path escape shielding from #8925
- the `_MarkdownEscapeMd`, `_MarkdownLabelEntityMd` and `_MessageLinkMd`
components
- the zero-width prefix before a leading `#channel`
Plain-text previews and gallery helpers keep their own escape handling.
`normalizeBareLinks` stays because attachment and gallery detection
depend on its output. Bubble segmentation unescapes a link destination
itself before its imeta lookup, since content no longer arrives
shielded.
- Kept desktop parity where plusparse differs from GFM:
- a table typed directly under a caption line gets a blank line inserted
before it, since plusparse can't start a table mid-paragraph
- `> [!NOTE]` quotes render as quotes (`alertBuilder` returns
`asBlockQuote()`), since desktop has no alerts
- `mapMarkdownProse` now ends an unclosed backtick run at its paragraph.
Before, it could pair with a run in a later block and flip code and
prose for the rest of the message.
- Synthesized autolink labels escape backslashes instead of using
`\`, which the parser leaves undecoded in a URL-shaped label.
- Repo/PR/issue chips keep their own semantics node.
- Forum card previews clip at their 120 px cap instead of overflowing:
blocks now lay out as a column.
- Pinned to exactly 1.3.4. 1.4.0 renders HTML by default and themes
through the separate `material_ui` package, which would need a theme
bridge.
### Validation
- Full mobile suite passes and analyze is clean at 78b9c1a6d0.
- All of #8925's and #8940's backslash-escape cases pass on the new
parser, including the strike-across-lines case that used to be a known
limitation.
- Real-message diff: 2,259 recent messages from 13 busy channels,
rendered through `MessageContent` on main and on this branch. 29
differed in text or styling. On inline and fenced code, the branch
matched desktop's parser (remark-gfm) in 23 of them and main in 7. The
review found the three parity gaps above, now fixed. The rest are
CommonMark behavior that desktop shares.
- Added tests, each confirmed to fail without its fix:
- a leading `#channel` renders as a link, not a heading
- a table under its caption renders as a table
- an alert-marker quote stays a quote
- an unclosed backtick run doesn't flip later code
- the transcript tests detect a rendered link by its style; the old
`LinkButton` check passed even with autolink on
- Automated simulator run (iPhone 17 Pro, iOS 27, debug build, signed in
to a Builderlab community): channel list, two channels with history
scrolled back several pages (mentions, link previews, thread summaries,
images), an agent Solo chat with long agent messages, and the inbox. No
rendering errors or layout breaks; the Solo chat screen also matched
main's build side by side.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
500 lines
16 KiB
Dart
500 lines
16 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:gpt_markdown/gpt_markdown.dart';
|
|
import 'package:buzz/shared/theme/buzz_icons.dart';
|
|
|
|
import '../../../shared/theme/theme.dart';
|
|
import '../message_content/markdown_escapes.dart';
|
|
import 'observer_models.dart';
|
|
|
|
/// Renders a single [TranscriptItem] in the agent activity transcript.
|
|
class TranscriptItemWidget extends StatelessWidget {
|
|
final TranscriptItem item;
|
|
|
|
const TranscriptItemWidget({super.key, required this.item});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return switch (item) {
|
|
final MessageItem i => _MessageItemWidget(item: i),
|
|
final ThoughtItem i => _ThoughtItemWidget(item: i),
|
|
final LifecycleItem i => _LifecycleItemWidget(item: i),
|
|
final MetadataItem i => _MetadataItemWidget(item: i),
|
|
final ToolItem i => _ToolItemWidget(item: i),
|
|
};
|
|
}
|
|
}
|
|
|
|
class _MessageItemWidget extends StatelessWidget {
|
|
final MessageItem item;
|
|
|
|
const _MessageItemWidget({required this.item});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isAssistant = item.role == 'assistant';
|
|
final badgeColor = isAssistant
|
|
? context.colors.primary
|
|
: context.colors.onSurfaceVariant;
|
|
final badgeLabel = isAssistant ? 'Assistant' : 'User';
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: Grid.xxs,
|
|
vertical: Grid.quarter,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: badgeColor.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
badgeLabel,
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
color: badgeColor,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: Grid.half),
|
|
if (item.text.isNotEmpty)
|
|
GptMarkdown(
|
|
escapeMarkdownLiterals(item.text),
|
|
style: context.textTheme.bodyMedium?.copyWith(
|
|
color: context.colors.onSurface,
|
|
),
|
|
// No link handler here, so an autolinked URL would draw as a
|
|
// link and do nothing when tapped. Transcript URLs stay text,
|
|
// as they were before gpt_markdown started autolinking.
|
|
autolink: false,
|
|
inlinePatterns: _transcriptInlinePatterns,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ThoughtItemWidget extends HookWidget {
|
|
final ThoughtItem item;
|
|
|
|
const _ThoughtItemWidget({required this.item});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final expanded = useState(item.text.length <= 200);
|
|
|
|
useEffect(() {
|
|
expanded.value = item.text.length <= 200;
|
|
return null;
|
|
}, [item.id]);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
|
child: GestureDetector(
|
|
onTap: () => expanded.value = !expanded.value,
|
|
child: Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(Grid.xxs),
|
|
decoration: BoxDecoration(
|
|
color: context.colors.surfaceContainerHighest.withValues(
|
|
alpha: 0.5,
|
|
),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
BuzzIcons.brain,
|
|
size: 14,
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: Grid.half),
|
|
Expanded(
|
|
child: Text(
|
|
item.title,
|
|
style: context.textTheme.labelMedium?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
Icon(
|
|
expanded.value
|
|
? BuzzIcons.chevronUp
|
|
: BuzzIcons.chevronDown,
|
|
size: 14,
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
],
|
|
),
|
|
if (expanded.value && item.text.isNotEmpty) ...[
|
|
const SizedBox(height: Grid.half),
|
|
GptMarkdown(
|
|
escapeMarkdownLiterals(item.text),
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
// As above: no link handler on this surface either.
|
|
autolink: false,
|
|
inlinePatterns: _transcriptInlinePatterns,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LifecycleItemWidget extends StatelessWidget {
|
|
final LifecycleItem item;
|
|
|
|
const _LifecycleItemWidget({required this.item});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
|
child: Center(
|
|
child: Text(
|
|
'${item.title}${item.text.isNotEmpty ? ' \u2014 ${item.text}' : ''}',
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MetadataItemWidget extends HookWidget {
|
|
final MetadataItem item;
|
|
|
|
const _MetadataItemWidget({required this.item});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final expanded = useState(false);
|
|
|
|
useEffect(() {
|
|
expanded.value = false;
|
|
return null;
|
|
}, [item.id]);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
|
child: GestureDetector(
|
|
onTap: () => expanded.value = !expanded.value,
|
|
child: Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(Grid.xxs),
|
|
decoration: BoxDecoration(
|
|
color: context.colors.surfaceContainerHighest.withValues(
|
|
alpha: 0.3,
|
|
),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
BuzzIcons.fileText,
|
|
size: 14,
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: Grid.half),
|
|
Expanded(
|
|
child: Text(
|
|
'${item.title} (${item.sections.length} sections)',
|
|
style: context.textTheme.labelMedium?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
Icon(
|
|
expanded.value
|
|
? BuzzIcons.chevronUp
|
|
: BuzzIcons.chevronDown,
|
|
size: 14,
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
],
|
|
),
|
|
if (expanded.value)
|
|
for (final section in item.sections) ...[
|
|
const SizedBox(height: Grid.xxs),
|
|
Text(
|
|
section.title,
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
if (section.body.isNotEmpty) ...[
|
|
const SizedBox(height: Grid.quarter),
|
|
Text(
|
|
section.body.length > 500
|
|
? '${section.body.substring(0, 500)}\u2026'
|
|
: section.body,
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ToolItemWidget extends HookWidget {
|
|
final ToolItem item;
|
|
|
|
const _ToolItemWidget({required this.item});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final argsExpanded = useState(false);
|
|
final resultExpanded = useState(false);
|
|
|
|
useEffect(() {
|
|
argsExpanded.value = false;
|
|
resultExpanded.value = false;
|
|
return null;
|
|
}, [item.id]);
|
|
|
|
final (statusColor, statusLabel, statusIcon) = _toolStatusDisplay(
|
|
item.status,
|
|
item.isError,
|
|
context,
|
|
);
|
|
|
|
final displayName = _formatToolName(item.toolName);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
|
child: Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(Grid.xxs),
|
|
decoration: BoxDecoration(
|
|
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.4),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: item.isError
|
|
? Border.all(color: context.colors.error.withValues(alpha: 0.3))
|
|
: null,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Header: tool name + status badge
|
|
Row(
|
|
children: [
|
|
Icon(BuzzIcons.wrench, size: 14, color: statusColor),
|
|
const SizedBox(width: Grid.half),
|
|
Expanded(
|
|
child: Text(
|
|
displayName,
|
|
style: context.textTheme.labelMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: Grid.xxs,
|
|
vertical: Grid.quarter,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: statusColor.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(statusIcon, size: 10, color: statusColor),
|
|
const SizedBox(width: Grid.quarter),
|
|
Text(
|
|
statusLabel,
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
color: statusColor,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
// Args section
|
|
if (item.args.isNotEmpty) ...[
|
|
const SizedBox(height: Grid.half),
|
|
GestureDetector(
|
|
onTap: () => argsExpanded.value = !argsExpanded.value,
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
'Arguments',
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(width: Grid.half),
|
|
Icon(
|
|
argsExpanded.value
|
|
? BuzzIcons.chevronUp
|
|
: BuzzIcons.chevronDown,
|
|
size: 12,
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (argsExpanded.value) ...[
|
|
const SizedBox(height: Grid.quarter),
|
|
_CodeBlock(text: _prettyJson(item.args)),
|
|
],
|
|
],
|
|
// Result section
|
|
if (item.result.isNotEmpty) ...[
|
|
const SizedBox(height: Grid.half),
|
|
GestureDetector(
|
|
onTap: () => resultExpanded.value = !resultExpanded.value,
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
'Result',
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
color: item.isError
|
|
? context.colors.error
|
|
: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(width: Grid.half),
|
|
Icon(
|
|
resultExpanded.value
|
|
? BuzzIcons.chevronUp
|
|
: BuzzIcons.chevronDown,
|
|
size: 12,
|
|
color: item.isError
|
|
? context.colors.error
|
|
: context.colors.onSurfaceVariant,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (resultExpanded.value) ...[
|
|
const SizedBox(height: Grid.quarter),
|
|
_CodeBlock(
|
|
text: item.result.length > 2000
|
|
? '${item.result.substring(0, 2000)}\n\n\u2026 (truncated)'
|
|
: item.result,
|
|
isError: item.isError,
|
|
),
|
|
],
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
(Color, String, IconData) _toolStatusDisplay(
|
|
ToolStatus status,
|
|
bool isError,
|
|
BuildContext context,
|
|
) {
|
|
if (isError || status == ToolStatus.failed) {
|
|
return (context.colors.error, 'Error', BuzzIcons.circleX);
|
|
}
|
|
if (status == ToolStatus.completed) {
|
|
return (context.appColors.success, 'Done', BuzzIcons.circleCheck);
|
|
}
|
|
if (status == ToolStatus.pending) {
|
|
return (context.colors.onSurfaceVariant, 'Pending', BuzzIcons.circleDot);
|
|
}
|
|
return (context.appColors.warning, 'Running', BuzzIcons.clock3);
|
|
}
|
|
|
|
String _formatToolName(String toolName) {
|
|
return toolName
|
|
.split('_')
|
|
.map(
|
|
(part) =>
|
|
part.isEmpty ? '' : '${part[0].toUpperCase()}${part.substring(1)}',
|
|
)
|
|
.join(' ');
|
|
}
|
|
|
|
String _prettyJson(Map<String, dynamic> value) {
|
|
try {
|
|
return const JsonEncoder.withIndent(' ').convert(value);
|
|
} catch (_) {
|
|
return value.toString();
|
|
}
|
|
}
|
|
|
|
class _CodeBlock extends StatelessWidget {
|
|
final String text;
|
|
final bool isError;
|
|
|
|
const _CodeBlock({required this.text, this.isError = false});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(Grid.xxs),
|
|
decoration: BoxDecoration(
|
|
color: isError
|
|
? context.colors.error.withValues(alpha: 0.06)
|
|
: context.colors.surface,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Text(
|
|
text,
|
|
softWrap: false,
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
fontFamily: 'monospace',
|
|
fontSize: 11,
|
|
color: isError
|
|
? context.colors.error
|
|
: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Escapes read the same here as in messages: `\(` stays a parenthesis rather
|
|
// than opening a formula, and an escaped sigil draws as itself.
|
|
final _transcriptInlinePatterns = [escapedSigilPattern];
|