From 01c3de37e141384ff601096fb51f27084ec9dc1d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 12 Jun 2026 01:18:41 +0200 Subject: [PATCH] render markdown hard breaks and image placeholders (T-379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both node types fell through the inline-span switch to an empty textContent span: words on either side of a hard break glued together, and images vanished with no trace. A br now emits a newline; an img renders a muted italic "[image: alt]" placeholder (falling back to the src) — no inline network loading in the owned renderer; live-pane images keep going through clide image show. Co-Authored-By: Claude Fable 5 --- .pql/changelog/ticket_history/2026-06.sql | 2 ++ .pql/changelog/tickets/2026-06.sql | 10 ++++++++++ CHANGELOG.md | 5 +++++ lib/widgets/src/clide_markdown.dart | 16 ++++++++++++++++ test/widgets/src/clide_markdown_test.dart | 22 ++++++++++++++++++++++ 5 files changed, 55 insertions(+) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 081529fb..1315572b 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -4007,3 +4007,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:13:18', '2026-06-11 23:13:18', '2026-06-11 23:13:18', NULL, '5382c848b654d1a93daed15a28d652cf', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCC6AR37VTF4SY8DR99JHC', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:13:49', '2026-06-11 23:13:49', '2026-06-11 23:13:49', NULL, '0edb800f29a8851306f7d21fb546d342', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCC6AR37VTF4SY8DR99JHC', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:16:28', '2026-06-11 23:16:28', '2026-06-11 23:16:28', NULL, '2fe291251d1a4789c8d2b75a2c32396f', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCJEYHC91PMVNVWVHBR2RG', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:16:53', '2026-06-11 23:16:53', '2026-06-11 23:16:53', NULL, 'e6c0cb3a2fce6e257e054f5b3e209814', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCJEYHC91PMVNVWVHBR2RG', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:18:22', '2026-06-11 23:18:22', '2026-06-11 23:18:22', NULL, '22809d0fac7e1aa116e682c18dae7455', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 6f553ab7..636fb1bd 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -4139,3 +4139,13 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat Fix: serialize with a real encoder (JSON/YAML emitter, whatever the file format is) covering nested structures; write to a temp file + rename for atomicity; on parse failure keep the original file (e.g. move aside as .broken) and log via the kernel Logger instead of resetting. Acceptance: round-trip test for a keymap overlay (list of maps) across save/load; simulated partial write leaves previous settings intact; corrupt file does not silently reset and produces a logged diagnostic.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:22', '2026-06-11 23:16:28', NULL, 'e23aad60b9f75e50cb305e727254e094', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCJEYHC91PMVNVWVHBR2RG', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Markdown renderer: hard breaks and images render as empty text', 'lib/widgets/src/clide_markdown.dart:408-410 — hard line breaks and image nodes both fall through to empty text spans: words on either side of a hard break glue together, and images vanish entirely (no placeholder, no alt text). + +Fix: emit a newline span for hard breaks; render images as at least an alt-text placeholder chip (full image rendering can be a follow-up — note the existing feedback that live-pane images go through clide image show). + +Acceptance: golden/widget test for hard-break line splitting; image node renders alt text; no regression in existing markdown goldens.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:13', '2026-06-11 23:16:53', NULL, 'e3bf2108898cf82d75be7a0da17443ca', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCJEYHC91PMVNVWVHBR2RG', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Markdown renderer: hard breaks and images render as empty text', 'lib/widgets/src/clide_markdown.dart:408-410 — hard line breaks and image nodes both fall through to empty text spans: words on either side of a hard break glue together, and images vanish entirely (no placeholder, no alt text). + +Fix: emit a newline span for hard breaks; render images as at least an alt-text placeholder chip (full image rendering can be a follow-up — note the existing feedback that live-pane images go through clide image show). + +Acceptance: golden/widget test for hard-break line splitting; image node renders alt text; no regression in existing markdown goldens.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:13', '2026-06-11 23:18:22', NULL, '5ec0ffc2676c2f31476249301ae2869b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index b13f95a4..c3389805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. preserved as `.broken` with a logged warning instead of being silently reset. (T-376) +- **Markdown hard breaks break lines and images leave a visible trace.** + Both rendered as empty text — words on either side of a hard break glued + together and images vanished; breaks now emit a newline and images render + an italic `[image: alt]` placeholder. (T-379) + - **Accepting ExitPlanMode now leaves plan mode in the conversation panel.** Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying session out of plan mode, but clide's tracked permission mode didn't follow, diff --git a/lib/widgets/src/clide_markdown.dart b/lib/widgets/src/clide_markdown.dart index 7cf94b41..990f765e 100644 --- a/lib/widgets/src/clide_markdown.dart +++ b/lib/widgets/src/clide_markdown.dart @@ -405,6 +405,22 @@ class ClideMarkdown extends StatelessWidget { text: _unescapeHtml(el.textContent), style: TextStyle(decoration: TextDecoration.lineThrough, color: tokens.globalTextMuted), ); + case 'br': + // A hard break has no textContent — the default branch rendered it + // as an empty span and glued the surrounding words together (T-379). + return const TextSpan(text: '\n'); + case 'img': + // No inline image loading (network fetch in a text span is not the + // owned-renderer way; live-pane images go through `clide image + // show`) — render a visible alt-text placeholder instead of + // disappearing (T-379). + final alt = _unescapeHtml(el.attributes['alt'] ?? ''); + final src = el.attributes['src'] ?? ''; + final label = alt.isNotEmpty ? alt : src; + return TextSpan( + text: label.isEmpty ? '[image]' : '[image: $label]', + style: TextStyle(color: tokens.globalTextMuted, fontStyle: FontStyle.italic), + ); default: return TextSpan(text: _unescapeHtml(el.textContent)); } diff --git a/test/widgets/src/clide_markdown_test.dart b/test/widgets/src/clide_markdown_test.dart index 2846ef88..c1fc8c65 100644 --- a/test/widgets/src/clide_markdown_test.dart +++ b/test/widgets/src/clide_markdown_test.dart @@ -49,4 +49,26 @@ void main() { expect(find.textContaining('https://example.com'), findsOneWidget); expect(tester.takeException(), isNull); }); + + // T-379: hard breaks and images fell through to empty text spans — + // words glued together, images vanished without a trace. + testWidgets('a hard line break splits the line instead of gluing words', (tester) async { + // Two trailing spaces = a markdown hard break. + await tester.pumpWidget(harness(f, const ClideMarkdown('alpha \nbeta'))); + await tester.pump(); + expect(find.textContaining('alpha\nbeta'), findsOneWidget); + expect(find.textContaining('alphabeta'), findsNothing); + }); + + testWidgets('an image renders its alt text as a visible placeholder', (tester) async { + await tester.pumpWidget(harness(f, const ClideMarkdown('before ![a diagram](http://x/y.png) after'))); + await tester.pump(); + expect(find.textContaining('[image: a diagram]'), findsOneWidget); + }); + + testWidgets('an image with no alt text falls back to its source', (tester) async { + await tester.pumpWidget(harness(f, const ClideMarkdown('![](http://x/y.png)'))); + await tester.pump(); + expect(find.textContaining('[image: http://x/y.png]'), findsOneWidget); + }); }