agentgui 1.0.535 → 1.0.537

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/database.js CHANGED
@@ -1038,6 +1038,44 @@ export const queries = {
1038
1038
  }
1039
1039
  },
1040
1040
 
1041
+ deleteAllConversations() {
1042
+ try {
1043
+ const conversations = prep('SELECT id, claudeSessionId FROM conversations').all();
1044
+
1045
+ for (const conv of conversations) {
1046
+ if (conv.claudeSessionId) {
1047
+ this.deleteClaudeSessionFile(conv.claudeSessionId);
1048
+ }
1049
+ }
1050
+
1051
+ const deleteAllStmt = db.transaction(() => {
1052
+ const allSessionIds = prep('SELECT id FROM sessions').all().map(r => r.id);
1053
+
1054
+ prep('DELETE FROM stream_updates');
1055
+ prep('DELETE FROM chunks');
1056
+ prep('DELETE FROM events');
1057
+
1058
+ if (allSessionIds.length > 0) {
1059
+ const placeholders = allSessionIds.map(() => '?').join(',');
1060
+ db.prepare(`DELETE FROM stream_updates WHERE sessionId IN (${placeholders})`).run(...allSessionIds);
1061
+ db.prepare(`DELETE FROM chunks WHERE sessionId IN (${placeholders})`).run(...allSessionIds);
1062
+ db.prepare(`DELETE FROM events WHERE sessionId IN (${placeholders})`).run(...allSessionIds);
1063
+ }
1064
+
1065
+ prep('DELETE FROM sessions');
1066
+ prep('DELETE FROM messages');
1067
+ prep('DELETE FROM conversations');
1068
+ });
1069
+
1070
+ deleteAllStmt();
1071
+ console.log('[deleteAllConversations] Deleted all conversations and associated Claude Code files');
1072
+ return true;
1073
+ } catch (err) {
1074
+ console.error('[deleteAllConversations] Error deleting all conversations:', err.message);
1075
+ return false;
1076
+ }
1077
+ },
1078
+
1041
1079
  cleanup() {
1042
1080
  const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
1043
1081
  const now = Date.now();
@@ -43,6 +43,12 @@ export function register(router, deps) {
43
43
  return { deleted: true };
44
44
  });
45
45
 
