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:
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user