diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 4164aaed..6d346695 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -3118,3 +3118,4 @@ Both panes use the existing `MultitabPane` (T-83) for tab management once they s MERGED (2026-06-10) into the unified canvas epic T-317 (decision D-91). The canvas rendering work consolidates onto one clide-owned canvas renderer driven by JSON; .canvas becomes an import format converted into that JSON rather than a native schema. T-7''s scope is preserved as children of T-317: canvas pane -> T-322, graph view -> T-323. This epic is cancelled as superseded; track the work under T-317.', NULL, '2026-06-10 11:17:31', '2026-06-10 11:17:31', '2026-06-10 11:17:31', NULL, 'e7d70c5b631bd6109f1b83b55c857c20', 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 ('06FB0TNQM67JSC5RKS6M9182KG', 'parent_id', 'T-7', 'T-276', NULL, '2026-06-10 11:18:12', '2026-06-10 11:18:12', '2026-06-10 11:18:12', NULL, 'df23887d88f2a45e8ad12a72a4b27407', 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 ('06FB0TNQM7162Z26FDWXG35VH0', 'status', 'backlog', 'cancelled', NULL, '2026-06-10 11:18:15', '2026-06-10 11:18:15', '2026-06-10 11:18:15', NULL, '64906dcc15f8f1b32034640a7e9aab82', 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 ('06FB1ZFJK6J2GSV4SA69QF730C', 'status', 'ready', 'done', NULL, '2026-06-10 11:25:58', '2026-06-10 11:25:58', '2026-06-10 11:25:58', NULL, '678a32ea96db32f51581309dc2af1b6d', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index d3cfd75b..53c64ab9 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -2057,3 +2057,14 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat Both panes use the existing `MultitabPane` (T-83) for tab management once they ship. Each lives in its own slot per D-47 (canvas in workspace, graph in context panel). MERGED (2026-06-10) into the unified canvas epic T-317 (decision D-91). The canvas rendering work consolidates onto one clide-owned canvas renderer driven by JSON; .canvas becomes an import format converted into that JSON rather than a native schema. T-7''s scope is preserved as children of T-317: canvas pane -> T-322, graph view -> T-323. This epic is cancelled as superseded; track the work under T-317.', 'cancelled', 'medium', NULL, NULL, 'D-17', '2026-04-22 11:45:41', '2026-06-10 11:18:15', NULL, '3e558a94afc3e675e673c6e9f9390855', 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 ('06FB1ZFJK6J2GSV4SA69QF730C', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Image card caches by path — overwritten file shows stale render', 'clide image show (and the conversation image viewer card) appears to cache by file path: re-showing a file after its bytes change at the SAME path renders the previous image, not the new one. Hit live while iterating on a Frame0 wireframe (re-exported settings-modal.png in place; the pane kept showing the prior version until exported under a new filename). + +Expected: showing a path always reflects the current file bytes. + +Explore / options: +- Cache-key on mtime + size (or a content hash) instead of path alone; invalidate when they change. +- A force-refresh path for clide image show (re-decode, bypass cache). +- Check where the cache lives: the image card widget (Image.file / ImageProvider cache — Flutter''s imageCache keys by path+scale and won''t refresh on overwrite), the IPC image.show handler, or the pane render layer. +- Likely Flutter imageCache: evict the key (or use a FileImage with a key that includes mtime) before showing. + +Note: surfaced via clide image show (T-249 / T-252 lightbox share the same provider). Repro: export an image, show it, overwrite the same path with different content, show again -> stale.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 10:04:53', '2026-06-10 11:25:58', NULL, 'e0db833047273042eaa96ba6b54bed8d', 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 d480e1b8..7b439324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -256,6 +256,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- **Re-showing an image after it changes on disk now refreshes.** Image cards, + thumbnails, the lightbox, and `clide image show` keyed Flutter's image cache by + path alone, so overwriting a file in place (e.g. re-exporting a wireframe PNG) + kept showing the stale render. A new `ClideFileImage` folds the file's mtime + + size into the cache key, so an in-place change re-decodes. (T-312) - **The `context` / `thinking` / agent-prompt blocks are now carded like the rest.** These muted meta blocks used to render frameless, reading as unfinished `> context …` rows wedged between the framed tool cards. They now diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index a422b3e2..9dd4dad8 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -11,7 +11,6 @@ library; import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:clide/builtin/claude/src/activity_cluster.dart'; import 'package:clide/builtin/claude/src/conversation_card.dart'; @@ -499,8 +498,8 @@ class _ConversationTurn extends StatelessWidget { borderRadius: BorderRadius.circular(4), child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 360), - child: Image.file( - File(m.path), + child: Image( + image: ClideFileImage(m.path), fit: BoxFit.contain, alignment: Alignment.centerLeft, errorBuilder: (_, __, ___) => _imagePlaceholder(m.path), @@ -522,8 +521,8 @@ class _ConversationTurn extends StatelessWidget { ClideKernel.of(context).dialog.show( (ctx, dismiss) => ClideLightbox( onDismiss: dismiss, - child: Image.file( - File(path), + child: Image( + image: ClideFileImage(path), fit: BoxFit.contain, errorBuilder: (_, __, ___) => _imagePlaceholder(path), ), diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index b990423c..26c841f6 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -378,7 +378,7 @@ class ClaudeExtension extends ClideExtension { _ctx?.dialog.show( (c, dismiss) => ClideLightbox( onDismiss: dismiss, - child: Image.file(File(path), fit: BoxFit.contain), + child: Image(image: ClideFileImage(path), fit: BoxFit.contain), ), ); return; diff --git a/lib/builtin/claude/src/image_thumbnail.dart b/lib/builtin/claude/src/image_thumbnail.dart index 1261e033..1cfa6d87 100644 --- a/lib/builtin/claude/src/image_thumbnail.dart +++ b/lib/builtin/claude/src/image_thumbnail.dart @@ -3,14 +3,13 @@ /// 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. +/// visually consistent. Reads the file directly via [ClideFileImage] (which +/// re-decodes when the file changes in place, T-312) — 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'; @@ -20,8 +19,8 @@ void openImageLightbox(BuildContext context, String path) { ClideKernel.of(context).dialog.show( (ctx, dismiss) => ClideLightbox( onDismiss: dismiss, - child: Image.file( - File(path), + child: Image( + image: ClideFileImage(path), fit: BoxFit.contain, errorBuilder: (ctx, _, __) => _placeholder(ctx, 48), ), @@ -65,8 +64,8 @@ class ImageThumbnail extends StatelessWidget { ), child: ClipRRect( borderRadius: BorderRadius.circular(radius), - child: Image.file( - File(path), + child: Image( + image: ClideFileImage(path), width: size, height: size, fit: BoxFit.cover, diff --git a/lib/widgets/src/clide_file_image.dart b/lib/widgets/src/clide_file_image.dart new file mode 100644 index 00000000..aeae00e2 --- /dev/null +++ b/lib/widgets/src/clide_file_image.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:flutter/widgets.dart'; + +/// A [FileImage] whose cache key also folds in the file's modification time and +/// size — so re-showing a path whose bytes changed *in place* re-decodes +/// instead of returning Flutter's stale cached frame (T-312). +/// +/// Plain `Image.file` / `FileImage` key the global `imageCache` by `(path, +/// scale)` only, so overwriting a file at the same path is invisible: the cache +/// hands back the previously decoded image. Hit live when re-exporting a +/// wireframe PNG in place kept showing the prior render. Folding mtime + size +/// into `==`/`hashCode` makes an overwrite a fresh key → a cache miss → a +/// re-read of the current bytes; an unchanged file still hits the cache. +class ClideFileImage extends FileImage { + ClideFileImage(String path, {double scale = 1.0}) + : _stamp = _stampOf(path), + super(File(path), scale: scale); + + /// mtime ⊕ size — changes on any in-place overwrite (a write bumps mtime; a + /// different length bumps size even within one clock tick). 0 if the file + /// can't be stat'd, which falls back to plain path+scale keying. + final int _stamp; + + static int _stampOf(String path) { + try { + final s = File(path).statSync(); + return s.modified.millisecondsSinceEpoch ^ s.size; + } catch (_) { + return 0; + } + } + + @override + bool operator ==(Object other) => other is ClideFileImage && other.file.path == file.path && other.scale == scale && other._stamp == _stamp; + + @override + int get hashCode => Object.hash(file.path, scale, _stamp); +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 60ec9e26..fff028de 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -13,6 +13,7 @@ export 'src/clide_collapser_card.dart'; export 'src/clide_column_hat.dart'; export 'src/clide_code_block.dart'; export 'src/clide_divider.dart'; +export 'src/clide_file_image.dart'; export 'src/clide_filter_box.dart'; export 'src/clide_lightbox.dart'; export 'src/clide_markdown.dart'; diff --git a/test/widgets/src/clide_file_image_test.dart b/test/widgets/src/clide_file_image_test.dart new file mode 100644 index 00000000..376e2b4e --- /dev/null +++ b/test/widgets/src/clide_file_image_test.dart @@ -0,0 +1,32 @@ +/// [ClideFileImage] folds mtime + size into the imageCache key so an in-place +/// overwrite re-decodes instead of returning Flutter's stale frame (T-312). +library; + +import 'dart:io'; + +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('an in-place overwrite produces a different cache key', () { + final dir = Directory.systemTemp.createTempSync('clide_fileimg_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final f = File('${dir.path}/img.bin')..writeAsBytesSync([1, 2, 3]); + + final before = ClideFileImage(f.path); + // Same bytes → same key (cache hit). + expect(before, ClideFileImage(f.path)); + expect(before.hashCode, ClideFileImage(f.path).hashCode); + + // Overwrite at the same path with a different length → different key. + f.writeAsBytesSync([9, 8, 7, 6]); + final after = ClideFileImage(f.path); + expect(after, isNot(before)); + expect(after.hashCode, isNot(before.hashCode)); + expect(after.file.path, before.file.path); // same path, just a fresh key + }); + + test('a missing file falls back to path-only keying', () { + expect(ClideFileImage('/no/such/clide/file.png'), ClideFileImage('/no/such/clide/file.png')); + }); +}