46
+ router.handle('conv.del.all', (p) => {
47
+ if (!queries.deleteAllConversations()) fail(500, 'Failed to delete all conversations');
48
+ broadcastSync({ type: 'all_conversations_deleted', timestamp: Date.now() });
49
+ return { deleted: true, message: 'All conversations deleted' };
50
+ });
51
+
46
52
  router.handle('conv.full', (p) => {
47
53
  const conv = queries.getConversation(p.id);
48
54
  if (!conv) notFound();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.535",
3
+ "version": "1.0.537",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/static/index.html CHANGED
@@ -3043,6 +3043,7 @@
3043
3043
  <div class="sidebar-header">
3044
3044
  <h2>History</h2>
3045
3045
  <div class="sidebar-header-actions">
3046
+ <button id="deleteAllConversationsBtn" class="sidebar-clone-btn" data-delete-all-conversations title="Delete all conversations and Claude Code artifacts">Clear All</button>
3046
3047
  <button id="cloneRepoBtn" class="sidebar-clone-btn" data-clone-repo title="Clone a GitHub repo">Clone</button>
3047
3048
  <button id="newConversationBtn" class="sidebar-new-btn" data-new-conversation title="Start new conversation">+ New</button>
3048
3049
  </div>
@@ -3246,6 +3247,7 @@
3246
3247
  </script>
3247
3248
  <script defer src="/gm/js/event-processor.js"></script>
3248
3249
  <script defer src="/gm/js/streaming-renderer.js"></script>
3250
+ <script defer src="/gm/js/image-loader.js"></script>
3249
3251
  <script defer src="/gm/js/kalman-filter.js"></script>
3250
3252
  <script defer src="/gm/js/event-consolidator.js"></script>
3251
3253
  <script defer src="/gm/js/websocket-manager.js"></script>
@@ -102,6 +102,12 @@ class AgentGUIClient {
102
102
  // Initialize renderer
103
103
  this.renderer.init(this.config.outputContainerId, this.config.scrollContainerId);
104
104
 
105
+ // Initialize image loader
106
+ if (typeof ImageLoader !== 'undefined') {
107
+ window.imageLoader = new ImageLoader();
108
+ console.log('Image loader initialized');
109
+ }
110
+
105
111
  // Setup event listeners
106
112
  this.setupWebSocketListeners();
107
113
  this.setupRendererListeners();
@@ -1649,10 +1655,6 @@ class AgentGUIClient {
1649
1655
  }
1650
1656
 
1651
1657
  let finalPrompt = prompt;
1652
- if (subAgent && agentId === 'claude-code') {
1653
- const displaySubAgent = subAgent.split('-·-')[0];
1654
- finalPrompt = `use ${displaySubAgent} subagent to ${prompt}`;
1655
- }
1656
1658
  const streamBody = { id: conversationId, content: finalPrompt, agentId };
1657
1659
  if (model) streamBody.model = model;
1658
1660
  if (subAgent) streamBody.subAgent = subAgent;
@@ -47,6 +47,7 @@ class ConversationManager {
47
47
  this.setupWebSocketListener();
48
48
  this.setupFolderBrowser();
49
49
  this.setupCloneUI();
50
+ this.setupDeleteAllButton();
50
51
 
51
52
  this._pollInterval = setInterval(() => this.loadConversations(), 30000);
52
53
 
@@ -245,6 +246,40 @@ class ConversationManager {
245
246
  }));
246
247
  }
247
248
 
249
+ setupDeleteAllButton() {
250
+ this.deleteAllBtn = document.getElementById('deleteAllConversationsBtn');
251
+ if (!this.deleteAllBtn) return;
252
+ this.deleteAllBtn.addEventListener('click', () => this.confirmDeleteAll());
253
+ }
254
+
255
+ async confirmDeleteAll() {
256
+ if (this.conversations.length === 0) {
257
+ window.UIDialog.alert('No conversations to delete', 'Information');
258
+ return;
259
+ }
260
+
261
+ const confirmed = await window.UIDialog.confirm(
262
+ `Delete all ${this.conversations.length} conversation(s) and associated Claude Code artifacts?\n\nThis action cannot be undone.`,
263
+ 'Delete All Conversations'
264
+ );
265
+ if (!confirmed) return;
266
+
267
+ try {
268
+ this.deleteAllBtn.disabled = true;
269
+ await window.wsClient.rpc('conv.del.all', {});
270
+ console.log('[ConversationManager] Deleted all conversations');
271
+ this.conversations = [];
272
+ this.activeId = null;
273
+ window.dispatchEvent(new CustomEvent('conversation-deselected'));
274
+ this.render();
275
+ } catch (err) {
276
+ console.error('[ConversationManager] Delete all error:', err);
277
+ window.UIDialog.alert('Failed to delete all conversations: ' + (err.message || 'Unknown error'), 'Error');
278
+ } finally {
279
+ this.deleteAllBtn.disabled = false;
280
+ }
281
+ }
282
+
248
283
  setupCloneUI() {
249
284
  this.cloneBtn = document.getElementById('cloneRepoBtn');
250
285
  this.cloneBar = document.getElementById('cloneInputBar');
@@ -119,6 +119,20 @@ class EventProcessor {
119
119
  this.stats.transformedEvents++;
120
120
  }
121
121
 
122
+ if (event.type === 'file_read' && event.path && this.isImagePath(event.path)) {
123
+ processed.isImage = true;
124
+ processed.imagePath = event.path;
125
+ this.stats.transformedEvents++;
126
+ }
127
+
128
+ if ((event.type === 'text_block' || event.type === 'command_execute' || event.type === 'streaming_progress') && event.content || event.output) {
129
+ const imagePaths = this.extractImagePaths(event.content || event.output || '');
130
+ if (imagePaths.length > 0) {
131
+ processed.detectedImages = imagePaths;
132
+ this.stats.transformedEvents++;
133
+ }
134
+ }
135
+
122
136
  processed.processTime = performance.now() - startTime;
123
137
  this.stats.processedEvents++;
124
138
 
@@ -444,6 +458,32 @@ class EventProcessor {
444
458
  avgProcessTime: 0
445
459
  };
446
460
  }
461
+
462
+ /**
463
+ * Check if a path is an image file
464
+ */
465
+ isImagePath(filePath) {
466
+ if (!filePath || typeof filePath !== 'string') return false;
467
+ const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'];
468
+ const ext = this.getFileExtension(filePath);
469
+ return imageExts.includes(ext);
470
+ }
471
+
472
+ /**
473
+ * Extract image file paths from text content
474
+ */
475
+ extractImagePaths(content) {
476
+ if (typeof content !== 'string') return [];
477
+ const paths = [];
478
+ const pathPattern = /(?:\/[a-zA-Z0-9_.\-]+)+\/[a-zA-Z0-9_.\-]+\.(?:png|jpg|jpeg|gif|webp|svg)/gi;
479
+ let match;
480
+ while ((match = pathPattern.exec(content)) !== null) {
481
+ if (this.isImagePath(match[0])) {
482
+ paths.push(match[0]);
483
+ }
484
+ }
485
+ return [...new Set(paths)];
486
+ }
447
487
  }
