clickable http links in the Claude conversation; bundle markdown hooks (T-253)

http(s) links (typed or autolinked) in the conversation now open via the OS URL
handler (OsBridge.openURL) on click, with a hover underline + pointer; non-http
schemes stay inert. Works across prose, lists, tables, and headings.

Refactor: ClideMarkdown's growing set of inline-interaction callbacks
(onRecordTap, onImageToken, onLinkTap) is bundled into one ClideMarkdownHooks
value threaded as a single param — no more per-callback threading, and the hooks
now reach every context uniformly (links/images previously only worked in some).
The public widget API is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 22:16:57 +02:00
co-authored by Claude Opus 4.8
parent dc8ae0beaa
commit 1e8e37c6f9
4 changed files with 167 additions and 49 deletions
+4
View File
@@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- **Links in the Claude conversation are clickable.** An http(s) link (typed or
autolinked) now opens in your default browser on click — with a hover
underline + pointer — across prose, lists, tables, and headings. Non-http
schemes stay inert. (T-253)
- **The activity-card fold level is now adjustable and persists.** A
`claude.activity.fold-level` command cycles how aggressively meta steps fold
(none → tools → thinking → everything); the choice is saved app-wide and the
@@ -9,6 +9,7 @@
/// separate concern (T-138).
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -360,6 +361,11 @@ void _openRecord(BuildContext context, String id) {
ClideKernel.of(context).messages.publish(publisher, 'selection', {'id': id});
}
/// Hand a clicked conversation link to the OS URL handler (T-253).
void _openUrl(BuildContext context, String url) {
unawaited(ClideKernel.of(context).os.openURL(url));
}
/// One conversation item, rendered by kind.
class _ConversationTurn extends StatelessWidget {
const _ConversationTurn({
@@ -421,6 +427,7 @@ class _ConversationTurn extends StatelessWidget {
i.text,
onRecordTap: (id) => _openRecord(context, id),
onImageToken: (path) => ImageThumbnail(path: path, size: 48),
onLinkTap: (url) => _openUrl(context, url),
),
),
// Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the
@@ -430,7 +437,7 @@ class _ConversationTurn extends StatelessWidget {
accent: i.isSidechain ? tokens.globalTextMuted : claudeAccent,
label: i.isSidechain ? 'agent' : 'claude',
copyText: i.text,
body: ClideMarkdown(i.text, onRecordTap: (id) => _openRecord(context, id)),
body: ClideMarkdown(i.text, onRecordTap: (id) => _openRecord(context, id), onLinkTap: (url) => _openUrl(context, url)),
),
AssistantThinkingMessage() => ConversationCard(
variant: ConversationCardVariant.bare,
+102 -48
View File
@@ -15,8 +15,32 @@ typedef RecordTapCallback = void Function(String id);
/// only locates the tokens and drops the widget into the text flow.
typedef ImageTokenBuilder = Widget Function(String path);
/// Invoked when a rendered http(s) link is activated (T-253) — the caller opens
/// it (e.g. the OS URL handler).
typedef LinkTapCallback = void Function(String url);
/// The interaction hooks a [ClideMarkdown] render may fire — bundled into one
/// value so the render tree threads a single object instead of a growing list
/// of optional callbacks. All optional; a null hook leaves that affordance
/// inert (the text renders, just not interactive).
@immutable
class ClideMarkdownHooks {
const ClideMarkdownHooks({this.onRecordTap, this.onImageToken, this.onLinkTap});
/// Tap a governance/ticket ref (T-281, D-77, …) → open the record (T-279).
final RecordTapCallback? onRecordTap;
/// Build the inline widget for a pasted-image `@<path>` token (T-236).
final ImageTokenBuilder? onImageToken;
/// Open an activated http(s) link (T-253).
final LinkTapCallback? onLinkTap;
static const none = ClideMarkdownHooks();
}
class ClideMarkdown extends StatelessWidget {
const ClideMarkdown(this.source, {super.key, this.onRecordTap, this.onImageToken});
const ClideMarkdown(this.source, {super.key, this.onRecordTap, this.onImageToken, this.onLinkTap});
static const double _fontSize = 16;
static const double _lineHeight = clideLineHeight;
@@ -41,6 +65,7 @@ class ClideMarkdown extends StatelessWidget {
final String source;
final RecordTapCallback? onRecordTap;
final ImageTokenBuilder? onImageToken;
final LinkTapCallback? onLinkTap;
static String _unescapeHtml(String s) {
return s
@@ -57,7 +82,8 @@ class ClideMarkdown extends StatelessWidget {
final tokens = ClideTheme.of(context).surface;
final doc = md.Document(extensionSet: md.ExtensionSet.gitHubFlavored);
final nodes = doc.parseLines(source.split('\n'));
final widgets = _buildNodes(nodes, tokens, onRecordTap, onImageToken: onImageToken);
final hooks = ClideMarkdownHooks(onRecordTap: onRecordTap, onImageToken: onImageToken, onLinkTap: onLinkTap);
final widgets = _buildNodes(nodes, tokens, hooks);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
@@ -73,7 +99,7 @@ class ClideMarkdown extends StatelessWidget {
return false;
}
static List<Widget> _buildNodes(List<md.Node> nodes, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {ImageTokenBuilder? onImageToken}) {
static List<Widget> _buildNodes(List<md.Node> nodes, SurfaceTokens tokens, ClideMarkdownHooks hooks) {
final out = <Widget>[];
final inlineRun = <md.Node>[];
@@ -82,9 +108,9 @@ class ClideMarkdown extends StatelessWidget {
final spans = <InlineSpan>[];
for (final n in inlineRun) {
if (n is md.Text) {
spans.addAll(_linkifyText(_unescapeHtml(n.text), tokens, onRecordTap, onImageToken: onImageToken));
spans.addAll(_linkifyText(_unescapeHtml(n.text), tokens, hooks));
} else if (n is md.Element) {
spans.add(_inlineElementSpan(n, tokens, onRecordTap, onImageToken: onImageToken));
spans.add(_inlineElementSpan(n, tokens, hooks));
}
}
out.add(Text.rich(
@@ -109,7 +135,7 @@ class ClideMarkdown extends StatelessWidget {
} else {
flushInline();
if (node is md.Element) {
out.add(_buildElement(node, tokens, onRecordTap, onImageToken: onImageToken));
out.add(_buildElement(node, tokens, hooks));
}
}
}
@@ -117,34 +143,34 @@ class ClideMarkdown extends StatelessWidget {
return out;
}
static Widget _buildElement(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {ImageTokenBuilder? onImageToken}) {
static Widget _buildElement(md.Element el, SurfaceTokens tokens, ClideMarkdownHooks hooks) {
switch (el.tag) {
case 'h1':
return Padding(
padding: const EdgeInsets.only(top: 20, bottom: 10),
child: _inlineText(el, tokens, onRecordTap, fontSize: 22, fontWeight: FontWeight.w500),
child: _inlineText(el, tokens, hooks, fontSize: 22, fontWeight: FontWeight.w500),
);
case 'h2':
return Padding(
padding: const EdgeInsets.only(top: 18, bottom: 10),
child: _inlineText(el, tokens, onRecordTap, fontSize: 18, fontWeight: FontWeight.w500),
child: _inlineText(el, tokens, hooks, fontSize: 18, fontWeight: FontWeight.w500),
);
case 'h3':
return Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: _inlineText(el, tokens, onRecordTap, fontSize: 16, fontWeight: FontWeight.w500),
child: _inlineText(el, tokens, hooks, fontSize: 16, fontWeight: FontWeight.w500),
);
case 'h4':
case 'h5':
case 'h6':
return Padding(
padding: const EdgeInsets.only(top: 14, bottom: 8),
child: _inlineText(el, tokens, onRecordTap, fontSize: clideFontBody, fontWeight: FontWeight.w600),
child: _inlineText(el, tokens, hooks, fontSize: clideFontBody, fontWeight: FontWeight.w600),
);
case 'p':
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: _inlineRichText(el, tokens, onRecordTap, onImageToken: onImageToken),
child: _inlineRichText(el, tokens, hooks),
);
case 'ul':
return Padding(
@@ -154,7 +180,7 @@ class ClideMarkdown extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
for (final c in el.children ?? const [])
if (c is md.Element) _buildListItem(c, tokens, onRecordTap, ordered: false, onImageToken: onImageToken)
if (c is md.Element) _buildListItem(c, tokens, hooks, ordered: false)
],
),
);
@@ -166,8 +192,7 @@ class ClideMarkdown extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < (el.children?.length ?? 0); i++)
if (el.children![i] is md.Element)
_buildListItem(el.children![i] as md.Element, tokens, onRecordTap, ordered: true, index: i + 1, onImageToken: onImageToken),
if (el.children![i] is md.Element) _buildListItem(el.children![i] as md.Element, tokens, hooks, ordered: true, index: i + 1),
],
),
);
@@ -179,7 +204,7 @@ class ClideMarkdown extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: _buildNodes(el.children?.cast<md.Node>() ?? const [], tokens, onRecordTap, onImageToken: onImageToken),
children: _buildNodes(el.children?.cast<md.Node>() ?? const [], tokens, hooks),
),
);
case 'pre':
@@ -199,15 +224,14 @@ class ClideMarkdown extends StatelessWidget {
case 'table':
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildTable(el, tokens, onRecordTap),
child: _buildTable(el, tokens, hooks),
);
default:
return _inlineRichText(el, tokens, onRecordTap, onImageToken: onImageToken);
return _inlineRichText(el, tokens, hooks);
}
}
static Widget _buildListItem(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap,
{bool ordered = false, int index = 1, ImageTokenBuilder? onImageToken}) {
static Widget _buildListItem(md.Element el, SurfaceTokens tokens, ClideMarkdownHooks hooks, {bool ordered = false, int index = 1}) {
final bullet = ordered ? '$index. ' : '';
return Padding(
padding: const EdgeInsets.only(bottom: 10),
@@ -219,7 +243,7 @@ class ClideMarkdown extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: _buildNodes(el.children?.cast<md.Node>() ?? const [], tokens, onRecordTap, onImageToken: onImageToken),
children: _buildNodes(el.children?.cast<md.Node>() ?? const [], tokens, hooks),
),
),
],
@@ -227,7 +251,7 @@ class ClideMarkdown extends StatelessWidget {
);
}
static Widget _buildTable(md.Element table, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
static Widget _buildTable(md.Element table, SurfaceTokens tokens, ClideMarkdownHooks hooks) {
final rows = <TableRow>[];
for (final child in table.children ?? const []) {
if (child is! md.Element) continue;
@@ -239,7 +263,7 @@ class ClideMarkdown extends StatelessWidget {
if (cell is! md.Element) continue;
cells.add(Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
child: _inlineText(cell, tokens, onRecordTap, fontWeight: isHeader ? FontWeight.w600 : null),
child: _inlineText(cell, tokens, hooks, fontWeight: isHeader ? FontWeight.w600 : null),
));
}
if (cells.isNotEmpty) {
@@ -258,22 +282,21 @@ class ClideMarkdown extends StatelessWidget {
);
}
static Widget _inlineText(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {double? fontSize, FontWeight? fontWeight}) {
return Text.rich(_buildInlineSpan(el, tokens, onRecordTap, fontSize: fontSize, fontWeight: fontWeight));
static Widget _inlineText(md.Element el, SurfaceTokens tokens, ClideMarkdownHooks hooks, {double? fontSize, FontWeight? fontWeight}) {
return Text.rich(_buildInlineSpan(el, tokens, hooks, fontSize: fontSize, fontWeight: fontWeight));
}
static Widget _inlineRichText(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {ImageTokenBuilder? onImageToken}) {
return Text.rich(_buildInlineSpan(el, tokens, onRecordTap, onImageToken: onImageToken));
static Widget _inlineRichText(md.Element el, SurfaceTokens tokens, ClideMarkdownHooks hooks) {
return Text.rich(_buildInlineSpan(el, tokens, hooks));
}
static TextSpan _buildInlineSpan(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap,
{double? fontSize, FontWeight? fontWeight, ImageTokenBuilder? onImageToken}) {
static TextSpan _buildInlineSpan(md.Element el, SurfaceTokens tokens, ClideMarkdownHooks hooks, {double? fontSize, FontWeight? fontWeight}) {
final children = <InlineSpan>[];
for (final child in el.children ?? const []) {
if (child is md.Text) {
children.addAll(_linkifyText(_unescapeHtml(child.text), tokens, onRecordTap, onImageToken: onImageToken));
children.addAll(_linkifyText(_unescapeHtml(child.text), tokens, hooks));
} else if (child is md.Element) {
children.add(_inlineElementSpan(child, tokens, onRecordTap, onImageToken: onImageToken));
children.add(_inlineElementSpan(child, tokens, hooks));
}
}
return TextSpan(
@@ -289,17 +312,14 @@ class ClideMarkdown extends StatelessWidget {
);
}
static InlineSpan _inlineElementSpan(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {ImageTokenBuilder? onImageToken}) {
static InlineSpan _inlineElementSpan(md.Element el, SurfaceTokens tokens, ClideMarkdownHooks hooks) {
switch (el.tag) {
case 'strong':
return TextSpan(
style: const TextStyle(fontWeight: FontWeight.w700),
children: [
for (final c in el.children ?? const [])
if (c is md.Text)
..._linkifyText(_unescapeHtml(c.text), tokens, onRecordTap, onImageToken: onImageToken)
else if (c is md.Element)
_inlineElementSpan(c, tokens, onRecordTap, onImageToken: onImageToken)
if (c is md.Text) ..._linkifyText(_unescapeHtml(c.text), tokens, hooks) else if (c is md.Element) _inlineElementSpan(c, tokens, hooks)
],
);
case 'em':
@@ -307,10 +327,7 @@ class ClideMarkdown extends StatelessWidget {
style: const TextStyle(fontStyle: FontStyle.italic),
children: [
for (final c in el.children ?? const [])
if (c is md.Text)
..._linkifyText(_unescapeHtml(c.text), tokens, onRecordTap, onImageToken: onImageToken)
else if (c is md.Element)
_inlineElementSpan(c, tokens, onRecordTap, onImageToken: onImageToken)
if (c is md.Text) ..._linkifyText(_unescapeHtml(c.text), tokens, hooks) else if (c is md.Element) _inlineElementSpan(c, tokens, hooks)
],
);
case 'code':
@@ -320,8 +337,14 @@ class ClideMarkdown extends StatelessWidget {
);
case 'a':
final text = _unescapeHtml(el.textContent);
if (onRecordTap != null && _recordPattern.hasMatch(text)) {
return _recordLinkSpan(text, tokens, onRecordTap);
if (hooks.onRecordTap != null && _recordPattern.hasMatch(text)) {
return _recordLinkSpan(text, tokens, hooks.onRecordTap!);
}
// An http(s) link (explicit or autolinked) → tappable, opens via the
// caller's handler (T-253). Non-http schemes stay coloured-but-inert.
final href = el.attributes['href'];
if (hooks.onLinkTap != null && href != null && _isHttpUrl(href)) {
return _urlLinkSpan(text, href, tokens, hooks.onLinkTap!);
}
return TextSpan(
text: text,
@@ -341,24 +364,24 @@ class ClideMarkdown extends StatelessWidget {
/// image widgets via [onImageToken] (T-236), and bare governance/ticket refs
/// (T-281, D-77, Q-5, R-2) become clickable [_recordLinkSpan]s (T-279). With
/// neither callback (or no match) the text passes through unchanged.
static List<InlineSpan> _linkifyText(String text, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {ImageTokenBuilder? onImageToken}) {
static List<InlineSpan> _linkifyText(String text, SurfaceTokens tokens, ClideMarkdownHooks hooks) {
if (text.isEmpty) return [TextSpan(text: text)];
// Pass 1: pull out image tokens, record-linkifying the prose between them.
if (onImageToken != null) {
if (hooks.onImageToken != null) {
final spans = <InlineSpan>[];
var last = 0;
for (final m in _imageTokenPattern.allMatches(text)) {
if (m.start > last) spans.addAll(_linkifyRecords(text.substring(last, m.start), tokens, onRecordTap));
if (m.start > last) spans.addAll(_linkifyRecords(text.substring(last, m.start), tokens, hooks.onRecordTap));
spans.add(WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 2), child: onImageToken(m.group(1)!)),
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 2), child: hooks.onImageToken!(m.group(1)!)),
));
last = m.end;
}
if (last < text.length) spans.addAll(_linkifyRecords(text.substring(last), tokens, onRecordTap));
if (last < text.length) spans.addAll(_linkifyRecords(text.substring(last), tokens, hooks.onRecordTap));
return spans.isEmpty ? [TextSpan(text: text)] : spans;
}
return _linkifyRecords(text, tokens, onRecordTap);
return _linkifyRecords(text, tokens, hooks.onRecordTap);
}
static List<InlineSpan> _linkifyRecords(String text, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
@@ -397,4 +420,35 @@ class ClideMarkdown extends StatelessWidget {
),
);
}
static bool _isHttpUrl(String s) {
final u = Uri.tryParse(s);
return u != null && (u.scheme == 'http' || u.scheme == 'https') && u.host.isNotEmpty;
}
/// A clickable http(s) link span (T-253): the link [text] in the focus accent,
/// underlined on hover, opening [href] via [onLinkTap]. Keyboard-activatable
/// (ClideTappable) and tooltipped with the destination.
static InlineSpan _urlLinkSpan(String text, String href, SurfaceTokens tokens, LinkTapCallback onLinkTap) {
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: ClideTappable(
onTap: () => onLinkTap(href),
tooltip: href,
builder: (_, hovered, __) => Text(
text,
style: TextStyle(
color: tokens.globalFocus,
fontSize: _fontSize,
height: _lineHeight,
fontFamily: clideUiFamily,
fontFamilyFallback: clideUiFamilyFallback,
decoration: hovered ? TextDecoration.underline : null,
decorationColor: tokens.globalFocus,
),
),
),
);
}
}
+53
View File
@@ -0,0 +1,53 @@
/// Tests for ClideMarkdown link handling (T-253): http(s) links are tappable and
/// hand the URL to the caller; other schemes stay inert.
library;
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
testWidgets('an autolinked bare URL is tappable and opens via onLinkTap', (tester) async {
String? opened;
await tester.pumpWidget(harness(f, ClideMarkdown('see https://example.com/x for more', onLinkTap: (u) => opened = u)));
await tester.pump();
await tester.tap(find.text('https://example.com/x'));
await tester.pump();
expect(opened, 'https://example.com/x');
});
testWidgets('a markdown [text](url) link opens the href, not the text', (tester) async {
String? opened;
await tester.pumpWidget(harness(f, ClideMarkdown('[the docs](https://clide.dev/docs)', onLinkTap: (u) => opened = u)));
await tester.pump();
await tester.tap(find.text('the docs'));
await tester.pump();
expect(opened, 'https://clide.dev/docs');
});
testWidgets('a non-http scheme is not tappable (no handler fired)', (tester) async {
var calls = 0;
await tester.pumpWidget(harness(f, ClideMarkdown('[mail](mailto:a@b.com)', onLinkTap: (_) => calls++)));
await tester.pump();
await tester.tap(find.text('mail'), warnIfMissed: false);
await tester.pump();
expect(calls, 0);
});
testWidgets('with no onLinkTap, a link still renders (inert, no crash)', (tester) async {
await tester.pumpWidget(harness(f, const ClideMarkdown('see https://example.com here')));
await tester.pumpAndSettle();
expect(find.textContaining('https://example.com'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}