Files
buzz/mobile/lib/features/channels/agent_activity/transcript_item_widget.dart
TolgaCinisli fa1b27bcc8 fix(mobile): style inline code with the app mono face (#6631)
## Summary

Inline code on mobile renders as **bold body text on a faint background
wash** — no monospace face, no chip, and it cannot wrap. #5257 diagnosed
this as a missing `highlightBuilder`.

That is no longer the right fix. `gpt_markdown` 1.2.0 deprecates
`highlightBuilder` (removal in 2.0.0), renders inline code as a real
chip, and adds `InlineCodeStyle` for restyling it. The package author
confirmed this on the issue. So this PR is an upgrade — 1.1.6 → 1.2.1 —
plus one theme declaration, rather than the builder the issue originally
asked for.

**Where the style is declared.** `GptMarkdownThemeData` goes in
`AppTheme._buildTheme`, which both `light()` and `dark()` call. That
reaches all four `GptMarkdown` call sites — `message_content`,
`transcript_item_widget`, `token_pill`, `custom_emoji_render` — so the
style is stated once instead of per widget. A widget-level
`inlineCodeStyle` would have covered channel messages only, leaving the
other three on the package's defaults.

**What is declared.** Face, size, ink, chip fill and outline — not the
face alone. A face name on its own leaves the rest on the package's
defaults, which put inline code at 14.1sp beside a fenced block's 13, on
a neutral `onSurface` tint rather than the app's code surface. In dark
that tint is *lighter* than the surface, while every other code surface
in the app is recessed, so the chip read as a different kind of object.
All of it now comes from one `CodeStyle` declaration that the fenced
block reads from too, so the two cannot be edited apart.

**Three adaptations the upgrade requires.** Each was found by running
the gate, not by reading the changelog:

1. **`imageBuilder` widened** to `(context, url, width, height)`. This
is a hard compile error, and it is **not listed in the package's
migration guide**, which states "nothing here stops code compiling".
Worth reporting upstream.
2. **`autolink` now defaults to `true`.** `normalizeBareLinks()` already
rewrites bare URLs into Markdown links before rendering, so both would
run. `message_content` opts out with `autolink: false` to keep current
behaviour exactly. The migration guide argues for dropping the
pre-processor instead — a better fix, but a behavioural change that
belongs in its own PR.
3. **`gpt_markdown.dart` now re-exports `markdown_config.dart`**, making
two direct imports redundant. `flutter analyze` reports `No issues
found!` on 1.1.6 and flags both on 1.2.1, so these warnings are new, not
pre-existing.

**Deliberately out of scope.** The three non-message call sites now
autolink bare URLs, since only `message_content` has a pre-processor to
collide with. Custom inline components (`_MentionMd`, `CustomEmojiMd`,
`_ChannelLinkMd`) could additionally declare `allScopesExceptLinkLabel`
— 1.2.0 offers it as the fix for a `WidgetSpan` chip going blank inside
a link label on iOS — but current behaviour is unchanged without it, so
that stays a separate change.

### Related issue

Fixes #5257

Duplicate scan: searched `gpt_markdown`, `inline code mobile`,
`highlightBuilder` and `InlineCodeStyle` across both PRs and issues. No
open PR touches inline code styling. #6135 (link labels) and #6166 (text
selection) also touch mobile Markdown but address different defects.

### Testing

Full gate, `just ci` — exit 0:

| Stage | Result |
|---|---|
| Rust (33 suites) | 4768 passed, 0 failed |
| Desktop | 5799 passed, 0 failed |
| Mobile | **2011 passed**, 0 failed |
| `flutter analyze` | `No issues found!` |
| Desktop + web build | ok |

Run on the branch with `main` merged in, so these numbers match what CI
builds.

**New regression test** — `renders inline code in the app code style`.
It resolves the `CodeTextSpan` the package tags inline code with, which
carries both the resolved `TextStyle` and the colours the chip behind it
is painted with, so face, size, ink, fill and outline are all asserted
rather than a widget's presence. It is negative-controlled: reverting
only the theme declaration fails it with