448
488
 
449
489
  // Export for use in browser
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Image Loader Module
3
+ * Detects image file reads from agent events and manages lazy loading
4
+ * Supports PNG, JPG, JPEG, GIF, WebP, SVG formats
5
+ */
6
+
7
+ class ImageLoader {
8
+ constructor(config = {}) {
9
+ this.config = {
10
+ supportedExts: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'],
11
+ lazyLoadThreshold: config.lazyLoadThreshold || 0.5,
12
+ maxImageDisplaySize: config.maxImageDisplaySize || '600px',
13
+ ...config
14
+ };
15
+
16
+ this.imageCache = new Map();
17
+ this.pendingImages = new Map();
18
+ this.intersectionObserver = null;
19
+ this.drawerObserver = null;
20
+ this.initIntersectionObserver();
21
+ }
22
+
23
+ /**
24
+ * Check if a path is an image file
25
+ */
26
+ isImagePath(filePath) {
27
+ if (!filePath || typeof filePath !== 'string') return false;
28
+ const ext = this.getExtension(filePath).toLowerCase();
29
+ return this.config.supportedExts.includes(ext);
30
+ }
31
+
32
+ /**
33
+ * Extract file extension from path
34
+ */
35
+ getExtension(filePath) {
36
+ const match = filePath.match(/\.([^.]+)$/);
37
+ return match ? match[1] : '';
38
+ }
39
+
40
+ /**
41
+ * Extract image paths from text content
42
+ */
43
+ extractImagePaths(content) {
44
+ if (typeof content !== 'string') return [];
45
+
46
+ const paths = [];
47
+ const pathPattern = /(?:\/[a-zA-Z0-9_.\-]+)+\/[a-zA-Z0-9_.\-]+\.(?:png|jpg|jpeg|gif|webp|svg)/gi;
48
+
49
+ let match;
50
+ while ((match = pathPattern.exec(content)) !== null) {
51
+ if (this.isImagePath(match[0])) {
52
+ paths.push(match[0]);
53
+ }
54
+ }
55
+
56
+ return [...new Set(paths)];
57
+ }
58
+
59
+ /**
60
+ * Register images from event
61
+ */
62
+ registerImagesFromEvent(event) {
63
+ const images = [];
64
+
65
+ if (event.type === 'file_read' && event.path && this.isImagePath(event.path)) {
66
+ images.push({
67
+ path: event.path,
68
+ type: 'file_read',
69
+ eventId: event.id || event.sessionId,
70
+ timestamp: event.timestamp || Date.now()
71
+ });
72
+ }
73
+
74
+ if (event.content && typeof event.content === 'string') {
75
+ const paths = this.extractImagePaths(event.content);
76
+ paths.forEach(path => {
77
+ images.push({
78
+ path,
79
+ type: 'extracted',
80
+ eventId: event.id || event.sessionId,
81
+ timestamp: event.timestamp || Date.now()
82
+ });
83
+ });
84
+ }
85
+
86
+ if (event.output && typeof event.output === 'string') {
87
+ const paths = this.extractImagePaths(event.output);
88
+ paths.forEach(path => {
89
+ images.push({
90
+ path,
91
+ type: 'extracted',
92
+ eventId: event.id || event.sessionId,
93
+ timestamp: event.timestamp || Date.now()
94
+ });
95
+ });
96
+ }
97
+
98
+ images.forEach(img => {
99
+ const key = img.path;
100
+ if (!this.imageCache.has(key)) {
101
+ this.imageCache.set(key, img);
102
+ this.pendingImages.set(key, img);
103
+ }
104
+ });
105
+
106
+ return images;
107
+ }
108
+
109
+ /**
110
+ * Create image element with lazy loading
111
+ */
112
+ createImageElement(imagePath, options = {}) {
113
+ const container = document.createElement('div');
114
+ container.className = 'image-container';
115
+ container.dataset.imagePath = imagePath;
116
+ container.style.cssText = `
117
+ display: flex;
118
+ flex-direction: column;
119
+ gap: 0.5rem;
120
+ padding: 0.75rem;
121
+ border-radius: 0.375rem;
122
+ background: var(--color-bg-secondary);
123
+ border: 1px solid var(--color-border);
124
+ `;
125
+
126
+ const placeholder = document.createElement('div');
127
+ placeholder.className = 'image-placeholder';
128
+ placeholder.style.cssText = `
129
+ background: linear-gradient(90deg, var(--color-bg-tertiary) 25%, var(--color-bg-secondary) 50%, var(--color-bg-tertiary) 75%);
130
+ background-size: 200% 100%;
131
+ animation: loading 1.5s infinite;
132
+ border-radius: 0.375rem;
133
+ aspect-ratio: 16/9;
134
+ display: flex;
135
+ align-items: center;
136
+ justify-content: center;
137
+ color: var(--color-text-secondary);
138
+ font-size: 0.875rem;
139
+ `;
140
+ placeholder.innerHTML = 'Loading image...';
141
+ placeholder.dataset.path = imagePath;
142
+
143
+ const img = document.createElement('img');
144
+ img.className = 'lazy-image';
145
+ img.alt = imagePath;
146
+ img.style.cssText = `
147
+ max-width: 100%;
148
+ max-height: ${this.config.maxImageDisplaySize};
149
+ border-radius: 0.375rem;
150
+ display: none;
151
+ `;
152
+ img.dataset.src = imagePath;
153
+
154
+ const caption = document.createElement('div');
155
+ caption.className = 'image-caption';
156
+ caption.style.cssText = `
157
+ font-size: 0.75rem;
158
+ color: var(--color-text-secondary);
159
+ word-break: break-all;
160
+ font-family: 'Monaco', 'Menlo', monospace;
161
+ `;
162
+ caption.textContent = imagePath;
163
+
164
+ container.appendChild(placeholder);
165
+ container.appendChild(img);
166
+ container.appendChild(caption);
167
+
168
+ img.addEventListener('load', () => {
169
+ placeholder.style.display = 'none';
170
+ img.style.display = 'block';
171
+ });
172
+
173
+ img.addEventListener('error', () => {
174
+ placeholder.textContent = 'Failed to load image';
175
+ placeholder.style.background = 'var(--color-bg-error)';
176
+ placeholder.style.color = 'var(--color-text-error)';
177
+ });
178
+
179
+ if (this.intersectionObserver) {
180
+ this.intersectionObserver.observe(container);
181
+ }
182
+
183
+ return container;
184
+ }
185
+
186
+ /**
187
+ * Initialize Intersection Observer for lazy loading
188
+ */
189
+ initIntersectionObserver() {
190
+ this.intersectionObserver = new IntersectionObserver(
191
+ (entries) => {
192
+ entries.forEach(entry => {
193
+ if (entry.isIntersecting) {
194
+ const img = entry.target.querySelector('img.lazy-image');
195
+ if (img && img.dataset.src && !img.src) {
196
+ img.src = img.dataset.src;
197
+ this.intersectionObserver.unobserve(entry.target);
198
+ }
199
+ }
200
+ });
201
+ },
202
+ { threshold: this.config.lazyLoadThreshold }
203
+ );
204
+ }
205
+
206
+ /**
207
+ * Setup drawer observer to load images when drawer opens
208
+ */
209
+ setupDrawerObserver(drawerSelector = '.drawer-panel, [role="dialog"]') {
210
+ const drawers = document.querySelectorAll(drawerSelector);
211
+ drawers.forEach(drawer => {
212
+ const observer = new MutationObserver(() => {
213
+ if (drawer.offsetHeight > 0 && drawer.offsetWidth > 0) {
214
+ this.loadVisibleImages(drawer);
215
+ }
216
+ });
217
+
218
+ observer.observe(drawer, { attributes: true, attributeFilter: ['style', 'class'] });
219
+ });
220
+ }
221
+
222
+ /**
223
+ * Load all visible images in a container
224
+ */
225
+ loadVisibleImages(container = document) {
226
+ const images = container.querySelectorAll('img.lazy-image[data-src]');
227
+ images.forEach(img => {
228
+ if (!img.src && img.dataset.src) {
229
+ img.src = img.dataset.src;
230
+ }
231
+ });
232
+ }
233
+
234
+ /**
235
+ * Get cached images for a session/conversation
236
+ */
237
+ getImages(eventId = null) {
238
+ if (!eventId) {
239
+ return Array.from(this.imageCache.values());
240
+ }
241
+ return Array.from(this.imageCache.values()).filter(img => img.eventId === eventId);
242
+ }
243
+
244
+ /**
245
+ * Clear cache
246
+ */
247
+ clear() {
248
+ this.imageCache.clear();
249
+ this.pendingImages.clear();
250
+ }
251
+ }
252
+
253
+ if (typeof module !== 'undefined' && module.exports) {
254
+ module.exports = ImageLoader;
255
+ }
@@ -1950,6 +1950,85 @@ class StreamingRenderer {
1950
1950
  return div;
1951
1951
  }
