diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a2c3e6..e48650e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index a165b66d..fc320c74 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -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, diff --git a/lib/widgets/src/clide_markdown.dart b/lib/widgets/src/clide_markdown.dart index 1b4d9581..904304a4 100644 --- a/lib/widgets/src/clide_markdown.dart +++ b/lib/widgets/src/clide_markdown.dart @@ -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 `@` 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 _buildNodes(List nodes, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {ImageTokenBuilder? onImageToken}) { + static List _buildNodes(List nodes, SurfaceTokens tokens, ClideMarkdownHooks hooks) { final out = []; final inlineRun = []; @@ -82,9 +108,9 @@ class ClideMarkdown extends StatelessWidget { final spans = []; 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() ?? const [], tokens, onRecordTap, onImageToken: onImageToken), + children: _buildNodes(el.children?.cast() ?? 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() ?? const [], tokens, onRecordTap, onImageToken: onImageToken), + children: _buildNodes(el.children?.cast() ?? 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 = []; 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 = []; 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 _linkifyText(String text, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {ImageTokenBuilder? onImageToken}) { + static List _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 = []; 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 _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, + ), + ), + ), + ); + } } diff --git a/test/widgets/src/clide_markdown_test.dart b/test/widgets/src/clide_markdown_test.dart new file mode 100644 index 00000000..67fe8800 --- /dev/null +++ b/test/widgets/src/clide_markdown_test.dart @@ -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); + }); +}