From 529f40996d6e2e41c0383ee3a2bd3a7bfceb1779 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 9 Jun 2026 17:54:55 +0200 Subject: [PATCH] render pasted images inline with lightbox expansion (T-236, T-254, D-89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pasted-image @ token now renders as an inline, keyboard-activatable thumbnail in the Claude conversation that opens the full image in the lightbox; the composer's attachment chips use the same (larger, 44px) thumbnail. New ImageThumbnail + openImageLightbox in the Claude layer; ClideMarkdown gains an onImageToken builder seam (mirroring onRecordTap) that drops a WidgetSpan into the text flow — it owns no Image.file/lightbox, staying generic. Missing files degrade to a placeholder; render-only (the sent text + copyText are unchanged). Resolves the conflicting T-236 (inline thumbnail) / T-254 (image card) designs into the hybrid the user chose; recorded as D-89. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++ governance/README.md | 1 + governance/decisions/design.md | 9 ++ lib/builtin/claude/src/claude_composer.dart | 17 +--- lib/builtin/claude/src/conversation_view.dart | 9 +- lib/builtin/claude/src/image_thumbnail.dart | 80 ++++++++++++++++ lib/widgets/src/clide_markdown.dart | 94 ++++++++++++++----- .../claude/conversation_view_test.dart | 18 ++++ test/builtin/claude/image_thumbnail_test.dart | 42 +++++++++ 9 files changed, 237 insertions(+), 39 deletions(-) create mode 100644 lib/builtin/claude/src/image_thumbnail.dart create mode 100644 test/builtin/claude/image_thumbnail_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b5613964..3256fc1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Pasted images render inline in the Claude conversation.** A pasted-image + `@` reference now shows as a bounded thumbnail in the message instead of + the raw path; clicking it (or pressing Enter when focused) opens the full + image in the lightbox. The composer's attachment previews use the same, + larger thumbnail. A missing file degrades to a placeholder; the text sent to + Claude is unchanged. (T-236, T-254, D-89) - **The editor honours `.editorconfig`.** Opening a file resolves the workspace rules into a source-agnostic `EditorSettings` (own INI parser + glob matcher, `root`/nearest-wins precedence — no new dependency). The editor diff --git a/governance/README.md b/governance/README.md index e3e2a587..86c9c1a9 100644 --- a/governance/README.md +++ b/governance/README.md @@ -130,6 +130,7 @@ You might also want, project-permitting: - [D-86: MCP tool surface — full clide namespace generated from the co-registered command registry](decisions/architecture.md#d-86-mcp-tool-surface--full-clide-namespace-generated-from-the-co-registered-command-registry) — _architecture_ - [D-87: Output/log dock — bottom, toggled, read-only (logs + problems)](decisions/architecture.md#d-87-outputlog-dock--bottom-toggled-read-only-logs--problems) — _architecture_ - [D-88: clide-owned anchored popover + menu primitive](decisions/design.md#d-88-clide-owned-anchored-popover--menu-primitive) — _design_ +- [D-89: inline pasted-image thumbnails that expand to the lightbox](decisions/design.md#d-89-inline-pasted-image-thumbnails-that-expand-to-the-lightbox) — _design_ ## Open questions diff --git a/governance/decisions/design.md b/governance/decisions/design.md index 3373859c..34929dfe 100644 --- a/governance/decisions/design.md +++ b/governance/decisions/design.md @@ -5,6 +5,15 @@ widget primitives. --- +### D-89: inline pasted-image thumbnails that expand to the lightbox +- **Date:** 2026-06-09 +- **Decision:** A pasted-image reference (the composer's `@` token) renders **inline** in the message prose as a bounded, keyboard-activatable thumbnail; activating it opens the full image in the shared lightbox (T-252). The same `ImageThumbnail` is used in the composer's attachment chips. The renderer (`ClideMarkdown`) only locates `@` image tokens and drops a caller-built widget into the text flow via a `WidgetSpan` — it owns no `Image.file`/lightbox. Display-only: the text sent to Claude and the card's `copyText` are unchanged. +- **Rationale:** Two conflicting designs were filed — T-236 (inline thumbnail, in place of the token) and T-254 (a separate image-viewer **card** with a path caption). The user resolved the conflict toward a hybrid: *inline* (a separate card "breaks context") **plus** the lightbox expansion. Reusing the existing lightbox keeps presentation consistent and avoids a second image surface; keeping `Image.file`/lightbox out of `ClideMarkdown` keeps the generic renderer decoupled (it gained only an `onImageToken` builder, mirroring the existing `onRecordTap` seam). +- **Cost:** `ClideMarkdown` threads one optional builder through its inline chain; the Claude layer owns `ImageThumbnail` + the token→widget wiring. Mid-sentence tokens render the thumbnail at the token's position; in practice the composer appends tokens, so they trail the prose. +- **Supersedes:** the either/or framing of **T-236** vs **T-254** — both are satisfied by this one design (neither rejected). Raised by the user while triaging the two as "conflicting designs." + +--- + ### D-88: clide-owned anchored popover + menu primitive - **Date:** 2026-06-08 - **Decision:** Anchored, non-modal popovers (dropdowns, pickers, typeaheads, the command palette) build on two clide-owned primitives in `lib/widgets/`: `ClideAnchoredOverlay` (positioning + lifecycle — a `LayerLink`/`CompositedTransformFollower` or centred `Positioned`, a full-screen tap-away barrier, `OverlayEntry` bookkeeping, focus capture, Esc-to-close, and auto-flip on viewport bounds) and `ClideMenu` + `ClideMenuListController` (a dropdown-token row surface with arrow/enter/escape nav, skip-disabled/separator, active mark, and a reusable nav controller for surfaces that keep bespoke rows). No Material/Cupertino. Modal, centred dialogs (session / project / branch pickers) stay on the kernel `DialogRouter` — a separate concern. diff --git a/lib/builtin/claude/src/claude_composer.dart b/lib/builtin/claude/src/claude_composer.dart index 9135fb7e..c2a9b6ad 100644 --- a/lib/builtin/claude/src/claude_composer.dart +++ b/lib/builtin/claude/src/claude_composer.dart @@ -9,10 +9,10 @@ library; import 'dart:async'; -import 'dart:io'; import 'package:clide/builtin/claude/src/claude_config.dart'; import 'package:clide/builtin/claude/src/clipboard_paste.dart'; +import 'package:clide/builtin/claude/src/image_thumbnail.dart'; import 'package:clide/builtin/claude/src/permission_mode_control.dart'; import 'package:clide/builtin/claude/src/running_indicator.dart'; import 'package:clide/builtin/claude/src/slash_commands.dart'; @@ -574,18 +574,11 @@ class _ClaudeComposerState extends State { } Widget _chipLeading(SurfaceTokens theme, ComposerAttachment a) { - const dim = 28.0; + // A readable preview (not the old 28px speck) that opens the full image in + // the lightbox on click — the same thumbnail the conversation log uses + // (T-236/T-254). if (a.isImage) { - return ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image.file( - File(a.path), - width: dim, - height: dim, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => ClideIcon(PhosphorIcons.image, size: 18, color: theme.globalTextMuted), - ), - ); + return ImageThumbnail(path: a.path, size: 44); } return ClideIcon(PhosphorIcons.fileText, size: 18, color: theme.globalTextMuted); } diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index 4c4fe560..ceb9222f 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -16,6 +16,7 @@ import 'package:clide/builtin/claude/src/activity_cluster.dart'; import 'package:clide/builtin/claude/src/conversation_card.dart'; import 'package:clide/builtin/claude/src/conversation_controller.dart'; import 'package:clide/builtin/claude/src/holder_card.dart'; +import 'package:clide/builtin/claude/src/image_thumbnail.dart'; import 'package:clide/builtin/claude/src/prompt_card.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:clide/kernel/src/facade.dart'; @@ -367,7 +368,13 @@ class _ConversationTurn extends StatelessWidget { accent: tokens.globalFocus, label: 'you', copyText: i.text, - body: ClideMarkdown(i.text, onRecordTap: (id) => _openRecord(context, id)), + // Pasted-image @path tokens render as inline thumbnails that open the + // lightbox (T-236/T-254); copyText keeps the original text verbatim. + body: ClideMarkdown( + i.text, + onRecordTap: (id) => _openRecord(context, id), + onImageToken: (path) => ImageThumbnail(path: path, size: 48), + ), ), // Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the // agent with a muted accent, never the coral "claude" brand (T-265). The diff --git a/lib/builtin/claude/src/image_thumbnail.dart b/lib/builtin/claude/src/image_thumbnail.dart new file mode 100644 index 00000000..00da2980 --- /dev/null +++ b/lib/builtin/claude/src/image_thumbnail.dart @@ -0,0 +1,80 @@ +/// Inline image preview for pasted-image references (T-236 / T-254). +/// +/// A bounded, keyboard-activatable thumbnail that opens the full image in the +/// shared lightbox (T-252) on tap. Used both for `@` image tokens in the +/// conversation log and for the composer's attachment chips, so the two stay +/// visually consistent. Reads the file directly via `Image.file` (dart:io) — +/// pasted temp files live outside the workspace, so this is not gated by the +/// `files.read` allow-list (D-80); it's display-only. A missing/unreadable file +/// degrades to a muted placeholder instead of throwing. +library; + +import 'dart:io'; + +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +/// Open [path] full-size in the lightbox via the kernel dialog router. +void openImageLightbox(BuildContext context, String path) { + ClideKernel.of(context).dialog.show( + (ctx, dismiss) => ClideLightbox( + onDismiss: dismiss, + child: Image.file( + File(path), + fit: BoxFit.contain, + errorBuilder: (ctx, _, __) => _placeholder(ctx, 48), + ), + ), + ); +} + +Widget _placeholder(BuildContext context, double size) { + final t = ClideTheme.of(context).surface; + return Container( + width: size, + height: size, + color: t.panelBackground, + alignment: Alignment.center, + child: ClideIcon(PhosphorIcons.image, size: size * 0.45, color: t.globalTextMuted), + ); +} + +class ImageThumbnail extends StatelessWidget { + const ImageThumbnail({super.key, required this.path, this.size = 56, this.radius = 4}); + + final String path; + final double size; + final double radius; + + String get _fileName => path.split('/').where((s) => s.isNotEmpty).lastOrNull ?? path; + + @override + Widget build(BuildContext context) { + final t = ClideTheme.of(context).surface; + return Semantics( + button: true, + label: 'Image $_fileName', + excludeSemantics: true, + child: ClideTappable( + onTap: () => openImageLightbox(context, path), + builder: (ctx, hovered, pressed) => DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: hovered ? t.globalFocus : t.globalBorder), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: Image.file( + File(path), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (ctx, _, __) => _placeholder(ctx, size), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/src/clide_markdown.dart b/lib/widgets/src/clide_markdown.dart index 550fbf3d..1b4d9581 100644 --- a/lib/widgets/src/clide_markdown.dart +++ b/lib/widgets/src/clide_markdown.dart @@ -10,8 +10,13 @@ import 'package:markdown/markdown.dart' as md; typedef RecordTapCallback = void Function(String id); +/// Builds the inline widget for a recognised `@` image token (T-236). The +/// caller owns how the image renders (thumbnail, tap-to-lightbox); the renderer +/// only locates the tokens and drops the widget into the text flow. +typedef ImageTokenBuilder = Widget Function(String path); + class ClideMarkdown extends StatelessWidget { - const ClideMarkdown(this.source, {super.key, this.onRecordTap}); + const ClideMarkdown(this.source, {super.key, this.onRecordTap, this.onImageToken}); static const double _fontSize = 16; static const double _lineHeight = clideLineHeight; @@ -27,8 +32,15 @@ class ClideMarkdown extends StatelessWidget { /// refs inside code stay plain. static final _bareRecordPattern = RegExp(r'\b[DQRT]-\d+\b'); + /// A pasted-image `@` token in running text (T-236): an `@` at a token + /// boundary followed by a path ending in an image extension. The lookbehind + /// keeps `foo@bar.png` (email-ish, mid-word) literal — only start-of-string or + /// a preceding space qualifies, which is how the composer emits them. + static final _imageTokenPattern = RegExp(r'(? _buildNodes(List nodes, SurfaceTokens tokens, RecordTapCallback? onRecordTap) { + static List _buildNodes(List nodes, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {ImageTokenBuilder? onImageToken}) { final out = []; final inlineRun = []; @@ -70,9 +82,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)); + spans.addAll(_linkifyText(_unescapeHtml(n.text), tokens, onRecordTap, onImageToken: onImageToken)); } else if (n is md.Element) { - spans.add(_inlineElementSpan(n, tokens, onRecordTap)); + spans.add(_inlineElementSpan(n, tokens, onRecordTap, onImageToken: onImageToken)); } } out.add(Text.rich( @@ -97,7 +109,7 @@ class ClideMarkdown extends StatelessWidget { } else { flushInline(); if (node is md.Element) { - out.add(_buildElement(node, tokens, onRecordTap)); + out.add(_buildElement(node, tokens, onRecordTap, onImageToken: onImageToken)); } } } @@ -105,7 +117,7 @@ class ClideMarkdown extends StatelessWidget { return out; } - static Widget _buildElement(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap) { + static Widget _buildElement(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {ImageTokenBuilder? onImageToken}) { switch (el.tag) { case 'h1': return Padding( @@ -132,7 +144,7 @@ class ClideMarkdown extends StatelessWidget { case 'p': return Padding( padding: const EdgeInsets.only(bottom: 14), - child: _inlineRichText(el, tokens, onRecordTap), + child: _inlineRichText(el, tokens, onRecordTap, onImageToken: onImageToken), ); case 'ul': return Padding( @@ -142,7 +154,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) + if (c is md.Element) _buildListItem(c, tokens, onRecordTap, ordered: false, onImageToken: onImageToken) ], ), ); @@ -154,7 +166,8 @@ 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), + if (el.children![i] is md.Element) + _buildListItem(el.children![i] as md.Element, tokens, onRecordTap, ordered: true, index: i + 1, onImageToken: onImageToken), ], ), ); @@ -166,7 +179,7 @@ class ClideMarkdown extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, - children: _buildNodes(el.children?.cast() ?? const [], tokens, onRecordTap), + children: _buildNodes(el.children?.cast() ?? const [], tokens, onRecordTap, onImageToken: onImageToken), ), ); case 'pre': @@ -189,11 +202,12 @@ class ClideMarkdown extends StatelessWidget { child: _buildTable(el, tokens, onRecordTap), ); default: - return _inlineRichText(el, tokens, onRecordTap); + return _inlineRichText(el, tokens, onRecordTap, onImageToken: onImageToken); } } - static Widget _buildListItem(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {bool ordered = false, int index = 1}) { + static Widget _buildListItem(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, + {bool ordered = false, int index = 1, ImageTokenBuilder? onImageToken}) { final bullet = ordered ? '$index. ' : '• '; return Padding( padding: const EdgeInsets.only(bottom: 10), @@ -205,7 +219,7 @@ class ClideMarkdown extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, - children: _buildNodes(el.children?.cast() ?? const [], tokens, onRecordTap), + children: _buildNodes(el.children?.cast() ?? const [], tokens, onRecordTap, onImageToken: onImageToken), ), ), ], @@ -248,17 +262,18 @@ class ClideMarkdown extends StatelessWidget { return Text.rich(_buildInlineSpan(el, tokens, onRecordTap, fontSize: fontSize, fontWeight: fontWeight)); } - static Widget _inlineRichText(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap) { - return Text.rich(_buildInlineSpan(el, tokens, onRecordTap)); + static Widget _inlineRichText(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {ImageTokenBuilder? onImageToken}) { + return Text.rich(_buildInlineSpan(el, tokens, onRecordTap, onImageToken: onImageToken)); } - static TextSpan _buildInlineSpan(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {double? fontSize, FontWeight? fontWeight}) { + static TextSpan _buildInlineSpan(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, + {double? fontSize, FontWeight? fontWeight, ImageTokenBuilder? onImageToken}) { final children = []; for (final child in el.children ?? const []) { if (child is md.Text) { - children.addAll(_linkifyText(_unescapeHtml(child.text), tokens, onRecordTap)); + children.addAll(_linkifyText(_unescapeHtml(child.text), tokens, onRecordTap, onImageToken: onImageToken)); } else if (child is md.Element) { - children.add(_inlineElementSpan(child, tokens, onRecordTap)); + children.add(_inlineElementSpan(child, tokens, onRecordTap, onImageToken: onImageToken)); } } return TextSpan( @@ -274,14 +289,17 @@ class ClideMarkdown extends StatelessWidget { ); } - static InlineSpan _inlineElementSpan(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap) { + static InlineSpan _inlineElementSpan(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {ImageTokenBuilder? onImageToken}) { 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) else if (c is md.Element) _inlineElementSpan(c, tokens, onRecordTap) + 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) ], ); case 'em': @@ -289,7 +307,10 @@ 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) else if (c is md.Element) _inlineElementSpan(c, tokens, onRecordTap) + 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) ], ); case 'code': @@ -316,10 +337,31 @@ class ClideMarkdown extends StatelessWidget { } } - /// Splits plain [text] into spans, turning bare governance/ticket refs - /// (T-281, D-77, Q-5, R-2) into clickable [_recordLinkSpan]s (T-279). With no - /// [onRecordTap] (or no match) the text passes through unchanged. - static List _linkifyText(String text, SurfaceTokens tokens, RecordTapCallback? onRecordTap) { + /// Splits plain [text] into spans: pasted-image `@` tokens become inline + /// 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}) { + if (text.isEmpty) return [TextSpan(text: text)]; + // Pass 1: pull out image tokens, record-linkifying the prose between them. + if (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)); + spans.add(WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding(padding: const EdgeInsets.symmetric(horizontal: 2), child: onImageToken(m.group(1)!)), + )); + last = m.end; + } + if (last < text.length) spans.addAll(_linkifyRecords(text.substring(last), tokens, onRecordTap)); + return spans.isEmpty ? [TextSpan(text: text)] : spans; + } + return _linkifyRecords(text, tokens, onRecordTap); + } + + static List _linkifyRecords(String text, SurfaceTokens tokens, RecordTapCallback? onRecordTap) { if (onRecordTap == null || text.isEmpty) return [TextSpan(text: text)]; final spans = []; var last = 0; diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index 20f338d6..e316d46e 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -10,6 +10,7 @@ import 'package:clide/builtin/claude/src/activity_cluster.dart'; import 'package:clide/builtin/claude/src/claude_banner.dart'; import 'package:clide/builtin/claude/src/conversation_controller.dart'; import 'package:clide/builtin/claude/src/conversation_view.dart'; +import 'package:clide/builtin/claude/src/image_thumbnail.dart'; import 'package:clide/builtin/claude/src/transcript_publisher.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:clide/kernel/src/events/message_bus.dart'; @@ -229,6 +230,23 @@ void main() { expect(opened!.data['id'], 'D-77'); }); + testWidgets('a pasted-image @path token renders an inline thumbnail (T-236/T-254)', (tester) async { + await pumpWith(tester, [_user('look at this @/tmp/clide/paste-1.png please')]); + expect(find.byType(ImageThumbnail), findsOneWidget); + // The prose around the token still renders. + expect(find.textContaining('look at this'), findsWidgets); + }); + + testWidgets('multiple image tokens each render a thumbnail (T-236)', (tester) async { + await pumpWith(tester, [_user('@/tmp/a.png and @/tmp/b.jpg')]); + expect(find.byType(ImageThumbnail), findsNWidgets(2)); + }); + + testWidgets('a non-image @path stays literal text (T-236)', (tester) async { + await pumpWith(tester, [_user('see @/tmp/notes.txt for details')]); + expect(find.byType(ImageThumbnail), findsNothing); + }); + testWidgets('meta items fold into a collapsed activity card; tap expands (T-230)', (tester) async { await pumpWith( tester, diff --git a/test/builtin/claude/image_thumbnail_test.dart b/test/builtin/claude/image_thumbnail_test.dart new file mode 100644 index 00000000..1e696dbe --- /dev/null +++ b/test/builtin/claude/image_thumbnail_test.dart @@ -0,0 +1,42 @@ +/// Tests for ImageThumbnail (T-236 / T-254): a bounded, keyboard-activatable +/// image preview that opens the lightbox and degrades gracefully. +library; + +import 'package:clide/builtin/claude/src/image_thumbnail.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() { + group('ImageThumbnail', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + testWidgets('carries an a11y label from the file name', (tester) async { + await tester.pumpWidget(harness(f, const ImageThumbnail(path: '/tmp/clide/paste-9.png'))); + await tester.pump(); + expect(find.bySemanticsLabel('Image paste-9.png'), findsOneWidget); + }); + + testWidgets('a missing file degrades to a placeholder without throwing', (tester) async { + await tester.pumpWidget(harness(f, const ImageThumbnail(path: '/no/such/file.png'))); + await tester.pumpAndSettle(); + expect(find.byType(ImageThumbnail), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('tapping opens the lightbox via the dialog router', (tester) async { + await tester.pumpWidget(harness(f, const ImageThumbnail(path: '/no/such/file.png'))); + await tester.pump(); + expect(f.services.dialog.isOpen, isFalse); + + await tester.tap(find.byType(ImageThumbnail)); + await tester.pump(); + + expect(f.services.dialog.isOpen, isTrue); + }); + }); +}