1952
1952
 
1953
+ /**
1954
+ * Render file read event with image detection
1955
+ */
1956
+ renderFileRead(event) {
1957
+ const div = document.createElement('div');
1958
+ div.className = 'event-file-read mb-3 p-3 rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900';
1959
+ div.dataset.eventId = event.id || '';
1960
+ div.dataset.eventType = 'file_read';
1961
+
1962
+ const filePath = event.path || '';
1963
+ const fileName = pathBasename(filePath);
1964
+ const isImage = this.isImagePath(filePath);
1965
+
1966
+ let html = `
1967
+ <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:${isImage ? '0.75rem' : '0'}">
1968
+ <svg viewBox="0 0 20 20" fill="currentColor" style="width:1rem;height:1rem;color:var(--color-info)">
1969
+ <path d="M8 16a2 2 0 100-4 2 2 0 000 4zM9 2a1 1 0 011 1v2h2V3a1 1 0 112 0v2h2V3a1 1 0 112 0v2h2a2 2 0 012 2v2h2a1 1 0 110 2h-2v2h2a1 1 0 110 2h-2v2a2 2 0 01-2 2h-2v2a1 1 0 11-2 0v-2h-2v2a1 1 0 11-2 0v-2H9a2 2 0 01-2-2v-2H5a1 1 0 110-2h2V9H5a1 1 0 010-2h2V5H5a1 1 0 010-2h2V3a1 1 0 011-1z"/>
1970
+ </svg>
1971
+ <code style="font-size:0.75rem;color:var(--color-text-primary);font-family:'Monaco','Menlo','Ubuntu Mono',monospace">${this.escapeHtml(filePath)}</code>
1972
+ </div>
1973
+ `;
1974
+
1975
+ if (isImage) {
1976
+ html += `<div style="margin-top:0.75rem" class="lazy-image-container" data-image-path="${this.escapeHtml(filePath)}"></div>`;
1977
+ }
1978
+
1979
+ div.innerHTML = html;
1980
+
1981
+ if (isImage) {
1982
+ const container = div.querySelector('.lazy-image-container');
1983
+ if (container && window.imageLoader) {
1984
+ const imgElement = window.imageLoader.createImageElement(filePath);
1985
+ container.appendChild(imgElement);
1986
+ }
1987
+ }
1988
+
1989
+ return div;
1990
+ }
1991
+
1992
+ /**
1993
+ * Render file write event
1994
+ */
1995
+ renderFileWrite(event) {
1996
+ const div = document.createElement('div');
1997
+ div.className = 'event-file-write mb-3 p-3 rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900';
1998
+ div.dataset.eventId = event.id || '';
1999
+ div.dataset.eventType = 'file_write';
2000
+
2001
+ const filePath = event.path || '';
2002
+ const icon = '<svg viewBox="0 0 20 20" fill="currentColor" style="width:1rem;height:1rem;color:var(--color-success)"><path d="M11 3a1 1 0 10-2 0v1a1 1 0 102 0V3zM15.657 5.757a1 1 0 00-1.414-1.414l-.707.707a1 1 0 001.414 1.414l.707-.707zM18 10a1 1 0 01-1 1h-1a1 1 0 110-2h1a1 1 0 011 1zM16.243 15.657a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414l.707.707zM10 15a1 1 0 01-1-1v-1a1 1 0 112 0v1a1 1 0 01-1 1zM5.757 16.243a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707zM5 10a1 1 0 01-1-1V8a1 1 0 012 0v1a1 1 0 01-1 1zM5.757 5.757a1 1 0 000-1.414l-.707-.707a1 1 0 00-1.414 1.414l.707.707zM10 5a1 1 0 011-1h1a1 1 0 110 2h-1a1 1 0 01-1-1z"/></svg>';
2003
+
2004
+ div.innerHTML = `
2005
+ <div style="display:flex;align-items:center;gap:0.5rem">
2006
+ ${icon}
2007
+ <code style="font-size:0.75rem;color:var(--color-text-primary);font-family:'Monaco','Menlo','Ubuntu Mono',monospace">${this.escapeHtml(filePath)}</code>
2008
+ </div>
2009
+ `;
2010
+
2011
+ return div;
2012
+ }
2013
+
2014
+ /**
2015
+ * Check if a path is an image file
2016
+ */
2017
+ isImagePath(filePath) {
2018
+ if (!filePath || typeof filePath !== 'string') return false;
2019
+ const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'];
2020
+ const ext = this.getFileExtension(filePath);
2021
+ return imageExts.includes(ext);
2022
+ }
2023
+
2024
+ /**
2025
+ * Extract file extension
2026
+ */
2027
+ getFileExtension(filePath) {
2028
+ const match = filePath.match(/\.([^.]+)$/);
2029
+ return match ? match[1].toLowerCase() : '';
2030
+ }
2031
+
1953
2032
  /**
1954
2033
  * Render generic event with formatted key-value pairs
1955
2034
  */
