mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
feat(image-popup): add auto-popup for detected screenshots and images
Implements image file watching in session working directories with automatic popup display in the web UI: - Add ImageWatcher class using chokidar for cross-platform file watching - Add ImageDetectedEvent type for SSE broadcasting - Integrate with session lifecycle (watch on create, unwatch on delete) - Add image:detected SSE event handler in frontend - Create draggable image popup window with open-in-new-tab support - Add CSS styling with compact wizard layout Supports: png, jpg, jpeg, gif, webp, bmp, svg Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -476,6 +476,10 @@ class ClaudemanApp {
|
||||
this.projectInsightsPanelVisible = false;
|
||||
this.currentSessionWorkingDir = null; // Track current session's working dir for path normalization
|
||||
|
||||
// Image popup windows (auto-open for detected screenshots/images)
|
||||
this.imagePopups = new Map(); // Map<imageId, { element, sessionId, filePath }>
|
||||
this.imagePopupZIndex = 3000;
|
||||
|
||||
// Tab alert states: Map<sessionId, 'action' | 'idle'>
|
||||
this.tabAlerts = new Map();
|
||||
|
||||
@@ -1129,6 +1133,7 @@ class ClaudemanApp {
|
||||
this.ralphStates.delete(data.id); // Clean up ralph state for this session
|
||||
this.projectInsights.delete(data.id); // Clean up project insights for this session
|
||||
this.closeSessionLogViewerWindows(data.id); // Close log viewer windows for this session
|
||||
this.closeSessionImagePopups(data.id); // Close image popup windows for this session
|
||||
this.closeSessionSubagentWindows(data.id, true); // Close subagent windows and cleanup activity data
|
||||
// Clean up idle timer for this session
|
||||
const idleTimer = this.idleTimers.get(data.id);
|
||||
@@ -1912,6 +1917,14 @@ class ClaudemanApp {
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
});
|
||||
|
||||
// ========== Image Detection Events (Screenshots & Generated Images) ==========
|
||||
|
||||
addListener('image:detected', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
console.log('[Image Detected]', data);
|
||||
this.openImagePopup(data);
|
||||
});
|
||||
|
||||
// Plan subagent visibility events (show Opus agents during plan generation)
|
||||
addListener('plan:subagent', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
@@ -9670,6 +9683,127 @@ class ClaudemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Image Popup Windows (Auto-popup for Screenshots) ==========
|
||||
|
||||
/**
|
||||
* Open a popup window to display a detected image.
|
||||
* Called automatically when image:detected SSE event is received.
|
||||
*/
|
||||
openImagePopup(imageEvent) {
|
||||
const { sessionId, filePath, fileName, timestamp, size } = imageEvent;
|
||||
|
||||
// Create unique window ID
|
||||
const imageId = `${sessionId}-${timestamp}`;
|
||||
|
||||
// If window already exists for this image, focus it
|
||||
if (this.imagePopups.has(imageId)) {
|
||||
const existing = this.imagePopups.get(imageId);
|
||||
existing.element.style.zIndex = ++this.imagePopupZIndex;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate position (cascade from center, with offset for multiple popups)
|
||||
const windowCount = this.imagePopups.size;
|
||||
const centerX = (window.innerWidth - 600) / 2;
|
||||
const centerY = (window.innerHeight - 500) / 2;
|
||||
const offsetX = centerX + (windowCount % 5) * 30;
|
||||
const offsetY = centerY + (windowCount % 5) * 30;
|
||||
|
||||
// Get session name for display
|
||||
const session = this.sessions.get(sessionId);
|
||||
const sessionName = session?.name || sessionId.substring(0, 8);
|
||||
|
||||
// Format file size
|
||||
const sizeKB = (size / 1024).toFixed(1);
|
||||
|
||||
// Build image URL using the existing file-raw endpoint
|
||||
const imageUrl = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(fileName)}`;
|
||||
|
||||
// Create window element
|
||||
const win = document.createElement('div');
|
||||
win.className = 'image-popup-window';
|
||||
win.id = `image-popup-${imageId}`;
|
||||
win.style.left = `${offsetX}px`;
|
||||
win.style.top = `${offsetY}px`;
|
||||
win.style.zIndex = ++this.imagePopupZIndex;
|
||||
|
||||
win.innerHTML = `
|
||||
<div class="image-popup-header">
|
||||
<div class="image-popup-title" title="${this.escapeHtml(filePath)}">
|
||||
<span class="icon">🖼️</span>
|
||||
<span class="filename">${this.escapeHtml(fileName)}</span>
|
||||
<span class="session-badge">${this.escapeHtml(sessionName)}</span>
|
||||
<span class="size-badge">${sizeKB} KB</span>
|
||||
</div>
|
||||
<div class="image-popup-actions">
|
||||
<button onclick="app.openImageInNewTab('${imageUrl}')" title="Open in new tab">↗</button>
|
||||
<button onclick="app.closeImagePopup('${imageId}')" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-popup-body">
|
||||
<img src="${imageUrl}" alt="${this.escapeHtml(fileName)}"
|
||||
onerror="this.parentElement.innerHTML='<div class=\\'image-error\\'>Failed to load image</div>'"
|
||||
onclick="app.openImageInNewTab('${imageUrl}')" />
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(win);
|
||||
|
||||
// Make draggable
|
||||
const dragListeners = this.makeWindowDraggable(win, win.querySelector('.image-popup-header'));
|
||||
|
||||
// Focus on click
|
||||
win.addEventListener('mousedown', () => {
|
||||
win.style.zIndex = ++this.imagePopupZIndex;
|
||||
});
|
||||
|
||||
// Store reference
|
||||
this.imagePopups.set(imageId, {
|
||||
element: win,
|
||||
sessionId,
|
||||
filePath,
|
||||
dragListeners,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close an image popup window.
|
||||
*/
|
||||
closeImagePopup(imageId) {
|
||||
const popupData = this.imagePopups.get(imageId);
|
||||
if (!popupData) return;
|
||||
|
||||
// Clean up global drag event listeners
|
||||
if (popupData.dragListeners) {
|
||||
document.removeEventListener('mousemove', popupData.dragListeners.move);
|
||||
document.removeEventListener('mouseup', popupData.dragListeners.up);
|
||||
}
|
||||
|
||||
// Remove element
|
||||
popupData.element.remove();
|
||||
|
||||
// Remove from map
|
||||
this.imagePopups.delete(imageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open image in a new browser tab.
|
||||
*/
|
||||
openImageInNewTab(url) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all image popups for a session.
|
||||
*/
|
||||
closeSessionImagePopups(sessionId) {
|
||||
for (const [imageId, data] of this.imagePopups) {
|
||||
if (data.sessionId === sessionId) {
|
||||
this.closeImagePopup(imageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Screen Sessions (in Monitor Panel) ==========
|
||||
|
||||
async loadScreens() {
|
||||
|
||||
@@ -942,7 +942,7 @@
|
||||
|
||||
<!-- Ralph Loop Wizard Modal -->
|
||||
<div class="modal" id="ralphWizardModal">
|
||||
<div class="modal-backdrop" onclick="app.closeRalphWizard()"></div>
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-content modal-wizard">
|
||||
<div class="modal-header">
|
||||
<h3>Start Ralph Loop</h3>
|
||||
@@ -976,7 +976,7 @@
|
||||
<div class="textarea-with-assist">
|
||||
<textarea
|
||||
id="ralphTaskDescription"
|
||||
rows="4"
|
||||
rows="3"
|
||||
placeholder="Build a REST API for user authentication with JWT tokens, login/logout endpoints, and password reset flow."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+126
-8
@@ -5282,6 +5282,124 @@ kbd {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ========== Image Popup Windows (Auto-popup for Screenshots) ========== */
|
||||
|
||||
.image-popup-window {
|
||||
position: fixed;
|
||||
width: 600px;
|
||||
height: 500px;
|
||||
min-width: 300px;
|
||||
min-height: 250px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
z-index: 3000;
|
||||
}
|
||||
|
||||
.image-popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.image-popup-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.image-popup-title .icon {
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.image-popup-title .filename {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.image-popup-title .session-badge {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.image-popup-title .size-badge {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
background: var(--text-muted);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.image-popup-actions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.image-popup-actions button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.image-popup-actions button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.image-popup-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.image-popup-body img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.image-popup-body img:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.image-popup-body .image-error {
|
||||
color: var(--error);
|
||||
font-size: 0.85rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ========== xterm.js Clickable Links ========== */
|
||||
|
||||
/* Style for detected file path links in terminal */
|
||||
@@ -5321,8 +5439,8 @@ kbd {
|
||||
.wizard-progress {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 1.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-dark);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
@@ -5345,15 +5463,15 @@ kbd {
|
||||
}
|
||||
|
||||
.wizard-step-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -5397,11 +5515,11 @@ kbd {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Wizard Modal - wider layout with height constraints */
|
||||
/* Wizard Modal - compact layout */
|
||||
.modal-wizard {
|
||||
width: 720px;
|
||||
width: 480px;
|
||||
max-width: 95vw;
|
||||
max-height: 80vh;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { generateClaudeMd } from '../templates/claude-md.js';
|
||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||
import { writeHooksConfig } from '../hooks-config.js';
|
||||
import { subagentWatcher, type SubagentInfo, type SubagentToolCall, type SubagentProgress, type SubagentMessage, type SubagentToolResult } from '../subagent-watcher.js';
|
||||
import { imageWatcher } from '../image-watcher.js';
|
||||
import { TranscriptWatcher } from '../transcript-watcher.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { createRequire } from 'node:module';
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
type CaseInfo,
|
||||
type PersistedRespawnConfig,
|
||||
type NiceConfig,
|
||||
type ImageDetectedEvent,
|
||||
DEFAULT_NICE_CONFIG,
|
||||
} from '../types.js';
|
||||
|
||||
@@ -345,6 +347,9 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Set up subagent watcher listeners
|
||||
this.setupSubagentWatcherListeners();
|
||||
|
||||
// Set up image watcher listeners
|
||||
this.setupImageWatcherListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,6 +394,20 @@ export class WebServer extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners for image watcher.
|
||||
* Broadcasts image detection events to SSE clients for auto-popup.
|
||||
*/
|
||||
private setupImageWatcherListeners(): void {
|
||||
imageWatcher.on('image:detected', (event: ImageDetectedEvent) => {
|
||||
this.broadcast('image:detected', event);
|
||||
});
|
||||
|
||||
imageWatcher.on('image:error', (error: Error, sessionId?: string) => {
|
||||
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
||||
});
|
||||
}
|
||||
|
||||
private async setupRoutes(): Promise<void> {
|
||||
// Serve static files
|
||||
await this.app.register(fastifyStatic, {
|
||||
@@ -3291,6 +3310,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
session.removeAllListeners();
|
||||
// Close any active file streams for this session
|
||||
fileStreamManager.closeSessionStreams(sessionId);
|
||||
// Stop watching for images in this session's directory
|
||||
imageWatcher.unwatchSession(sessionId);
|
||||
await session.stop(killScreen);
|
||||
this.sessions.delete(sessionId);
|
||||
// Only remove from state.json if we're also killing the screen.
|
||||
@@ -3312,6 +3333,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// Set working directory for Ralph tracker to auto-load @fix_plan.md
|
||||
session.ralphTracker.setWorkingDir(session.workingDir);
|
||||
|
||||
// Start watching for new images in this session's working directory
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
|
||||
session.on('output', (data) => {
|
||||
// Use batching for better performance at high throughput
|
||||
this.batchOutputData(session.id, data);
|
||||
@@ -4338,6 +4362,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
} else {
|
||||
console.log('Subagent watcher disabled by user settings');
|
||||
}
|
||||
|
||||
// Start image watcher for auto-popup of screenshots
|
||||
imageWatcher.start();
|
||||
console.log('Image watcher started - monitoring session directories for new images');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4592,6 +4620,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// Stop subagent watcher
|
||||
subagentWatcher.stop();
|
||||
|
||||
// Stop image watcher
|
||||
imageWatcher.stop();
|
||||
|
||||
await this.app.close();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user