mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19: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:
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* @fileoverview Image Watcher - Detects new image files in session working directories
|
||||
*
|
||||
* Watches session working directories for new image files (screenshots, generated images)
|
||||
* and emits events to trigger automatic popup display in the web UI.
|
||||
*
|
||||
* Uses chokidar for reliable cross-platform file watching with awaitWriteFinish
|
||||
* to ensure files are fully written before emitting detection events.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { watch, type FSWatcher } from 'chokidar';
|
||||
import { basename, extname } from 'path';
|
||||
import { statSync } from 'fs';
|
||||
import type { ImageDetectedEvent } from './types.js';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface ImageWatcherEvents {
|
||||
'image:detected': (event: ImageDetectedEvent) => void;
|
||||
'image:error': (error: Error, sessionId?: string) => void;
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
/** Supported image file extensions (lowercase) */
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
'.svg',
|
||||
]);
|
||||
|
||||
/** Time to wait for file writes to stabilize (ms) */
|
||||
const STABILITY_THRESHOLD_MS = 500;
|
||||
|
||||
/** Poll interval for checking file write stability (ms) */
|
||||
const POLL_INTERVAL_MS = 100;
|
||||
|
||||
/** Debounce delay for rapid image creation (ms) */
|
||||
const DEBOUNCE_DELAY_MS = 200;
|
||||
|
||||
// ========== ImageWatcher Class ==========
|
||||
|
||||
/**
|
||||
* Watches session working directories for new image files.
|
||||
*
|
||||
* Follows the SubagentWatcher pattern: extends EventEmitter, manages
|
||||
* file watchers in Maps, emits typed events.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const watcher = new ImageWatcher();
|
||||
* watcher.on('image:detected', (event) => {
|
||||
* console.log(`New image in session ${event.sessionId}: ${event.fileName}`);
|
||||
* });
|
||||
* watcher.watchSession('session-123', '/path/to/working/dir');
|
||||
* ```
|
||||
*/
|
||||
export class ImageWatcher extends EventEmitter {
|
||||
/** Map of sessionId -> FSWatcher for per-session directory watching */
|
||||
private sessionWatchers = new Map<string, FSWatcher>();
|
||||
|
||||
/** Map of sessionId -> working directory path */
|
||||
private sessionDirs = new Map<string, string>();
|
||||
|
||||
/** Debounce timers for rapid image creation */
|
||||
private debounceTimers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
/** Whether the watcher is currently running */
|
||||
private _isRunning = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
// ========== Public API ==========
|
||||
|
||||
/**
|
||||
* Check if the watcher is currently running
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this._isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the image watcher.
|
||||
* After calling start(), use watchSession() to add directories to monitor.
|
||||
*/
|
||||
start(): void {
|
||||
if (this._isRunning) return;
|
||||
this._isRunning = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the image watcher and clean up all resources.
|
||||
*/
|
||||
stop(): void {
|
||||
this._isRunning = false;
|
||||
|
||||
// Close all session watchers
|
||||
for (const [sessionId, watcher] of this.sessionWatchers) {
|
||||
try {
|
||||
watcher.close();
|
||||
} catch (error) {
|
||||
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
|
||||
}
|
||||
}
|
||||
this.sessionWatchers.clear();
|
||||
this.sessionDirs.clear();
|
||||
|
||||
// Clear all debounce timers
|
||||
for (const timer of this.debounceTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.debounceTimers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching a session's working directory for new images.
|
||||
*
|
||||
* @param sessionId - Claudeman session ID
|
||||
* @param workingDir - Path to the session's working directory
|
||||
*/
|
||||
watchSession(sessionId: string, workingDir: string): void {
|
||||
if (!this._isRunning) {
|
||||
this.start();
|
||||
}
|
||||
|
||||
// Don't double-watch the same session
|
||||
if (this.sessionWatchers.has(sessionId)) {
|
||||
// If working directory changed, unwatch old and watch new
|
||||
if (this.sessionDirs.get(sessionId) !== workingDir) {
|
||||
this.unwatchSession(sessionId);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.sessionDirs.set(sessionId, workingDir);
|
||||
|
||||
try {
|
||||
// Create chokidar watcher for the directory
|
||||
const watcher = watch(workingDir, {
|
||||
// Only detect NEW files, not existing ones
|
||||
ignoreInitial: true,
|
||||
// Wait for file writes to stabilize before emitting
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: STABILITY_THRESHOLD_MS,
|
||||
pollInterval: POLL_INTERVAL_MS,
|
||||
},
|
||||
// Only watch files at the top level (not subdirectories)
|
||||
depth: 0,
|
||||
// Ignore non-image files early for performance
|
||||
ignored: (path: string) => {
|
||||
const ext = extname(path).toLowerCase();
|
||||
// Don't ignore directories (needed for watching to work)
|
||||
// Ignore files that aren't images
|
||||
return ext !== '' && !IMAGE_EXTENSIONS.has(ext);
|
||||
},
|
||||
});
|
||||
|
||||
// Handle new file detection
|
||||
watcher.on('add', (filePath: string) => {
|
||||
this.handleNewFile(sessionId, filePath);
|
||||
});
|
||||
|
||||
// Handle watcher errors
|
||||
watcher.on('error', (error: Error) => {
|
||||
this.emit('image:error', error, sessionId);
|
||||
});
|
||||
|
||||
this.sessionWatchers.set(sessionId, watcher);
|
||||
} catch (error) {
|
||||
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching a session's working directory.
|
||||
*
|
||||
* @param sessionId - Claudeman session ID to stop watching
|
||||
*/
|
||||
unwatchSession(sessionId: string): void {
|
||||
const watcher = this.sessionWatchers.get(sessionId);
|
||||
if (watcher) {
|
||||
try {
|
||||
watcher.close();
|
||||
} catch (error) {
|
||||
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
|
||||
}
|
||||
this.sessionWatchers.delete(sessionId);
|
||||
}
|
||||
this.sessionDirs.delete(sessionId);
|
||||
|
||||
// Clear any pending debounce timer for this session
|
||||
const timer = this.debounceTimers.get(sessionId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.debounceTimers.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of currently watched session IDs.
|
||||
*/
|
||||
getWatchedSessions(): string[] {
|
||||
return Array.from(this.sessionWatchers.keys());
|
||||
}
|
||||
|
||||
// ========== Private Methods ==========
|
||||
|
||||
/**
|
||||
* Handle a new file being detected.
|
||||
* Verifies it's an image and emits the detection event.
|
||||
*/
|
||||
private handleNewFile(sessionId: string, filePath: string): void {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
|
||||
// Double-check it's an image extension
|
||||
if (!IMAGE_EXTENSIONS.has(ext)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce rapid file creation (e.g., multiple screenshots quickly)
|
||||
const existingTimer = this.debounceTimers.get(filePath);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.debounceTimers.delete(filePath);
|
||||
this.emitImageDetected(sessionId, filePath);
|
||||
}, DEBOUNCE_DELAY_MS);
|
||||
|
||||
this.debounceTimers.set(filePath, timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the image:detected event with file metadata.
|
||||
*/
|
||||
private emitImageDetected(sessionId: string, filePath: string): void {
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
const fileName = basename(filePath);
|
||||
|
||||
const event: ImageDetectedEvent = {
|
||||
sessionId,
|
||||
filePath,
|
||||
fileName,
|
||||
timestamp: Date.now(),
|
||||
size: stat.size,
|
||||
};
|
||||
|
||||
this.emit('image:detected', event);
|
||||
} catch (error) {
|
||||
// File may have been deleted between detection and stat
|
||||
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance for convenience
|
||||
export const imageWatcher = new ImageWatcher();
|
||||
Reference in New Issue
Block a user