@@ -0,0 +1,76 @@
1
+ <!-- Image Display Container with Lazy Loading -->
2
+ <div class="image-container" data-image-path="{{ imagePath }}" role="img" aria-label="{{ imagePath }}">
3
+ <!-- Placeholder shown while loading -->
4
+ <div class="image-placeholder" data-path="{{ imagePath }}" style="
5
+ background: linear-gradient(90deg, var(--color-bg-tertiary) 25%, var(--color-bg-secondary) 50%, var(--color-bg-tertiary) 75%);
6
+ background-size: 200% 100%;
7
+ animation: loading 1.5s infinite;
8
+ border-radius: 0.375rem;
9
+ aspect-ratio: 16/9;
10
+ display: flex;
11
+ align-items: center;
12
+ justify-content: center;
13
+ color: var(--color-text-secondary);
14
+ font-size: 0.875rem;
15
+ ">
16
+ Loading image...
17
+ </div>
18
+
19
+ <!-- Actual image element with lazy loading -->
20
+ <img
21
+ class="lazy-image"
22
+ data-src="{{ imagePath }}"
23
+ alt="{{ imagePath }}"
24
+ loading="lazy"
25
+ style="
26
+ max-width: 100%;
27
+ max-height: 600px;
28
+ border-radius: 0.375rem;
29
+ display: none;
30
+ "
31
+ />
32
+
33
+ <!-- Image path caption -->
34
+ <div class="image-caption" style="
35
+ font-size: 0.75rem;
36
+ color: var(--color-text-secondary);
37
+ word-break: break-all;
38
+ font-family: 'Monaco', 'Menlo', monospace;
39
+ ">
40
+ {{ imagePath }}
41
+ </div>
42
+ </div>
43
+
44
+ <style>
45
+ @keyframes loading {
46
+ 0% {
47
+ background-position: 200% 0;
48
+ }
49
+ 100% {
50
+ background-position: -200% 0;
51
+ }
52
+ }
53
+
54
+ .image-container {
55
+ display: flex;
56
+ flex-direction: column;
57
+ gap: 0.5rem;
58
+ padding: 0.75rem;
59
+ border-radius: 0.375rem;
60
+ background: var(--color-bg-secondary);
61
+ border: 1px solid var(--color-border);
62
+ margin: 0.5rem 0;
63
+ }
64
+
65
+ .image-placeholder {
66
+ animation: loading 1.5s infinite;
67
+ }
68
+
69
+ .lazy-image {
70
+ transition: opacity 0.3s ease-in-out;
71
+ }
72
+
73
+ .image-caption {
74
+ white-space: pre-wrap;
75
+ }
76
+ </style>