```text
Expected: a numeric value within <0.001> of <13.0>
  Actual: <14.1>
```

and dropping the declaration entirely falls back to
`packages/gpt_markdown/JetBrainsMono` — so the test measures the real
thing, and it would catch a future regression that silently drops the
theme extension.

The test passes `baseStyle: messageBodyTextStyle`, the style the message
surfaces actually use; the widget's own fallback is the smaller
`bodyMedium`, which would move the expected size.

The test finds paragraphs with `find.byWidgetPredicate((widget) =>
widget is RichText)`, not `find.byType(RichText)`: inline code renders
through `BidiRichText`, a `RichText` subclass, and `byType` matches
exact runtime types.

That is a hazard for any test that reads text back out of a paragraph,
and one landed after this branch was cut:
`message_content_custom_emoji_test.dart` arrived with #6996 and its
`code keeps literal emoji while adjacent known tokens render` case reads
a code span through `find.byType(RichText)`. It passes on `main` and
fails on the merge result, which is what CI builds, so it went red only
once CI was authorized. It now uses the same predicate. The two other
`byType(RichText)` call sites — the rest of that file and
`message_author_meta_test.dart` — were re-run and pass: their content
carries no code span, so the exact type still matches. They were left
alone.

### Screenshots

Rendered through the real `MessageContent` widget with the app's own
fonts loaded, at 390pt wide, 3x DPR. Sample text: ``Set `BUZZ_RELAY_URL`
before launch, then run `just mobile-test` to verify.``

| | Before (1.1.6) | After (1.2.1) |
|---|---|---|
| Light |
![before-inline-code-light](https://raw.githubusercontent.com/TolgaCinisli/buzz/2d2d846291416d9b32d3fb9cfead950bcc4fe123/pr-6631--before-inline-code-light.png)
|
![after-inline-code-light](https://raw.githubusercontent.com/TolgaCinisli/buzz/f230b95c7260a32bd5d76b1ac42130720a168521/pr-6631--after-inline-code-light.png)
|
| Dark |
![before-inline-code-dark](https://raw.githubusercontent.com/TolgaCinisli/buzz/2d2d846291416d9b32d3fb9cfead950bcc4fe123/pr-6631--before-inline-code-dark.png)
|
![after-inline-code-dark](https://raw.githubusercontent.com/TolgaCinisli/buzz/f230b95c7260a32bd5d76b1ac42130720a168521/pr-6631--after-inline-code-dark.png)
|

Before: bold Inter on a flat wash, no chip edge, and `just mobile-test`
breaks across the line with the wash simply ending. After: Geist Mono in
a bordered, rounded chip, and the wrapped fragment gets its own chip on
each line.

---------

Signed-off-by: Tolga Cinisli <tolgacinisli@gmail.com>
Co-authored-by: Tolga Cinisli <tolgacinisli@gmail.com>
2026-09-08 08:05:11 -06:00

493 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:lucide_icons_flutter/lucide_icons.dart';
import '../../../shared/theme/theme.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(
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,
),
],
),
);
}
}
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(
LucideIcons.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
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 14,
color: context.colors.onSurfaceVariant,
),
],
),
if (expanded.value && item.text.isNotEmpty) ...[
const SizedBox(height: Grid.half),
GptMarkdown(
item.text,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
// As above: no link handler on this surface either.
autolink: false,
),
],
],
),
),
),
);
}
}
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(
LucideIcons.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
? LucideIcons.chevronUp
: LucideIcons.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(LucideIcons.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
? LucideIcons.chevronUp
: LucideIcons.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
? LucideIcons.chevronUp
: LucideIcons.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', LucideIcons.circleX);
}
if (status == ToolStatus.completed) {
return (context.appColors.success, 'Done', LucideIcons.circleCheck);
}
if (status == ToolStatus.pending) {
return (context.colors.onSurfaceVariant, 'Pending', LucideIcons.circleDot);
}
return (context.appColors.warning, 'Running', LucideIcons.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,
),
),
),
);
}
}