render markdown hard breaks and image placeholders (T-379)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 01:18:41 +02:00
co-authored by Claude Fable 5
parent e413380ea9
commit 01c3de37e1
5 changed files with 55 additions and 0 deletions
@@ -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;
+10
View File
@@ -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);
+5
View File
@@ -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,
+16
View File
@@ -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));
}
+22
View File
@@ -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);
});
}