render pasted images inline with lightbox expansion (T-236, T-254, D-89)
A pasted-image @<path> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
`@<path>` 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 `@<path>` 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 `@<path>` 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.
|
||||
|
||||
@@ -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<ClaudeComposer> {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `@<path>` 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<Object>(
|
||||
(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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,13 @@ import 'package:markdown/markdown.dart' as md;
|
||||
|
||||
typedef RecordTapCallback = void Function(String id);
|
||||
|
||||
/// Builds the inline widget for a recognised `@<path>` 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 `@<path>` 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'(?<![^\s])@(\S+\.(?:png|jpe?g|gif|webp|bmp))', caseSensitive: false);
|
||||
|
||||
final String source;
|
||||
final RecordTapCallback? onRecordTap;
|
||||
final ImageTokenBuilder? onImageToken;
|
||||
|
||||
static String _unescapeHtml(String s) {
|
||||
return s
|
||||
@@ -45,7 +57,7 @@ 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);
|
||||
final widgets = _buildNodes(nodes, tokens, onRecordTap, onImageToken: onImageToken);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -61,7 +73,7 @@ class ClideMarkdown extends StatelessWidget {
|
||||
return false;
|
||||
}
|
||||
|
||||
static List<Widget> _buildNodes(List<md.Node> nodes, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
|
||||
static List<Widget> _buildNodes(List<md.Node> nodes, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {ImageTokenBuilder? onImageToken}) {
|
||||
final out = <Widget>[];
|
||||
final inlineRun = <md.Node>[];
|
||||
|
||||
@@ -70,9 +82,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));
|
||||
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<md.Node>() ?? const [], tokens, onRecordTap),
|
||||
children: _buildNodes(el.children?.cast<md.Node>() ?? 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<md.Node>() ?? const [], tokens, onRecordTap),
|
||||
children: _buildNodes(el.children?.cast<md.Node>() ?? 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 = <InlineSpan>[];
|
||||
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<InlineSpan> _linkifyText(String text, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
|
||||
/// Splits plain [text] into spans: pasted-image `@<path>` 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<InlineSpan> _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 = <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));
|
||||
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<InlineSpan> _linkifyRecords(String text, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
|
||||
if (onRecordTap == null || text.isEmpty) return [TextSpan(text: text)];
|
||||
final spans = <InlineSpan>[];
|
||||
var last = 0;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user