From e2718b37e36b00ced81b01d7135f0ad211156a1f Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 21 Dec 2025 23:44:26 -0500 Subject: [PATCH] fixing file uploads on context page --- apps/server/src/index.ts | 485 +++++---- apps/server/src/providers/claude-provider.ts | 88 +- apps/server/src/routes/context/index.ts | 24 + .../routes/context/routes/describe-file.ts | 140 +++ .../routes/context/routes/describe-image.ts | 387 +++++++ .../dialogs/board-background-modal.tsx | 157 +-- .../ui/description-image-dropzone.tsx | 229 +++-- .../components/ui/feature-image-upload.tsx | 44 +- apps/ui/src/components/ui/image-drop-zone.tsx | 54 +- apps/ui/src/components/views/agent-view.tsx | 228 +++-- .../board-view/dialogs/add-feature-dialog.tsx | 221 ++-- .../dialogs/edit-feature-dialog.tsx | 10 + apps/ui/src/components/views/context-view.tsx | 878 +++++++++++----- apps/ui/src/hooks/use-electron-agent.ts | 197 ++-- apps/ui/src/hooks/use-message-queue.ts | 37 +- apps/ui/src/lib/electron.ts | 958 ++++++++---------- apps/ui/src/lib/http-api-client.ts | 19 + apps/ui/src/lib/image-utils.ts | 236 +++++ apps/ui/src/store/app-store.ts | 43 +- libs/types/src/feature.ts | 12 +- libs/types/src/index.ts | 46 +- libs/types/src/model.ts | 8 +- 22 files changed, 2808 insertions(+), 1693 deletions(-) create mode 100644 apps/server/src/routes/context/index.ts create mode 100644 apps/server/src/routes/context/routes/describe-file.ts create mode 100644 apps/server/src/routes/context/routes/describe-image.ts create mode 100644 apps/ui/src/lib/image-utils.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index adf39f3a..d1a33b25 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -6,53 +6,54 @@ * In web mode, this server runs on a remote host. */ -import express from "express"; -import cors from "cors"; -import morgan from "morgan"; -import { WebSocketServer, WebSocket } from "ws"; -import { createServer } from "http"; -import dotenv from "dotenv"; +import express from 'express'; +import cors from 'cors'; +import morgan from 'morgan'; +import { WebSocketServer, WebSocket } from 'ws'; +import { createServer } from 'http'; +import dotenv from 'dotenv'; -import { createEventEmitter, type EventEmitter } from "./lib/events.js"; -import { initAllowedPaths } from "@automaker/platform"; -import { authMiddleware, getAuthStatus } from "./lib/auth.js"; -import { createFsRoutes } from "./routes/fs/index.js"; -import { createHealthRoutes } from "./routes/health/index.js"; -import { createAgentRoutes } from "./routes/agent/index.js"; -import { createSessionsRoutes } from "./routes/sessions/index.js"; -import { createFeaturesRoutes } from "./routes/features/index.js"; -import { createAutoModeRoutes } from "./routes/auto-mode/index.js"; -import { createEnhancePromptRoutes } from "./routes/enhance-prompt/index.js"; -import { createWorktreeRoutes } from "./routes/worktree/index.js"; -import { createGitRoutes } from "./routes/git/index.js"; -import { createSetupRoutes } from "./routes/setup/index.js"; -import { createSuggestionsRoutes } from "./routes/suggestions/index.js"; -import { createModelsRoutes } from "./routes/models/index.js"; -import { createRunningAgentsRoutes } from "./routes/running-agents/index.js"; -import { createWorkspaceRoutes } from "./routes/workspace/index.js"; -import { createTemplatesRoutes } from "./routes/templates/index.js"; +import { createEventEmitter, type EventEmitter } from './lib/events.js'; +import { initAllowedPaths } from '@automaker/platform'; +import { authMiddleware, getAuthStatus } from './lib/auth.js'; +import { createFsRoutes } from './routes/fs/index.js'; +import { createHealthRoutes } from './routes/health/index.js'; +import { createAgentRoutes } from './routes/agent/index.js'; +import { createSessionsRoutes } from './routes/sessions/index.js'; +import { createFeaturesRoutes } from './routes/features/index.js'; +import { createAutoModeRoutes } from './routes/auto-mode/index.js'; +import { createEnhancePromptRoutes } from './routes/enhance-prompt/index.js'; +import { createWorktreeRoutes } from './routes/worktree/index.js'; +import { createGitRoutes } from './routes/git/index.js'; +import { createSetupRoutes } from './routes/setup/index.js'; +import { createSuggestionsRoutes } from './routes/suggestions/index.js'; +import { createModelsRoutes } from './routes/models/index.js'; +import { createRunningAgentsRoutes } from './routes/running-agents/index.js'; +import { createWorkspaceRoutes } from './routes/workspace/index.js'; +import { createTemplatesRoutes } from './routes/templates/index.js'; import { createTerminalRoutes, validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired, -} from "./routes/terminal/index.js"; -import { createSettingsRoutes } from "./routes/settings/index.js"; -import { AgentService } from "./services/agent-service.js"; -import { FeatureLoader } from "./services/feature-loader.js"; -import { AutoModeService } from "./services/auto-mode-service.js"; -import { getTerminalService } from "./services/terminal-service.js"; -import { SettingsService } from "./services/settings-service.js"; -import { createSpecRegenerationRoutes } from "./routes/app-spec/index.js"; -import { createClaudeRoutes } from "./routes/claude/index.js"; -import { ClaudeUsageService } from "./services/claude-usage-service.js"; +} from './routes/terminal/index.js'; +import { createSettingsRoutes } from './routes/settings/index.js'; +import { AgentService } from './services/agent-service.js'; +import { FeatureLoader } from './services/feature-loader.js'; +import { AutoModeService } from './services/auto-mode-service.js'; +import { getTerminalService } from './services/terminal-service.js'; +import { SettingsService } from './services/settings-service.js'; +import { createSpecRegenerationRoutes } from './routes/app-spec/index.js'; +import { createClaudeRoutes } from './routes/claude/index.js'; +import { ClaudeUsageService } from './services/claude-usage-service.js'; +import { createContextRoutes } from './routes/context/index.js'; // Load environment variables dotenv.config(); -const PORT = parseInt(process.env.PORT || "3008", 10); -const DATA_DIR = process.env.DATA_DIR || "./data"; -const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== "false"; // Default to true +const PORT = parseInt(process.env.PORT || '3008', 10); +const DATA_DIR = process.env.DATA_DIR || './data'; +const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true // Check for required environment variables const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; @@ -71,7 +72,7 @@ if (!hasAnthropicKey) { ╚═══════════════════════════════════════════════════════════════════════╝ `); } else { - console.log("[Server] ✓ ANTHROPIC_API_KEY detected (API key auth)"); + console.log('[Server] ✓ ANTHROPIC_API_KEY detected (API key auth)'); } // Initialize security @@ -83,7 +84,7 @@ const app = express(); // Middleware // Custom colored logger showing only endpoint and status code (configurable via ENABLE_REQUEST_LOGGING env var) if (ENABLE_REQUEST_LOGGING) { - morgan.token("status-colored", (req, res) => { + morgan.token('status-colored', (req, res) => { const status = res.statusCode; if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors @@ -92,18 +93,18 @@ if (ENABLE_REQUEST_LOGGING) { }); app.use( - morgan(":method :url :status-colored", { - skip: (req) => req.url === "/api/health", // Skip health check logs + morgan(':method :url :status-colored', { + skip: (req) => req.url === '/api/health', // Skip health check logs }) ); } app.use( cors({ - origin: process.env.CORS_ORIGIN || "*", + origin: process.env.CORS_ORIGIN || '*', credentials: true, }) ); -app.use(express.json({ limit: "50mb" })); +app.use(express.json({ limit: '50mb' })); // Create shared event emitter for streaming const events: EventEmitter = createEventEmitter(); @@ -118,33 +119,34 @@ const claudeUsageService = new ClaudeUsageService(); // Initialize services (async () => { await agentService.initialize(); - console.log("[Server] Agent service initialized"); + console.log('[Server] Agent service initialized'); })(); // Mount API routes - health is unauthenticated for monitoring -app.use("/api/health", createHealthRoutes()); +app.use('/api/health', createHealthRoutes()); // Apply authentication to all other routes -app.use("/api", authMiddleware); +app.use('/api', authMiddleware); -app.use("/api/fs", createFsRoutes(events)); -app.use("/api/agent", createAgentRoutes(agentService, events)); -app.use("/api/sessions", createSessionsRoutes(agentService)); -app.use("/api/features", createFeaturesRoutes(featureLoader)); -app.use("/api/auto-mode", createAutoModeRoutes(autoModeService)); -app.use("/api/enhance-prompt", createEnhancePromptRoutes()); -app.use("/api/worktree", createWorktreeRoutes()); -app.use("/api/git", createGitRoutes()); -app.use("/api/setup", createSetupRoutes()); -app.use("/api/suggestions", createSuggestionsRoutes(events)); -app.use("/api/models", createModelsRoutes()); -app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events)); -app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService)); -app.use("/api/workspace", createWorkspaceRoutes()); -app.use("/api/templates", createTemplatesRoutes()); -app.use("/api/terminal", createTerminalRoutes()); -app.use("/api/settings", createSettingsRoutes(settingsService)); -app.use("/api/claude", createClaudeRoutes(claudeUsageService)); +app.use('/api/fs', createFsRoutes(events)); +app.use('/api/agent', createAgentRoutes(agentService, events)); +app.use('/api/sessions', createSessionsRoutes(agentService)); +app.use('/api/features', createFeaturesRoutes(featureLoader)); +app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); +app.use('/api/enhance-prompt', createEnhancePromptRoutes()); +app.use('/api/worktree', createWorktreeRoutes()); +app.use('/api/git', createGitRoutes()); +app.use('/api/setup', createSetupRoutes()); +app.use('/api/suggestions', createSuggestionsRoutes(events)); +app.use('/api/models', createModelsRoutes()); +app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events)); +app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService)); +app.use('/api/workspace', createWorkspaceRoutes()); +app.use('/api/templates', createTemplatesRoutes()); +app.use('/api/terminal', createTerminalRoutes()); +app.use('/api/settings', createSettingsRoutes(settingsService)); +app.use('/api/claude', createClaudeRoutes(claudeUsageService)); +app.use('/api/context', createContextRoutes()); // Create HTTP server const server = createServer(app); @@ -155,19 +157,16 @@ const terminalWss = new WebSocketServer({ noServer: true }); const terminalService = getTerminalService(); // Handle HTTP upgrade requests manually to route to correct WebSocket server -server.on("upgrade", (request, socket, head) => { - const { pathname } = new URL( - request.url || "", - `http://${request.headers.host}` - ); +server.on('upgrade', (request, socket, head) => { + const { pathname } = new URL(request.url || '', `http://${request.headers.host}`); - if (pathname === "/api/events") { + if (pathname === '/api/events') { wss.handleUpgrade(request, socket, head, (ws) => { - wss.emit("connection", ws, request); + wss.emit('connection', ws, request); }); - } else if (pathname === "/api/terminal/ws") { + } else if (pathname === '/api/terminal/ws') { terminalWss.handleUpgrade(request, socket, head, (ws) => { - terminalWss.emit("connection", ws, request); + terminalWss.emit('connection', ws, request); }); } else { socket.destroy(); @@ -175,8 +174,8 @@ server.on("upgrade", (request, socket, head) => { }); // Events WebSocket connection handler -wss.on("connection", (ws: WebSocket) => { - console.log("[WebSocket] Client connected"); +wss.on('connection', (ws: WebSocket) => { + console.log('[WebSocket] Client connected'); // Subscribe to all events and forward to this client const unsubscribe = events.subscribe((type, payload) => { @@ -185,13 +184,13 @@ wss.on("connection", (ws: WebSocket) => { } }); - ws.on("close", () => { - console.log("[WebSocket] Client disconnected"); + ws.on('close', () => { + console.log('[WebSocket] Client disconnected'); unsubscribe(); }); - ws.on("error", (error) => { - console.error("[WebSocket] Error:", error); + ws.on('error', (error) => { + console.error('[WebSocket] Error:', error); unsubscribe(); }); }); @@ -212,184 +211,176 @@ terminalService.onExit((sessionId) => { }); // Terminal WebSocket connection handler -terminalWss.on( - "connection", - (ws: WebSocket, req: import("http").IncomingMessage) => { - // Parse URL to get session ID and token - const url = new URL(req.url || "", `http://${req.headers.host}`); - const sessionId = url.searchParams.get("sessionId"); - const token = url.searchParams.get("token"); +terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage) => { + // Parse URL to get session ID and token + const url = new URL(req.url || '', `http://${req.headers.host}`); + const sessionId = url.searchParams.get('sessionId'); + const token = url.searchParams.get('token'); - console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`); + console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`); - // Check if terminal is enabled - if (!isTerminalEnabled()) { - console.log("[Terminal WS] Terminal is disabled"); - ws.close(4003, "Terminal access is disabled"); - return; - } + // Check if terminal is enabled + if (!isTerminalEnabled()) { + console.log('[Terminal WS] Terminal is disabled'); + ws.close(4003, 'Terminal access is disabled'); + return; + } - // Validate token if password is required - if ( - isTerminalPasswordRequired() && - !validateTerminalToken(token || undefined) - ) { - console.log("[Terminal WS] Invalid or missing token"); - ws.close(4001, "Authentication required"); - return; - } + // Validate token if password is required + if (isTerminalPasswordRequired() && !validateTerminalToken(token || undefined)) { + console.log('[Terminal WS] Invalid or missing token'); + ws.close(4001, 'Authentication required'); + return; + } - if (!sessionId) { - console.log("[Terminal WS] No session ID provided"); - ws.close(4002, "Session ID required"); - return; - } + if (!sessionId) { + console.log('[Terminal WS] No session ID provided'); + ws.close(4002, 'Session ID required'); + return; + } - // Check if session exists - const session = terminalService.getSession(sessionId); - if (!session) { - console.log(`[Terminal WS] Session ${sessionId} not found`); - ws.close(4004, "Session not found"); - return; - } + // Check if session exists + const session = terminalService.getSession(sessionId); + if (!session) { + console.log(`[Terminal WS] Session ${sessionId} not found`); + ws.close(4004, 'Session not found'); + return; + } - console.log(`[Terminal WS] Client connected to session ${sessionId}`); + console.log(`[Terminal WS] Client connected to session ${sessionId}`); - // Track this connection - if (!terminalConnections.has(sessionId)) { - terminalConnections.set(sessionId, new Set()); - } - terminalConnections.get(sessionId)!.add(ws); + // Track this connection + if (!terminalConnections.has(sessionId)) { + terminalConnections.set(sessionId, new Set()); + } + terminalConnections.get(sessionId)!.add(ws); - // Send initial connection success FIRST + // Send initial connection success FIRST + ws.send( + JSON.stringify({ + type: 'connected', + sessionId, + shell: session.shell, + cwd: session.cwd, + }) + ); + + // Send scrollback buffer BEFORE subscribing to prevent race condition + // Also clear pending output buffer to prevent duplicates from throttled flush + const scrollback = terminalService.getScrollbackAndClearPending(sessionId); + if (scrollback && scrollback.length > 0) { ws.send( JSON.stringify({ - type: "connected", - sessionId, - shell: session.shell, - cwd: session.cwd, + type: 'scrollback', + data: scrollback, }) ); - - // Send scrollback buffer BEFORE subscribing to prevent race condition - // Also clear pending output buffer to prevent duplicates from throttled flush - const scrollback = terminalService.getScrollbackAndClearPending(sessionId); - if (scrollback && scrollback.length > 0) { - ws.send( - JSON.stringify({ - type: "scrollback", - data: scrollback, - }) - ); - } - - // NOW subscribe to terminal data (after scrollback is sent) - const unsubscribeData = terminalService.onData((sid, data) => { - if (sid === sessionId && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "data", data })); - } - }); - - // Subscribe to terminal exit - const unsubscribeExit = terminalService.onExit((sid, exitCode) => { - if (sid === sessionId && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "exit", exitCode })); - ws.close(1000, "Session ended"); - } - }); - - // Handle incoming messages - ws.on("message", (message) => { - try { - const msg = JSON.parse(message.toString()); - - switch (msg.type) { - case "input": - // Write user input to terminal - terminalService.write(sessionId, msg.data); - break; - - case "resize": - // Resize terminal with deduplication and rate limiting - if (msg.cols && msg.rows) { - const now = Date.now(); - const lastTime = lastResizeTime.get(sessionId) || 0; - const lastDimensions = lastResizeDimensions.get(sessionId); - - // Skip if resized too recently (prevents resize storm during splits) - if (now - lastTime < RESIZE_MIN_INTERVAL_MS) { - break; - } - - // Check if dimensions are different from last resize - if ( - !lastDimensions || - lastDimensions.cols !== msg.cols || - lastDimensions.rows !== msg.rows - ) { - // Only suppress output on subsequent resizes, not the first one - // The first resize happens on terminal open and we don't want to drop the initial prompt - const isFirstResize = !lastDimensions; - terminalService.resize(sessionId, msg.cols, msg.rows, !isFirstResize); - lastResizeDimensions.set(sessionId, { - cols: msg.cols, - rows: msg.rows, - }); - lastResizeTime.set(sessionId, now); - } - } - break; - - case "ping": - // Respond to ping - ws.send(JSON.stringify({ type: "pong" })); - break; - - default: - console.warn(`[Terminal WS] Unknown message type: ${msg.type}`); - } - } catch (error) { - console.error("[Terminal WS] Error processing message:", error); - } - }); - - ws.on("close", () => { - console.log( - `[Terminal WS] Client disconnected from session ${sessionId}` - ); - unsubscribeData(); - unsubscribeExit(); - - // Remove from connections tracking - const connections = terminalConnections.get(sessionId); - if (connections) { - connections.delete(ws); - if (connections.size === 0) { - terminalConnections.delete(sessionId); - // DON'T delete lastResizeDimensions/lastResizeTime here! - // The session still exists, and reconnecting clients need to know - // this isn't the "first resize" to prevent duplicate prompts. - // These get cleaned up when the session actually exits. - } - } - }); - - ws.on("error", (error) => { - console.error(`[Terminal WS] Error on session ${sessionId}:`, error); - unsubscribeData(); - unsubscribeExit(); - }); } -); + + // NOW subscribe to terminal data (after scrollback is sent) + const unsubscribeData = terminalService.onData((sid, data) => { + if (sid === sessionId && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'data', data })); + } + }); + + // Subscribe to terminal exit + const unsubscribeExit = terminalService.onExit((sid, exitCode) => { + if (sid === sessionId && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'exit', exitCode })); + ws.close(1000, 'Session ended'); + } + }); + + // Handle incoming messages + ws.on('message', (message) => { + try { + const msg = JSON.parse(message.toString()); + + switch (msg.type) { + case 'input': + // Write user input to terminal + terminalService.write(sessionId, msg.data); + break; + + case 'resize': + // Resize terminal with deduplication and rate limiting + if (msg.cols && msg.rows) { + const now = Date.now(); + const lastTime = lastResizeTime.get(sessionId) || 0; + const lastDimensions = lastResizeDimensions.get(sessionId); + + // Skip if resized too recently (prevents resize storm during splits) + if (now - lastTime < RESIZE_MIN_INTERVAL_MS) { + break; + } + + // Check if dimensions are different from last resize + if ( + !lastDimensions || + lastDimensions.cols !== msg.cols || + lastDimensions.rows !== msg.rows + ) { + // Only suppress output on subsequent resizes, not the first one + // The first resize happens on terminal open and we don't want to drop the initial prompt + const isFirstResize = !lastDimensions; + terminalService.resize(sessionId, msg.cols, msg.rows, !isFirstResize); + lastResizeDimensions.set(sessionId, { + cols: msg.cols, + rows: msg.rows, + }); + lastResizeTime.set(sessionId, now); + } + } + break; + + case 'ping': + // Respond to ping + ws.send(JSON.stringify({ type: 'pong' })); + break; + + default: + console.warn(`[Terminal WS] Unknown message type: ${msg.type}`); + } + } catch (error) { + console.error('[Terminal WS] Error processing message:', error); + } + }); + + ws.on('close', () => { + console.log(`[Terminal WS] Client disconnected from session ${sessionId}`); + unsubscribeData(); + unsubscribeExit(); + + // Remove from connections tracking + const connections = terminalConnections.get(sessionId); + if (connections) { + connections.delete(ws); + if (connections.size === 0) { + terminalConnections.delete(sessionId); + // DON'T delete lastResizeDimensions/lastResizeTime here! + // The session still exists, and reconnecting clients need to know + // this isn't the "first resize" to prevent duplicate prompts. + // These get cleaned up when the session actually exits. + } + } + }); + + ws.on('error', (error) => { + console.error(`[Terminal WS] Error on session ${sessionId}:`, error); + unsubscribeData(); + unsubscribeExit(); + }); +}); // Start server with error handling for port conflicts const startServer = (port: number) => { server.listen(port, () => { const terminalStatus = isTerminalEnabled() ? isTerminalPasswordRequired() - ? "enabled (password protected)" - : "enabled" - : "disabled"; + ? 'enabled (password protected)' + : 'enabled' + : 'disabled'; const portStr = port.toString().padEnd(4); console.log(` ╔═══════════════════════════════════════════════════════╗ @@ -404,8 +395,8 @@ const startServer = (port: number) => { `); }); - server.on("error", (error: NodeJS.ErrnoException) => { - if (error.code === "EADDRINUSE") { + server.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { console.error(` ╔═══════════════════════════════════════════════════════╗ ║ ❌ ERROR: Port ${port} is already in use ║ @@ -426,7 +417,7 @@ const startServer = (port: number) => { `); process.exit(1); } else { - console.error("[Server] Error starting server:", error); + console.error('[Server] Error starting server:', error); process.exit(1); } }); @@ -435,20 +426,20 @@ const startServer = (port: number) => { startServer(PORT); // Graceful shutdown -process.on("SIGTERM", () => { - console.log("SIGTERM received, shutting down..."); +process.on('SIGTERM', () => { + console.log('SIGTERM received, shutting down...'); terminalService.cleanup(); server.close(() => { - console.log("Server closed"); + console.log('Server closed'); process.exit(0); }); }); -process.on("SIGINT", () => { - console.log("SIGINT received, shutting down..."); +process.on('SIGINT', () => { + console.log('SIGINT received, shutting down...'); terminalService.cleanup(); server.close(() => { - console.log("Server closed"); + console.log('Server closed'); process.exit(0); }); }); diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index ea8471e1..2ed2728d 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -5,26 +5,24 @@ * with the provider architecture. */ -import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; -import { BaseProvider } from "./base-provider.js"; +import { query, type Options } from '@anthropic-ai/claude-agent-sdk'; +import { BaseProvider } from './base-provider.js'; import type { ExecuteOptions, ProviderMessage, InstallationStatus, ModelDefinition, -} from "./types.js"; +} from './types.js'; export class ClaudeProvider extends BaseProvider { getName(): string { - return "claude"; + return 'claude'; } /** * Execute a query using Claude Agent SDK */ - async *executeQuery( - options: ExecuteOptions - ): AsyncGenerator { + async *executeQuery(options: ExecuteOptions): AsyncGenerator { const { prompt, model, @@ -38,16 +36,7 @@ export class ClaudeProvider extends BaseProvider { } = options; // Build Claude SDK options - const defaultTools = [ - "Read", - "Write", - "Edit", - "Glob", - "Grep", - "Bash", - "WebSearch", - "WebFetch", - ]; + const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; const toolsToUse = allowedTools || defaultTools; const sdkOptions: Options = { @@ -56,7 +45,7 @@ export class ClaudeProvider extends BaseProvider { maxTurns, cwd, allowedTools: toolsToUse, - permissionMode: "acceptEdits", + permissionMode: 'acceptEdits', sandbox: { enabled: true, autoAllowBashIfSandboxed: true, @@ -75,10 +64,10 @@ export class ClaudeProvider extends BaseProvider { // Multi-part prompt (with images) promptPayload = (async function* () { const multiPartPrompt = { - type: "user" as const, - session_id: "", + type: 'user' as const, + session_id: '', message: { - role: "user" as const, + role: 'user' as const, content: prompt, }, parent_tool_use_id: null, @@ -99,10 +88,7 @@ export class ClaudeProvider extends BaseProvider { yield msg as ProviderMessage; } } catch (error) { - console.error( - "[ClaudeProvider] executeQuery() error during execution:", - error - ); + console.error('[ClaudeProvider] executeQuery() error during execution:', error); throw error; } } @@ -116,7 +102,7 @@ export class ClaudeProvider extends BaseProvider { const status: InstallationStatus = { installed: true, - method: "sdk", + method: 'sdk', hasApiKey, authenticated: hasApiKey, }; @@ -130,53 +116,53 @@ export class ClaudeProvider extends BaseProvider { getAvailableModels(): ModelDefinition[] { const models = [ { - id: "claude-opus-4-5-20251101", - name: "Claude Opus 4.5", - modelString: "claude-opus-4-5-20251101", - provider: "anthropic", - description: "Most capable Claude model", + id: 'claude-opus-4-5-20251101', + name: 'Claude Opus 4.5', + modelString: 'claude-opus-4-5-20251101', + provider: 'anthropic', + description: 'Most capable Claude model', contextWindow: 200000, maxOutputTokens: 16000, supportsVision: true, supportsTools: true, - tier: "premium" as const, + tier: 'premium' as const, default: true, }, { - id: "claude-sonnet-4-20250514", - name: "Claude Sonnet 4", - modelString: "claude-sonnet-4-20250514", - provider: "anthropic", - description: "Balanced performance and cost", + id: 'claude-sonnet-4-20250514', + name: 'Claude Sonnet 4', + modelString: 'claude-sonnet-4-20250514', + provider: 'anthropic', + description: 'Balanced performance and cost', contextWindow: 200000, maxOutputTokens: 16000, supportsVision: true, supportsTools: true, - tier: "standard" as const, + tier: 'standard' as const, }, { - id: "claude-3-5-sonnet-20241022", - name: "Claude 3.5 Sonnet", - modelString: "claude-3-5-sonnet-20241022", - provider: "anthropic", - description: "Fast and capable", + id: 'claude-3-5-sonnet-20241022', + name: 'Claude 3.5 Sonnet', + modelString: 'claude-3-5-sonnet-20241022', + provider: 'anthropic', + description: 'Fast and capable', contextWindow: 200000, maxOutputTokens: 8000, supportsVision: true, supportsTools: true, - tier: "standard" as const, + tier: 'standard' as const, }, { - id: "claude-3-5-haiku-20241022", - name: "Claude 3.5 Haiku", - modelString: "claude-3-5-haiku-20241022", - provider: "anthropic", - description: "Fastest Claude model", + id: 'claude-haiku-4-5-20251001', + name: 'Claude Haiku 4.5', + modelString: 'claude-haiku-4-5-20251001', + provider: 'anthropic', + description: 'Fastest Claude model', contextWindow: 200000, maxOutputTokens: 8000, supportsVision: true, supportsTools: true, - tier: "basic" as const, + tier: 'basic' as const, }, ] satisfies ModelDefinition[]; return models; @@ -186,7 +172,7 @@ export class ClaudeProvider extends BaseProvider { * Check if the provider supports a specific feature */ supportsFeature(feature: string): boolean { - const supportedFeatures = ["tools", "text", "vision", "thinking"]; + const supportedFeatures = ['tools', 'text', 'vision', 'thinking']; return supportedFeatures.includes(feature); } } diff --git a/apps/server/src/routes/context/index.ts b/apps/server/src/routes/context/index.ts new file mode 100644 index 00000000..37e447bf --- /dev/null +++ b/apps/server/src/routes/context/index.ts @@ -0,0 +1,24 @@ +/** + * Context routes - HTTP API for context file operations + * + * Provides endpoints for managing context files including + * AI-powered image description generation. + */ + +import { Router } from 'express'; +import { createDescribeImageHandler } from './routes/describe-image.js'; +import { createDescribeFileHandler } from './routes/describe-file.js'; + +/** + * Create the context router + * + * @returns Express router with context endpoints + */ +export function createContextRoutes(): Router { + const router = Router(); + + router.post('/describe-image', createDescribeImageHandler()); + router.post('/describe-file', createDescribeFileHandler()); + + return router; +} diff --git a/apps/server/src/routes/context/routes/describe-file.ts b/apps/server/src/routes/context/routes/describe-file.ts new file mode 100644 index 00000000..06c15f3c --- /dev/null +++ b/apps/server/src/routes/context/routes/describe-file.ts @@ -0,0 +1,140 @@ +/** + * POST /context/describe-file endpoint - Generate description for a text file + * + * Uses Claude Haiku to analyze a text file and generate a concise description + * suitable for context file metadata. + */ + +import type { Request, Response } from 'express'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { createLogger } from '@automaker/utils'; +import { CLAUDE_MODEL_MAP } from '@automaker/types'; + +const logger = createLogger('DescribeFile'); + +/** + * Request body for the describe-file endpoint + */ +interface DescribeFileRequestBody { + /** Path to the file */ + filePath: string; +} + +/** + * Success response from the describe-file endpoint + */ +interface DescribeFileSuccessResponse { + success: true; + description: string; +} + +/** + * Error response from the describe-file endpoint + */ +interface DescribeFileErrorResponse { + success: false; + error: string; +} + +/** + * Extract text content from Claude SDK response messages + */ +async function extractTextFromStream( + stream: AsyncIterable<{ + type: string; + subtype?: string; + result?: string; + message?: { + content?: Array<{ type: string; text?: string }>; + }; + }> +): Promise { + let responseText = ''; + + for await (const msg of stream) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } else if (msg.type === 'result' && msg.subtype === 'success') { + responseText = msg.result || responseText; + } + } + + return responseText; +} + +/** + * Create the describe-file request handler + * + * @returns Express request handler for file description + */ +export function createDescribeFileHandler(): (req: Request, res: Response) => Promise { + return async (req: Request, res: Response): Promise => { + try { + const { filePath } = req.body as DescribeFileRequestBody; + + // Validate required fields + if (!filePath || typeof filePath !== 'string') { + const response: DescribeFileErrorResponse = { + success: false, + error: 'filePath is required and must be a string', + }; + res.status(400).json(response); + return; + } + + logger.info(`[DescribeFile] Starting description generation for: ${filePath}`); + + // Build prompt that explicitly asks to read and describe the file + const prompt = `Read the file at "${filePath}" and describe what it contains. + +After reading the file, provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project"). + +Respond with ONLY the description text, no additional formatting, preamble, or explanation.`; + + // Use Claude SDK query function - needs 3+ turns for: tool call, tool result, response + const stream = query({ + prompt, + options: { + model: CLAUDE_MODEL_MAP.haiku, + maxTurns: 3, + allowedTools: ['Read'], + permissionMode: 'acceptEdits', + }, + }); + + // Extract the description from the response + const description = await extractTextFromStream(stream); + + if (!description || description.trim().length === 0) { + logger.warn('Received empty response from Claude'); + const response: DescribeFileErrorResponse = { + success: false, + error: 'Failed to generate description - empty response', + }; + res.status(500).json(response); + return; + } + + logger.info(`Description generated, length: ${description.length} chars`); + + const response: DescribeFileSuccessResponse = { + success: true, + description: description.trim(), + }; + res.json(response); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + logger.error('File description failed:', errorMessage); + + const response: DescribeFileErrorResponse = { + success: false, + error: errorMessage, + }; + res.status(500).json(response); + } + }; +} diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts new file mode 100644 index 00000000..cba497cf --- /dev/null +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -0,0 +1,387 @@ +/** + * POST /context/describe-image endpoint - Generate description for an image + * + * Uses Claude Haiku to analyze an image and generate a concise description + * suitable for context file metadata. + * + * IMPORTANT: + * The agent runner (chat/auto-mode) sends images as multi-part content blocks (base64 image blocks), + * not by asking Claude to use the Read tool to open files. This endpoint now mirrors that approach + * so it doesn't depend on Claude's filesystem tool access or working directory restrictions. + */ + +import type { Request, Response } from 'express'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { createLogger, readImageAsBase64 } from '@automaker/utils'; +import { CLAUDE_MODEL_MAP } from '@automaker/types'; +import { createCustomOptions } from '../../../lib/sdk-options.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +const logger = createLogger('DescribeImage'); + +/** + * Find the actual file path, handling Unicode character variations. + * macOS screenshots use U+202F (NARROW NO-BREAK SPACE) before AM/PM, + * but this may be transmitted as a regular space through the API. + */ +function findActualFilePath(requestedPath: string): string | null { + // First, try the exact path + if (fs.existsSync(requestedPath)) { + return requestedPath; + } + + // Try with Unicode normalization + const normalizedPath = requestedPath.normalize('NFC'); + if (fs.existsSync(normalizedPath)) { + return normalizedPath; + } + + // If not found, try to find the file in the directory by matching the basename + // This handles cases where the space character differs (U+0020 vs U+202F vs U+00A0) + const dir = path.dirname(requestedPath); + const baseName = path.basename(requestedPath); + + if (!fs.existsSync(dir)) { + return null; + } + + try { + const files = fs.readdirSync(dir); + + // Normalize the requested basename for comparison + // Replace various space-like characters with regular space for comparison + const normalizeSpaces = (s: string): string => s.replace(/[\u00A0\u202F\u2009\u200A]/g, ' '); + + const normalizedBaseName = normalizeSpaces(baseName); + + for (const file of files) { + if (normalizeSpaces(file) === normalizedBaseName) { + logger.info(`Found matching file with different space encoding: ${file}`); + return path.join(dir, file); + } + } + } catch (err) { + logger.error(`Error reading directory ${dir}: ${err}`); + } + + return null; +} + +/** + * Request body for the describe-image endpoint + */ +interface DescribeImageRequestBody { + /** Path to the image file */ + imagePath: string; +} + +/** + * Success response from the describe-image endpoint + */ +interface DescribeImageSuccessResponse { + success: true; + description: string; +} + +/** + * Error response from the describe-image endpoint + */ +interface DescribeImageErrorResponse { + success: false; + error: string; + requestId?: string; +} + +/** + * Map SDK/CLI errors to a stable status + user-facing message. + */ +function mapDescribeImageError(rawMessage: string | undefined): { + statusCode: number; + userMessage: string; +} { + const baseResponse = { + statusCode: 500, + userMessage: 'Failed to generate an image description. Please try again.', + }; + + if (!rawMessage) return baseResponse; + + if (rawMessage.includes('Claude Code process exited')) { + return { + statusCode: 503, + userMessage: + 'Claude exited unexpectedly while describing the image. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup so Claude can restart cleanly.', + }; + } + + if ( + rawMessage.includes('Failed to spawn Claude Code process') || + rawMessage.includes('Claude Code executable not found') || + rawMessage.includes('Claude Code native binary not found') + ) { + return { + statusCode: 503, + userMessage: + 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, then try again.', + }; + } + + if (rawMessage.toLowerCase().includes('rate limit') || rawMessage.includes('429')) { + return { + statusCode: 429, + userMessage: 'Rate limited while describing the image. Please wait a moment and try again.', + }; + } + + if (rawMessage.toLowerCase().includes('payload too large') || rawMessage.includes('413')) { + return { + statusCode: 413, + userMessage: + 'The image is too large to send for description. Please resize/compress it and try again.', + }; + } + + return baseResponse; +} + +/** + * Extract text content from Claude SDK response messages and log high-signal stream events. + */ +async function extractTextFromStream( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stream: AsyncIterable, + requestId: string +): Promise { + let responseText = ''; + let messageCount = 0; + + logger.info(`[${requestId}] [Stream] Begin reading SDK stream...`); + + for await (const msg of stream) { + messageCount++; + const msgType = msg?.type; + const msgSubtype = msg?.subtype; + + // Keep this concise but informative. Full error object is logged in catch blocks. + logger.info( + `[${requestId}] [Stream] #${messageCount} type=${String(msgType)} subtype=${String(msgSubtype ?? '')}` + ); + + if (msgType === 'assistant' && msg.message?.content) { + const blocks = msg.message.content as Array<{ type: string; text?: string }>; + logger.info(`[${requestId}] [Stream] assistant blocks=${blocks.length}`); + for (const block of blocks) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } + + if (msgType === 'result' && msgSubtype === 'success') { + if (typeof msg.result === 'string' && msg.result.length > 0) { + responseText = msg.result; + } + } + } + + logger.info( + `[${requestId}] [Stream] End of stream. messages=${messageCount} textLength=${responseText.length}` + ); + + return responseText; +} + +/** + * Create the describe-image request handler + * + * Uses Claude SDK query with multi-part content blocks to include the image (base64), + * matching the agent runner behavior. + * + * @returns Express request handler for image description + */ +export function createDescribeImageHandler(): (req: Request, res: Response) => Promise { + return async (req: Request, res: Response): Promise => { + const requestId = `describe-image-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + const startedAt = Date.now(); + + // Request envelope logs (high value when correlating failures) + logger.info(`[${requestId}] ===== POST /api/context/describe-image =====`); + logger.info(`[${requestId}] headers=${JSON.stringify(req.headers)}`); + logger.info(`[${requestId}] body=${JSON.stringify(req.body)}`); + + try { + const { imagePath } = req.body as DescribeImageRequestBody; + + // Validate required fields + if (!imagePath || typeof imagePath !== 'string') { + const response: DescribeImageErrorResponse = { + success: false, + error: 'imagePath is required and must be a string', + requestId, + }; + res.status(400).json(response); + return; + } + + logger.info(`[${requestId}] imagePath="${imagePath}" type=${typeof imagePath}`); + + // Find the actual file path (handles Unicode space character variations) + const actualPath = findActualFilePath(imagePath); + if (!actualPath) { + logger.error(`[${requestId}] File not found: ${imagePath}`); + // Log hex representation of the path for debugging + const hexPath = Buffer.from(imagePath).toString('hex'); + logger.error(`[${requestId}] imagePath hex: ${hexPath}`); + const response: DescribeImageErrorResponse = { + success: false, + error: `File not found: ${imagePath}`, + requestId, + }; + res.status(404).json(response); + return; + } + + if (actualPath !== imagePath) { + logger.info(`[${requestId}] Using actual path: ${actualPath}`); + } + + // Log path + stats (this is often where issues start: missing file, perms, size) + let stat: fs.Stats | null = null; + try { + stat = fs.statSync(actualPath); + logger.info( + `[${requestId}] fileStats size=${stat.size} bytes mtime=${stat.mtime.toISOString()}` + ); + } catch (statErr) { + logger.warn( + `[${requestId}] Unable to stat image file (continuing to read base64): ${String(statErr)}` + ); + } + + // Read image and convert to base64 (same as agent runner) + logger.info(`[${requestId}] Reading image into base64...`); + const imageReadStart = Date.now(); + const imageData = await readImageAsBase64(actualPath); + const imageReadMs = Date.now() - imageReadStart; + + const base64Length = imageData.base64.length; + const estimatedBytes = Math.ceil((base64Length * 3) / 4); + logger.info(`[${requestId}] imageReadMs=${imageReadMs}`); + logger.info( + `[${requestId}] image meta filename=${imageData.filename} mime=${imageData.mimeType} base64Len=${base64Length} estBytes=${estimatedBytes}` + ); + + // Build multi-part prompt with image block (no Read tool required) + const instructionText = + `Describe this image in 1-2 sentences suitable for use as context in an AI coding assistant. ` + + `Focus on what the image shows and its purpose (e.g., "UI mockup showing login form with email/password fields", ` + + `"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` + + `Respond with ONLY the description text, no additional formatting, preamble, or explanation.`; + + const promptContent = [ + { type: 'text' as const, text: instructionText }, + { + type: 'image' as const, + source: { + type: 'base64' as const, + media_type: imageData.mimeType, + data: imageData.base64, + }, + }, + ]; + + logger.info(`[${requestId}] Built multi-part prompt blocks=${promptContent.length}`); + + const cwd = path.dirname(actualPath); + logger.info(`[${requestId}] Using cwd=${cwd}`); + + // Use the same centralized option builder used across the server (validates cwd) + const sdkOptions = createCustomOptions({ + cwd, + model: CLAUDE_MODEL_MAP.haiku, + maxTurns: 1, + allowedTools: [], + sandbox: { enabled: true, autoAllowBashIfSandboxed: true }, + }); + + logger.info( + `[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify( + sdkOptions.allowedTools + )} sandbox=${JSON.stringify(sdkOptions.sandbox)}` + ); + + const promptGenerator = (async function* () { + yield { + type: 'user' as const, + session_id: '', + message: { role: 'user' as const, content: promptContent }, + parent_tool_use_id: null, + }; + })(); + + logger.info(`[${requestId}] Calling query()...`); + const queryStart = Date.now(); + const stream = query({ prompt: promptGenerator, options: sdkOptions }); + logger.info(`[${requestId}] query() returned stream in ${Date.now() - queryStart}ms`); + + // Extract the description from the response + const extractStart = Date.now(); + const description = await extractTextFromStream(stream, requestId); + logger.info(`[${requestId}] extractMs=${Date.now() - extractStart}`); + + if (!description || description.trim().length === 0) { + logger.warn(`[${requestId}] Received empty response from Claude`); + const response: DescribeImageErrorResponse = { + success: false, + error: 'Failed to generate description - empty response', + requestId, + }; + res.status(500).json(response); + return; + } + + const totalMs = Date.now() - startedAt; + logger.info(`[${requestId}] Success descriptionLen=${description.length} totalMs=${totalMs}`); + + const response: DescribeImageSuccessResponse = { + success: true, + description: description.trim(), + }; + res.json(response); + } catch (error) { + const totalMs = Date.now() - startedAt; + const err = error as unknown; + const errMessage = err instanceof Error ? err.message : String(err); + const errName = err instanceof Error ? err.name : 'UnknownError'; + const errStack = err instanceof Error ? err.stack : undefined; + + logger.error(`[${requestId}] FAILED totalMs=${totalMs}`); + logger.error(`[${requestId}] errorName=${errName}`); + logger.error(`[${requestId}] errorMessage=${errMessage}`); + if (errStack) logger.error(`[${requestId}] errorStack=${errStack}`); + + // Dump all enumerable + non-enumerable props (this is where stderr/stdout/exitCode often live) + try { + const props = err && typeof err === 'object' ? Object.getOwnPropertyNames(err) : []; + logger.error(`[${requestId}] errorProps=${JSON.stringify(props)}`); + if (err && typeof err === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anyErr = err as any; + const details = JSON.stringify(anyErr, props as unknown as string[]); + logger.error(`[${requestId}] errorDetails=${details}`); + } + } catch (stringifyErr) { + logger.error(`[${requestId}] Failed to serialize error object: ${String(stringifyErr)}`); + } + + const { statusCode, userMessage } = mapDescribeImageError(errMessage); + const response: DescribeImageErrorResponse = { + success: false, + error: `${userMessage} (requestId: ${requestId})`, + requestId, + }; + res.status(statusCode).json(response); + } + }; +} diff --git a/apps/ui/src/components/dialogs/board-background-modal.tsx b/apps/ui/src/components/dialogs/board-background-modal.tsx index 3244dfdf..2738ec79 100644 --- a/apps/ui/src/components/dialogs/board-background-modal.tsx +++ b/apps/ui/src/components/dialogs/board-background-modal.tsx @@ -1,41 +1,34 @@ - -import { useState, useRef, useCallback, useEffect } from "react"; -import { ImageIcon, Upload, Loader2, Trash2 } from "lucide-react"; +import { useState, useRef, useCallback, useEffect } from 'react'; +import { ImageIcon, Upload, Loader2, Trash2 } from 'lucide-react'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, -} from "@/components/ui/sheet"; -import { Button } from "@/components/ui/button"; -import { Slider } from "@/components/ui/slider"; -import { Label } from "@/components/ui/label"; -import { Checkbox } from "@/components/ui/checkbox"; -import { cn } from "@/lib/utils"; -import { useAppStore, defaultBackgroundSettings } from "@/store/app-store"; -import { getHttpApiClient } from "@/lib/http-api-client"; -import { useBoardBackgroundSettings } from "@/hooks/use-board-background-settings"; -import { toast } from "sonner"; - -const ACCEPTED_IMAGE_TYPES = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/webp", -]; -const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB +} from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Slider } from '@/components/ui/slider'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { cn } from '@/lib/utils'; +import { useAppStore, defaultBackgroundSettings } from '@/store/app-store'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings'; +import { toast } from 'sonner'; +import { + fileToBase64, + validateImageFile, + ACCEPTED_IMAGE_TYPES, + DEFAULT_MAX_FILE_SIZE, +} from '@/lib/image-utils'; interface BoardBackgroundModalProps { open: boolean; onOpenChange: (open: boolean) => void; } -export function BoardBackgroundModal({ - open, - onOpenChange, -}: BoardBackgroundModalProps) { +export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModalProps) { const { currentProject, boardBackgroundByProject } = useAppStore(); const { setBoardBackground, @@ -55,8 +48,7 @@ export function BoardBackgroundModal({ // Get current background settings (live from store) const backgroundSettings = - (currentProject && boardBackgroundByProject[currentProject.path]) || - defaultBackgroundSettings; + (currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings; const cardOpacity = backgroundSettings.cardOpacity; const columnOpacity = backgroundSettings.columnOpacity; @@ -70,12 +62,9 @@ export function BoardBackgroundModal({ // Update preview image when background settings change useEffect(() => { if (currentProject && backgroundSettings.imagePath) { - const serverUrl = - import.meta.env.VITE_SERVER_URL || "http://localhost:3008"; + const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; // Add cache-busting query parameter to force browser to reload image - const cacheBuster = imageVersion - ? `&v=${imageVersion}` - : `&v=${Date.now()}`; + const cacheBuster = imageVersion ? `&v=${imageVersion}` : `&v=${Date.now()}`; const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent( backgroundSettings.imagePath )}&projectPath=${encodeURIComponent(currentProject.path)}${cacheBuster}`; @@ -85,40 +74,17 @@ export function BoardBackgroundModal({ } }, [currentProject, backgroundSettings.imagePath, imageVersion]); - const fileToBase64 = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === "string") { - resolve(reader.result); - } else { - reject(new Error("Failed to read file as base64")); - } - }; - reader.onerror = () => reject(new Error("Failed to read file")); - reader.readAsDataURL(file); - }); - }; - const processFile = useCallback( async (file: File) => { if (!currentProject) { - toast.error("No project selected"); + toast.error('No project selected'); return; } - // Validate file type - if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - toast.error( - "Unsupported file type. Please use JPG, PNG, GIF, or WebP." - ); - return; - } - - // Validate file size - if (file.size > DEFAULT_MAX_FILE_SIZE) { - const maxSizeMB = DEFAULT_MAX_FILE_SIZE / (1024 * 1024); - toast.error(`File too large. Maximum size is ${maxSizeMB}MB.`); + // Validate file + const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE); + if (!validation.isValid) { + toast.error(validation.error); return; } @@ -141,14 +107,14 @@ export function BoardBackgroundModal({ if (result.success && result.path) { // Update store and persist to server await setBoardBackground(currentProject.path, result.path); - toast.success("Background image saved"); + toast.success('Background image saved'); } else { - toast.error(result.error || "Failed to save background image"); + toast.error(result.error || 'Failed to save background image'); setPreviewImage(null); } } catch (error) { - console.error("Failed to process image:", error); - toast.error("Failed to process image"); + console.error('Failed to process image:', error); + toast.error('Failed to process image'); setPreviewImage(null); } finally { setIsProcessing(false); @@ -191,7 +157,7 @@ export function BoardBackgroundModal({ } // Reset the input so the same file can be selected again if (fileInputRef.current) { - fileInputRef.current.value = ""; + fileInputRef.current.value = ''; } }, [processFile] @@ -209,20 +175,18 @@ export function BoardBackgroundModal({ try { setIsProcessing(true); const httpClient = getHttpApiClient(); - const result = await httpClient.deleteBoardBackground( - currentProject.path - ); + const result = await httpClient.deleteBoardBackground(currentProject.path); if (result.success) { await clearBoardBackground(currentProject.path); setPreviewImage(null); - toast.success("Background image cleared"); + toast.success('Background image cleared'); } else { - toast.error(result.error || "Failed to clear background image"); + toast.error(result.error || 'Failed to clear background image'); } } catch (error) { - console.error("Failed to clear background:", error); - toast.error("Failed to clear background"); + console.error('Failed to clear background:', error); + toast.error('Failed to clear background'); } finally { setIsProcessing(false); } @@ -298,8 +262,7 @@ export function BoardBackgroundModal({ Board Background Settings - Set a custom background image for your kanban board and adjust - card/column opacity + Set a custom background image for your kanban board and adjust card/column opacity @@ -312,7 +275,7 @@ export function BoardBackgroundModal({
{isProcessing ? ( @@ -393,12 +355,12 @@ export function BoardBackgroundModal({

{isDragOver && !isProcessing - ? "Drop image here" - : "Click to upload or drag and drop"} + ? 'Drop image here' + : 'Click to upload or drag and drop'}

- JPG, PNG, GIF, or WebP (max{" "} - {Math.round(DEFAULT_MAX_FILE_SIZE / (1024 * 1024))}MB) + JPG, PNG, GIF, or WebP (max {Math.round(DEFAULT_MAX_FILE_SIZE / (1024 * 1024))} + MB)

)} @@ -410,9 +372,7 @@ export function BoardBackgroundModal({
- - {cardOpacity}% - + {cardOpacity}%
- - {columnOpacity}% - + {columnOpacity}%
-
@@ -485,9 +440,7 @@ export function BoardBackgroundModal({
- - {cardBorderOpacity}% - + {cardBorderOpacity}%
; +// Re-export for convenience +export type { FeatureImagePath, FeatureTextFilePath }; + interface DescriptionImageDropZoneProps { value: string; onChange: (value: string) => void; images: FeatureImagePath[]; onImagesChange: (images: FeatureImagePath[]) => void; + textFiles?: FeatureTextFilePath[]; + onTextFilesChange?: (textFiles: FeatureTextFilePath[]) => void; placeholder?: string; className?: string; disabled?: boolean; @@ -25,14 +45,13 @@ interface DescriptionImageDropZoneProps { error?: boolean; // Show error state with red border } -const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; -const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB - export function DescriptionImageDropZone({ value, onChange, images, onImagesChange, + textFiles = [], + onTextFilesChange, placeholder = 'Describe the feature...', className, disabled = false, @@ -81,21 +100,6 @@ export function DescriptionImageDropZone({ [currentProject?.path] ); - const fileToBase64 = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === 'string') { - resolve(reader.result); - } else { - reject(new Error('Failed to read file as base64')); - } - }; - reader.onerror = () => reject(new Error('Failed to read file')); - reader.readAsDataURL(file); - }); - }; - const saveImageToTemp = useCallback( async (base64Data: string, filename: string, mimeType: string): Promise => { try { @@ -129,54 +133,89 @@ export function DescriptionImageDropZone({ setIsProcessing(true); const newImages: FeatureImagePath[] = []; + const newTextFiles: FeatureTextFilePath[] = []; const newPreviews = new Map(previewImages); const errors: string[] = []; + // Calculate total current files + const currentTotalFiles = images.length + textFiles.length; + for (const file of Array.from(files)) { - // Validate file type - if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); - continue; - } - - // Validate file size - if (file.size > maxFileSize) { - const maxSizeMB = maxFileSize / (1024 * 1024); - errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); - continue; - } - - // Check if we've reached max files - if (newImages.length + images.length >= maxFiles) { - errors.push(`Maximum ${maxFiles} images allowed.`); - break; - } - - try { - const base64 = await fileToBase64(file); - const tempPath = await saveImageToTemp(base64, file.name, file.type); - - if (tempPath) { - const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; - const imagePathRef: FeatureImagePath = { - id: imageId, - path: tempPath, - filename: file.name, - mimeType: file.type, - }; - newImages.push(imagePathRef); - // Store preview for display - newPreviews.set(imageId, base64); - } else { - errors.push(`${file.name}: Failed to save image.`); + // Check if it's a text file + if (isTextFile(file)) { + const validation = validateTextFile(file, DEFAULT_MAX_TEXT_FILE_SIZE); + if (!validation.isValid) { + errors.push(validation.error!); + continue; } - } catch { - errors.push(`${file.name}: Failed to process image.`); + + // Check if we've reached max files + const totalFiles = newImages.length + newTextFiles.length + currentTotalFiles; + if (totalFiles >= maxFiles) { + errors.push(`Maximum ${maxFiles} files allowed.`); + break; + } + + try { + const content = await fileToText(file); + const sanitizedName = sanitizeFilename(file.name); + const textFilePath: FeatureTextFilePath = { + id: generateFileId(), + path: '', // Text files don't need to be saved to disk + filename: sanitizedName, + mimeType: getTextFileMimeType(file.name), + content, + }; + newTextFiles.push(textFilePath); + } catch { + errors.push(`${file.name}: Failed to read text file.`); + } + } + // Check if it's an image file + else if (isImageFile(file)) { + // Validate file size + if (file.size > maxFileSize) { + const maxSizeMB = maxFileSize / (1024 * 1024); + errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); + continue; + } + + // Check if we've reached max files + const totalFiles = newImages.length + newTextFiles.length + currentTotalFiles; + if (totalFiles >= maxFiles) { + errors.push(`Maximum ${maxFiles} files allowed.`); + break; + } + + try { + const base64 = await fileToBase64(file); + const sanitizedName = sanitizeFilename(file.name); + const tempPath = await saveImageToTemp(base64, sanitizedName, file.type); + + if (tempPath) { + const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + const imagePathRef: FeatureImagePath = { + id: imageId, + path: tempPath, + filename: sanitizedName, + mimeType: file.type, + }; + newImages.push(imagePathRef); + // Store preview for display + newPreviews.set(imageId, base64); + } else { + errors.push(`${file.name}: Failed to save image.`); + } + } catch { + errors.push(`${file.name}: Failed to process image.`); + } + } else { + errors.push(`${file.name}: Unsupported file type. Use images, .txt, or .md files.`); } } if (errors.length > 0) { - console.warn('Image upload errors:', errors); + console.warn('File upload errors:', errors); } if (newImages.length > 0) { @@ -184,15 +223,21 @@ export function DescriptionImageDropZone({ setPreviewImages(newPreviews); } + if (newTextFiles.length > 0 && onTextFilesChange) { + onTextFilesChange([...textFiles, ...newTextFiles]); + } + setIsProcessing(false); }, [ disabled, isProcessing, images, + textFiles, maxFiles, maxFileSize, onImagesChange, + onTextFilesChange, previewImages, saveImageToTemp, ] @@ -263,6 +308,15 @@ export function DescriptionImageDropZone({ [images, onImagesChange] ); + const removeTextFile = useCallback( + (fileId: string) => { + if (onTextFilesChange) { + onTextFilesChange(textFiles.filter((file) => file.id !== fileId)); + } + }, + [textFiles, onTextFilesChange] + ); + // Handle paste events to detect and process images from clipboard // Works across all OS (Windows, Linux, macOS) const handlePaste = useCallback( @@ -314,11 +368,11 @@ export function DescriptionImageDropZone({ ref={fileInputRef} type="file" multiple - accept={ACCEPTED_IMAGE_TYPES.join(',')} + accept={[...ACCEPTED_IMAGE_TYPES, ...ACCEPTED_TEXT_EXTENSIONS].join(',')} onChange={handleFileSelect} className="hidden" disabled={disabled} - data-testid="description-image-input" + data-testid="description-file-input" /> {/* Drop zone wrapper */} @@ -338,7 +392,7 @@ export function DescriptionImageDropZone({ >
- Drop images here + Drop files here
)} @@ -359,7 +413,7 @@ export function DescriptionImageDropZone({ {/* Hint text */}

- Paste, drag and drop images, or{' '} + Paste, drag and drop files, or{' '} {' '} - to attach context images + to attach context (images, .txt, .md)

{/* Processing indicator */} {isProcessing && (
- Saving images... + Processing files...
)} - {/* Image previews */} - {images.length > 0 && ( -
+ {/* File previews (images and text files) */} + {(images.length > 0 || textFiles.length > 0) && ( +

- {images.length} image{images.length > 1 ? 's' : ''} attached + {images.length + textFiles.length} file + {images.length + textFiles.length > 1 ? 's' : ''} attached

+ {/* Image previews */} {images.map((image) => (
))} + {/* Text file previews */} + {textFiles.map((file) => ( +
+ {/* Text file icon */} +
+ +
+ {/* Remove button */} + {!disabled && ( + + )} + {/* Filename and size tooltip on hover */} +
+

{file.filename}

+

{formatFileSize(file.content.length)}

+
+
+ ))}
)} diff --git a/apps/ui/src/components/ui/feature-image-upload.tsx b/apps/ui/src/components/ui/feature-image-upload.tsx index 0cb5403c..4722502e 100644 --- a/apps/ui/src/components/ui/feature-image-upload.tsx +++ b/apps/ui/src/components/ui/feature-image-upload.tsx @@ -1,6 +1,14 @@ import React, { useState, useRef, useCallback } from 'react'; import { cn } from '@/lib/utils'; import { ImageIcon, X, Upload } from 'lucide-react'; +import { + fileToBase64, + generateImageId, + ACCEPTED_IMAGE_TYPES, + DEFAULT_MAX_FILE_SIZE, + DEFAULT_MAX_FILES, + validateImageFile, +} from '@/lib/image-utils'; export interface FeatureImage { id: string; @@ -19,13 +27,10 @@ interface FeatureImageUploadProps { disabled?: boolean; } -const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; -const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB - export function FeatureImageUpload({ images, onImagesChange, - maxFiles = 5, + maxFiles = DEFAULT_MAX_FILES, maxFileSize = DEFAULT_MAX_FILE_SIZE, className, disabled = false, @@ -34,21 +39,6 @@ export function FeatureImageUpload({ const [isProcessing, setIsProcessing] = useState(false); const fileInputRef = useRef(null); - const fileToBase64 = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === 'string') { - resolve(reader.result); - } else { - reject(new Error('Failed to read file as base64')); - } - }; - reader.onerror = () => reject(new Error('Failed to read file')); - reader.readAsDataURL(file); - }); - }; - const processFiles = useCallback( async (files: FileList) => { if (disabled || isProcessing) return; @@ -58,16 +48,10 @@ export function FeatureImageUpload({ const errors: string[] = []; for (const file of Array.from(files)) { - // Validate file type - if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); - continue; - } - - // Validate file size - if (file.size > maxFileSize) { - const maxSizeMB = maxFileSize / (1024 * 1024); - errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); + // Validate file + const validation = validateImageFile(file, maxFileSize); + if (!validation.isValid) { + errors.push(validation.error!); continue; } @@ -80,7 +64,7 @@ export function FeatureImageUpload({ try { const base64 = await fileToBase64(file); const imageAttachment: FeatureImage = { - id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + id: generateImageId(), data: base64, mimeType: file.type, filename: file.name, diff --git a/apps/ui/src/components/ui/image-drop-zone.tsx b/apps/ui/src/components/ui/image-drop-zone.tsx index 04e53491..2f8f5c43 100644 --- a/apps/ui/src/components/ui/image-drop-zone.tsx +++ b/apps/ui/src/components/ui/image-drop-zone.tsx @@ -2,6 +2,15 @@ import React, { useState, useRef, useCallback } from 'react'; import { cn } from '@/lib/utils'; import { ImageIcon, X, Upload } from 'lucide-react'; import type { ImageAttachment } from '@/store/app-store'; +import { + fileToBase64, + generateImageId, + formatFileSize, + validateImageFile, + ACCEPTED_IMAGE_TYPES, + DEFAULT_MAX_FILE_SIZE, + DEFAULT_MAX_FILES, +} from '@/lib/image-utils'; interface ImageDropZoneProps { onImagesSelected: (images: ImageAttachment[]) => void; @@ -13,12 +22,9 @@ interface ImageDropZoneProps { images?: ImageAttachment[]; // Optional controlled images prop } -const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; -const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB - export function ImageDropZone({ onImagesSelected, - maxFiles = 5, + maxFiles = DEFAULT_MAX_FILES, maxFileSize = DEFAULT_MAX_FILE_SIZE, className, children, @@ -53,16 +59,10 @@ export function ImageDropZone({ const errors: string[] = []; for (const file of Array.from(files)) { - // Validate file type - if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); - continue; - } - - // Validate file size - if (file.size > maxFileSize) { - const maxSizeMB = maxFileSize / (1024 * 1024); - errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); + // Validate file + const validation = validateImageFile(file, maxFileSize); + if (!validation.isValid) { + errors.push(validation.error!); continue; } @@ -75,7 +75,7 @@ export function ImageDropZone({ try { const base64 = await fileToBase64(file); const imageAttachment: ImageAttachment = { - id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + id: generateImageId(), data: base64, mimeType: file.type, filename: file.name, @@ -89,7 +89,6 @@ export function ImageDropZone({ if (errors.length > 0) { console.warn('Image upload errors:', errors); - // You could show these errors to the user via a toast or notification } if (newImages.length > 0) { @@ -282,26 +281,3 @@ export function ImageDropZone({
); } - -function fileToBase64(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === 'string') { - resolve(reader.result); - } else { - reject(new Error('Failed to read file as base64')); - } - }; - reader.onerror = () => reject(new Error('Failed to read file')); - reader.readAsDataURL(file); - }); -} - -function formatFileSize(bytes: number): string { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; -} diff --git a/apps/ui/src/components/views/agent-view.tsx b/apps/ui/src/components/views/agent-view.tsx index 0d6a7f77..d5e9ea8a 100644 --- a/apps/ui/src/components/views/agent-view.tsx +++ b/apps/ui/src/components/views/agent-view.tsx @@ -16,12 +16,26 @@ import { X, ImageIcon, ChevronDown, + FileText, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useElectronAgent } from '@/hooks/use-electron-agent'; import { SessionManager } from '@/components/session-manager'; import { Markdown } from '@/components/ui/markdown'; -import type { ImageAttachment } from '@/store/app-store'; +import type { ImageAttachment, TextFileAttachment } from '@/store/app-store'; +import { + fileToBase64, + generateImageId, + generateFileId, + validateImageFile, + validateTextFile, + isTextFile, + isImageFile, + fileToText, + getTextFileMimeType, + DEFAULT_MAX_FILE_SIZE, + DEFAULT_MAX_FILES, +} from '@/lib/image-utils'; import { useKeyboardShortcuts, useKeyboardShortcutsConfig, @@ -40,6 +54,7 @@ export function AgentView() { const shortcuts = useKeyboardShortcutsConfig(); const [input, setInput] = useState(''); const [selectedImages, setSelectedImages] = useState([]); + const [selectedTextFiles, setSelectedTextFiles] = useState([]); const [showImageDropZone, setShowImageDropZone] = useState(false); const [currentTool, setCurrentTool] = useState(null); const [currentSessionId, setCurrentSessionId] = useState(null); @@ -116,17 +131,23 @@ export function AgentView() { }, [currentProject?.path]); const handleSend = useCallback(async () => { - if ((!input.trim() && selectedImages.length === 0) || isProcessing) return; + if ( + (!input.trim() && selectedImages.length === 0 && selectedTextFiles.length === 0) || + isProcessing + ) + return; const messageContent = input; const messageImages = selectedImages; + const messageTextFiles = selectedTextFiles; setInput(''); setSelectedImages([]); + setSelectedTextFiles([]); setShowImageDropZone(false); - await sendMessage(messageContent, messageImages); - }, [input, selectedImages, isProcessing, sendMessage]); + await sendMessage(messageContent, messageImages, messageTextFiles); + }, [input, selectedImages, selectedTextFiles, isProcessing, sendMessage]); const handleImagesSelected = useCallback((images: ImageAttachment[]) => { setSelectedImages(images); @@ -136,84 +157,99 @@ export function AgentView() { setShowImageDropZone(!showImageDropZone); }, [showImageDropZone]); - // Helper function to convert file to base64 - const fileToBase64 = useCallback((file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === 'string') { - resolve(reader.result); - } else { - reject(new Error('Failed to read file as base64')); - } - }; - reader.onerror = () => reject(new Error('Failed to read file')); - reader.readAsDataURL(file); - }); - }, []); - - // Process dropped files + // Process dropped files (images and text files) const processDroppedFiles = useCallback( async (files: FileList) => { if (isProcessing) return; - const ACCEPTED_IMAGE_TYPES = [ - 'image/jpeg', - 'image/jpg', - 'image/png', - 'image/gif', - 'image/webp', - ]; - const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB - const MAX_FILES = 5; - const newImages: ImageAttachment[] = []; + const newTextFiles: TextFileAttachment[] = []; const errors: string[] = []; for (const file of Array.from(files)) { - // Validate file type - if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); - continue; - } + // Check if it's a text file + if (isTextFile(file)) { + const validation = validateTextFile(file); + if (!validation.isValid) { + errors.push(validation.error!); + continue; + } - // Validate file size - if (file.size > MAX_FILE_SIZE) { - const maxSizeMB = MAX_FILE_SIZE / (1024 * 1024); - errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); - continue; - } + // Check if we've reached max files + const totalFiles = + newImages.length + + selectedImages.length + + newTextFiles.length + + selectedTextFiles.length; + if (totalFiles >= DEFAULT_MAX_FILES) { + errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`); + break; + } - // Check if we've reached max files - if (newImages.length + selectedImages.length >= MAX_FILES) { - errors.push(`Maximum ${MAX_FILES} images allowed.`); - break; + try { + const content = await fileToText(file); + const textFileAttachment: TextFileAttachment = { + id: generateFileId(), + content, + mimeType: getTextFileMimeType(file.name), + filename: file.name, + size: file.size, + }; + newTextFiles.push(textFileAttachment); + } catch { + errors.push(`${file.name}: Failed to read text file.`); + } } + // Check if it's an image file + else if (isImageFile(file)) { + const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE); + if (!validation.isValid) { + errors.push(validation.error!); + continue; + } - try { - const base64 = await fileToBase64(file); - const imageAttachment: ImageAttachment = { - id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - data: base64, - mimeType: file.type, - filename: file.name, - size: file.size, - }; - newImages.push(imageAttachment); - } catch (error) { - errors.push(`${file.name}: Failed to process image.`); + // Check if we've reached max files + const totalFiles = + newImages.length + + selectedImages.length + + newTextFiles.length + + selectedTextFiles.length; + if (totalFiles >= DEFAULT_MAX_FILES) { + errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`); + break; + } + + try { + const base64 = await fileToBase64(file); + const imageAttachment: ImageAttachment = { + id: generateImageId(), + data: base64, + mimeType: file.type, + filename: file.name, + size: file.size, + }; + newImages.push(imageAttachment); + } catch { + errors.push(`${file.name}: Failed to process image.`); + } + } else { + errors.push(`${file.name}: Unsupported file type. Use images, .txt, or .md files.`); } } if (errors.length > 0) { - console.warn('Image upload errors:', errors); + console.warn('File upload errors:', errors); } if (newImages.length > 0) { setSelectedImages((prev) => [...prev, ...newImages]); } + + if (newTextFiles.length > 0) { + setSelectedTextFiles((prev) => [...prev, ...newTextFiles]); + } }, - [isProcessing, selectedImages, fileToBase64] + [isProcessing, selectedImages, selectedTextFiles] ); // Remove individual image @@ -221,6 +257,11 @@ export function AgentView() { setSelectedImages((prev) => prev.filter((img) => img.id !== imageId)); }, []); + // Remove individual text file + const removeTextFile = useCallback((fileId: string) => { + setSelectedTextFiles((prev) => prev.filter((file) => file.id !== fileId)); + }, []); + // Drag and drop handlers for the input area const handleDragEnter = useCallback( (e: React.DragEvent) => { @@ -720,16 +761,19 @@ export function AgentView() { /> )} - {/* Selected Images Preview - only show when ImageDropZone is hidden to avoid duplicate display */} - {selectedImages.length > 0 && !showImageDropZone && ( + {/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */} + {(selectedImages.length > 0 || selectedTextFiles.length > 0) && !showImageDropZone && (

- {selectedImages.length} image - {selectedImages.length > 1 ? 's' : ''} attached + {selectedImages.length + selectedTextFiles.length} file + {selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''} attached

+ {/* Image attachments */} {selectedImages.map((image) => (
))} + {/* Text file attachments */} + {selectedTextFiles.map((file) => ( +
+ {/* File icon */} +
+ +
+ {/* File info */} +
+

+ {file.filename} +

+

+ {formatFileSize(file.size)} +

+
+ {/* Remove button */} + +
+ ))}
)} @@ -792,7 +866,7 @@ export function AgentView() { setInput(e.target.value)} @@ -803,14 +877,15 @@ export function AgentView() { className={cn( 'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all', 'focus:ring-2 focus:ring-primary/20 focus:border-primary/50', - selectedImages.length > 0 && 'border-primary/30', + (selectedImages.length > 0 || selectedTextFiles.length > 0) && + 'border-primary/30', isDragOver && 'border-primary bg-primary/5' )} /> - {selectedImages.length > 0 && !isDragOver && ( + {(selectedImages.length > 0 || selectedTextFiles.length > 0) && !isDragOver && (
- {selectedImages.length} image - {selectedImages.length > 1 ? 's' : ''} + {selectedImages.length + selectedTextFiles.length} file + {selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''}
)} {isDragOver && ( @@ -821,7 +896,7 @@ export function AgentView() { )}
- {/* Image Attachment Button */} + {/* File Attachment Button */} @@ -841,7 +917,11 @@ export function AgentView() { - setEnhancementMode("improve")} - > + setEnhancementMode('improve')}> Improve Clarity - setEnhancementMode("technical")} - > + setEnhancementMode('technical')}> Add Technical Details - setEnhancementMode("simplify")} - > + setEnhancementMode('simplify')}> Simplify - setEnhancementMode("acceptance")} - > + setEnhancementMode('acceptance')}> Add Acceptance Criteria @@ -422,9 +399,7 @@ export function AddFeatureDialog({ - setNewFeature({ ...newFeature, category: value }) - } + onChange={(value) => setNewFeature({ ...newFeature, category: value })} suggestions={categorySuggestions} placeholder="e.g., Core, UI, API" data-testid="feature-category-input" @@ -435,9 +410,7 @@ export function AddFeatureDialog({ useCurrentBranch={useCurrentBranch} onUseCurrentBranchChange={setUseCurrentBranch} branchName={newFeature.branchName} - onBranchNameChange={(value) => - setNewFeature({ ...newFeature, branchName: value }) - } + onBranchNameChange={(value) => setNewFeature({ ...newFeature, branchName: value })} branchSuggestions={branchSuggestions} branchCardCounts={branchCardCounts} currentBranch={currentBranch} @@ -448,25 +421,18 @@ export function AddFeatureDialog({ {/* Priority Selector */} - setNewFeature({ ...newFeature, priority }) - } + onPrioritySelect={(priority) => setNewFeature({ ...newFeature, priority })} testIdPrefix="priority" /> {/* Model Tab */} - + {/* Show Advanced Options Toggle */} {showProfilesOnly && (
-

- Simple Mode Active -

+

Simple Mode Active

Only showing AI profiles. Advanced model tweaking is hidden.

@@ -478,7 +444,7 @@ export function AddFeatureDialog({ data-testid="show-advanced-options-toggle" > - {showAdvancedOptions ? "Hide" : "Show"} Advanced + {showAdvancedOptions ? 'Hide' : 'Show'} Advanced
)} @@ -492,23 +458,19 @@ export function AddFeatureDialog({ showManageLink onManageLinkClick={() => { onOpenChange(false); - navigate({ to: "/profiles" }); + navigate({ to: '/profiles' }); }} /> {/* Separator */} - {aiProfiles.length > 0 && - (!showProfilesOnly || showAdvancedOptions) && ( -
- )} + {aiProfiles.length > 0 && (!showProfilesOnly || showAdvancedOptions) && ( +
+ )} {/* Claude Models Section */} {(!showProfilesOnly || showAdvancedOptions) && ( <> - + {newModelAllowsThinking && ( {/* Options Tab */} - + {/* Planning Mode Section */} - setNewFeature({ ...newFeature, skipTests }) - } + onSkipTestsChange={(skipTests) => setNewFeature({ ...newFeature, skipTests })} steps={newFeature.steps} onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })} /> @@ -556,12 +513,10 @@ export function AddFeatureDialog({ Add Feature diff --git a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx index 683f0144..d7f9e5ac 100644 --- a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx @@ -16,6 +16,7 @@ import { CategoryAutocomplete } from '@/components/ui/category-autocomplete'; import { DescriptionImageDropZone, FeatureImagePath as DescriptionImagePath, + FeatureTextFilePath as DescriptionTextFilePath, ImagePreviewMap, } from '@/components/ui/description-image-dropzone'; import { @@ -68,6 +69,7 @@ interface EditFeatureDialogProps { model: AgentModel; thinkingLevel: ThinkingLevel; imagePaths: DescriptionImagePath[]; + textFilePaths: DescriptionTextFilePath[]; branchName: string; // Can be empty string to use current branch priority: number; planningMode: PlanningMode; @@ -168,6 +170,7 @@ export function EditFeatureDialog({ model: selectedModel, thinkingLevel: normalizedThinking, imagePaths: editingFeature.imagePaths ?? [], + textFilePaths: editingFeature.textFilePaths ?? [], branchName: finalBranchName, priority: editingFeature.priority ?? 2, planningMode, @@ -294,6 +297,13 @@ export function EditFeatureDialog({ imagePaths: images, }) } + textFiles={editingFeature.textFilePaths ?? []} + onTextFilesChange={(textFiles) => + setEditingFeature({ + ...editingFeature, + textFilePaths: textFiles, + }) + } placeholder="Describe the feature..." previewMap={editFeaturePreviewMap} onPreviewMapChange={setEditFeaturePreviewMap} diff --git a/apps/ui/src/components/views/context-view.tsx b/apps/ui/src/components/views/context-view.tsx index 434d43f6..93bfcb01 100644 --- a/apps/ui/src/components/views/context-view.tsx +++ b/apps/ui/src/components/views/context-view.tsx @@ -1,11 +1,12 @@ -import { useEffect, useState, useCallback, useMemo } from 'react'; +import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { useAppStore } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { HotkeyButton } from '@/components/ui/hotkey-button'; import { Card } from '@/components/ui/card'; import { - Plus, RefreshCw, FileText, Image as ImageIcon, @@ -14,9 +15,12 @@ import { Upload, File, BookOpen, - EditIcon, Eye, Pencil, + FilePlus, + FileUp, + Loader2, + MoreVertical, } from 'lucide-react'; import { useKeyboardShortcuts, @@ -34,13 +38,26 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; +import { sanitizeFilename } from '@/lib/image-utils'; import { Markdown } from '../ui/markdown'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Textarea } from '@/components/ui/textarea'; interface ContextFile { name: string; type: 'text' | 'image'; content?: string; path: string; + description?: string; +} + +interface ContextMetadata { + files: Record; } export function ContextView() { @@ -52,24 +69,44 @@ export function ContextView() { const [isSaving, setIsSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); const [editedContent, setEditedContent] = useState(''); - const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [renameFileName, setRenameFileName] = useState(''); - const [newFileName, setNewFileName] = useState(''); - const [newFileType, setNewFileType] = useState<'text' | 'image'>('text'); - const [uploadedImageData, setUploadedImageData] = useState(null); - const [newFileContent, setNewFileContent] = useState(''); const [isDropHovering, setIsDropHovering] = useState(false); const [isPreviewMode, setIsPreviewMode] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [uploadingFileName, setUploadingFileName] = useState(null); + + // Create Markdown modal state + const [isCreateMarkdownOpen, setIsCreateMarkdownOpen] = useState(false); + const [newMarkdownName, setNewMarkdownName] = useState(''); + const [newMarkdownDescription, setNewMarkdownDescription] = useState(''); + const [newMarkdownContent, setNewMarkdownContent] = useState(''); + + // Track files with generating descriptions (async) + const [generatingDescriptions, setGeneratingDescriptions] = useState>(new Set()); + + // Edit description modal state + const [isEditDescriptionOpen, setIsEditDescriptionOpen] = useState(false); + const [editDescriptionValue, setEditDescriptionValue] = useState(''); + const [editDescriptionFileName, setEditDescriptionFileName] = useState(''); + + // File input ref for import + const fileInputRef = useRef(null); + + // Get images directory path + const getImagesPath = useCallback(() => { + if (!currentProject) return null; + return `${currentProject.path}/.automaker/images`; + }, [currentProject]); // Keyboard shortcuts for this view const contextShortcuts: KeyboardShortcut[] = useMemo( () => [ { key: shortcuts.addContextFile, - action: () => setIsAddDialogOpen(true), - description: 'Add new context file', + action: () => setIsCreateMarkdownOpen(true), + description: 'Create new markdown file', }, ], [shortcuts] @@ -94,6 +131,41 @@ export function ContextView() { return imageExtensions.includes(ext); }; + // Load context metadata + const loadMetadata = useCallback(async (): Promise => { + const contextPath = getContextPath(); + if (!contextPath) return { files: {} }; + + try { + const api = getElectronAPI(); + const metadataPath = `${contextPath}/context-metadata.json`; + const result = await api.readFile(metadataPath); + if (result.success && result.content) { + return JSON.parse(result.content); + } + } catch { + // Metadata file doesn't exist yet + } + return { files: {} }; + }, [getContextPath]); + + // Save context metadata + const saveMetadata = useCallback( + async (metadata: ContextMetadata) => { + const contextPath = getContextPath(); + if (!contextPath) return; + + try { + const api = getElectronAPI(); + const metadataPath = `${contextPath}/context-metadata.json`; + await api.writeFile(metadataPath, JSON.stringify(metadata, null, 2)); + } catch (error) { + console.error('Failed to save metadata:', error); + } + }, + [getContextPath] + ); + // Load context files const loadContextFiles = useCallback(async () => { const contextPath = getContextPath(); @@ -106,15 +178,19 @@ export function ContextView() { // Ensure context directory exists await api.mkdir(contextPath); + // Load metadata for descriptions + const metadata = await loadMetadata(); + // Read directory contents const result = await api.readdir(contextPath); if (result.success && result.entries) { const files: ContextFile[] = result.entries - .filter((entry) => entry.isFile) + .filter((entry) => entry.isFile && entry.name !== 'context-metadata.json') .map((entry) => ({ name: entry.name, type: isImageFile(entry.name) ? 'image' : 'text', path: `${contextPath}/${entry.name}`, + description: metadata.files[entry.name]?.description, })); setContextFiles(files); } @@ -123,7 +199,7 @@ export function ContextView() { } finally { setIsLoading(false); } - }, [getContextPath]); + }, [getContextPath, loadMetadata]); useEffect(() => { loadContextFiles(); @@ -176,43 +252,232 @@ export function ContextView() { setHasChanges(true); }; - // Add new context file - const handleAddFile = async () => { + // Generate description for a file + const generateDescription = async ( + filePath: string, + fileName: string, + isImage: boolean + ): Promise => { + try { + const httpClient = getHttpApiClient(); + const result = isImage + ? await httpClient.context.describeImage(filePath) + : await httpClient.context.describeFile(filePath); + + if (result.success && result.description) { + return result.description; + } + + const message = + result.error || `Automaker couldn't generate a description for “${fileName}”.`; + toast.error('Failed to generate description', { description: message }); + } catch (error) { + console.error('Failed to generate description:', error); + const message = + error instanceof Error + ? error.message + : 'An unexpected error occurred while generating the description.'; + toast.error('Failed to generate description', { description: message }); + } + return undefined; + }; + + // Generate description in background and update metadata + const generateDescriptionAsync = useCallback( + async (filePath: string, fileName: string, isImage: boolean) => { + // Add to generating set + setGeneratingDescriptions((prev) => new Set(prev).add(fileName)); + + try { + const description = await generateDescription(filePath, fileName, isImage); + + if (description) { + const metadata = await loadMetadata(); + metadata.files[fileName] = { description }; + await saveMetadata(metadata); + + // Reload files to update UI with new description + await loadContextFiles(); + } + } catch (error) { + console.error('Failed to generate description:', error); + } finally { + // Remove from generating set + setGeneratingDescriptions((prev) => { + const next = new Set(prev); + next.delete(fileName); + return next; + }); + } + }, + [loadMetadata, saveMetadata, loadContextFiles] + ); + + // Upload a file and generate description asynchronously + const uploadFile = async (file: globalThis.File) => { const contextPath = getContextPath(); - if (!contextPath || !newFileName.trim()) return; + if (!contextPath) return; + + setIsUploading(true); + setUploadingFileName(file.name); try { const api = getElectronAPI(); - let filename = newFileName.trim(); + const isImage = isImageFile(file.name); - // Add default extension if not provided - if (newFileType === 'text' && !filename.includes('.')) { + let filePath: string; + let fileName: string; + let imagePathForDescription: string | undefined; + + if (isImage) { + // For images: sanitize filename, store in .automaker/images + fileName = sanitizeFilename(file.name); + + // Read file as base64 + const dataUrl = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (event) => resolve(event.target?.result as string); + reader.readAsDataURL(file); + }); + + // Extract base64 data without the data URL prefix + const base64Data = dataUrl.split(',')[1] || dataUrl; + + // Determine mime type from original file + const mimeType = file.type || 'image/png'; + + // Use saveImageToTemp to properly save as binary file in .automaker/images + const saveResult = await api.saveImageToTemp?.( + base64Data, + fileName, + mimeType, + currentProject!.path + ); + + if (!saveResult?.success || !saveResult.path) { + throw new Error(saveResult?.error || 'Failed to save image'); + } + + // The saved image path is used for description + imagePathForDescription = saveResult.path; + + // Also save to context directory for display in the UI + // (as a data URL for inline display) + filePath = `${contextPath}/${fileName}`; + await api.writeFile(filePath, dataUrl); + } else { + // For non-images: keep original behavior + fileName = file.name; + filePath = `${contextPath}/${fileName}`; + + const content = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (event) => resolve(event.target?.result as string); + reader.readAsText(file); + }); + + await api.writeFile(filePath, content); + } + + // Reload files immediately (file appears in list without description) + await loadContextFiles(); + + // Start description generation in background (don't await) + // For images, use the path in the images directory + generateDescriptionAsync(imagePathForDescription || filePath, fileName, isImage); + } catch (error) { + console.error('Failed to upload file:', error); + toast.error('Failed to upload file', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsUploading(false); + setUploadingFileName(null); + } + }; + + // Handle file drop + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDropHovering(false); + + const files = Array.from(e.dataTransfer.files); + if (files.length === 0) return; + + // Process files sequentially + for (const file of files) { + await uploadFile(file); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDropHovering(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDropHovering(false); + }; + + // Handle file import via button + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileInputChange = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + for (const file of Array.from(files)) { + await uploadFile(file); + } + + // Reset input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + // Handle create markdown + const handleCreateMarkdown = async () => { + const contextPath = getContextPath(); + if (!contextPath || !newMarkdownName.trim()) return; + + try { + const api = getElectronAPI(); + let filename = newMarkdownName.trim(); + + // Add .md extension if not provided + if (!filename.includes('.')) { filename += '.md'; } const filePath = `${contextPath}/${filename}`; - if (newFileType === 'image' && uploadedImageData) { - // Write image data - await api.writeFile(filePath, uploadedImageData); - } else { - // Write text file with content (or empty if no content) - await api.writeFile(filePath, newFileContent); + // Write markdown file + await api.writeFile(filePath, newMarkdownContent); + + // Save description if provided + if (newMarkdownDescription.trim()) { + const metadata = await loadMetadata(); + metadata.files[filename] = { description: newMarkdownDescription.trim() }; + await saveMetadata(metadata); } - // Only reload files on success + // Reload files await loadContextFiles(); + + // Reset and close modal + setIsCreateMarkdownOpen(false); + setNewMarkdownName(''); + setNewMarkdownDescription(''); + setNewMarkdownContent(''); } catch (error) { - console.error('Failed to add file:', error); - // Optionally show error toast to user here - } finally { - // Close dialog and reset state - setIsAddDialogOpen(false); - setNewFileName(''); - setNewFileType('text'); - setUploadedImageData(null); - setNewFileContent(''); - setIsDropHovering(false); + console.error('Failed to create markdown:', error); } }; @@ -224,6 +489,11 @@ export function ContextView() { const api = getElectronAPI(); await api.deleteFile(selectedFile.path); + // Remove from metadata + const metadata = await loadMetadata(); + delete metadata.files[selectedFile.name]; + await saveMetadata(metadata); + setIsDeleteDialogOpen(false); setSelectedFile(null); setEditedContent(''); @@ -269,6 +539,14 @@ export function ContextView() { // Delete old file await api.deleteFile(selectedFile.path); + // Update metadata + const metadata = await loadMetadata(); + if (metadata.files[selectedFile.name]) { + metadata.files[newName] = metadata.files[selectedFile.name]; + delete metadata.files[selectedFile.name]; + await saveMetadata(metadata); + } + setIsRenameDialogOpen(false); setRenameFileName(''); @@ -281,6 +559,7 @@ export function ContextView() { type: isImageFile(newName) ? 'image' : 'text', path: newPath, content: result.content, + description: metadata.files[newName]?.description, }; setSelectedFile(renamedFile); } catch (error) { @@ -288,98 +567,60 @@ export function ContextView() { } }; - // Handle image upload - const handleImageUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; + // Save edited description + const handleSaveDescription = async () => { + if (!editDescriptionFileName) return; - const reader = new FileReader(); - reader.onload = (event) => { - const base64 = event.target?.result as string; - setUploadedImageData(base64); - if (!newFileName) { - setNewFileName(file.name); + try { + const metadata = await loadMetadata(); + metadata.files[editDescriptionFileName] = { description: editDescriptionValue.trim() }; + await saveMetadata(metadata); + + // Update selected file if it's the one being edited + if (selectedFile?.name === editDescriptionFileName) { + setSelectedFile({ ...selectedFile, description: editDescriptionValue.trim() }); } - }; - reader.readAsDataURL(file); - }; - // Handle drag and drop for file upload - const handleDrop = async (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); + // Reload files to update list + await loadContextFiles(); - const files = Array.from(e.dataTransfer.files); - if (files.length === 0) return; - - const contextPath = getContextPath(); - if (!contextPath) return; - - const api = getElectronAPI(); - - for (const file of files) { - const reader = new FileReader(); - reader.onload = async (event) => { - const content = event.target?.result as string; - const filePath = `${contextPath}/${file.name}`; - await api.writeFile(filePath, content); - await loadContextFiles(); - }; - - if (isImageFile(file.name)) { - reader.readAsDataURL(file); - } else { - reader.readAsText(file); - } + setIsEditDescriptionOpen(false); + setEditDescriptionValue(''); + setEditDescriptionFileName(''); + } catch (error) { + console.error('Failed to save description:', error); } }; - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); + // Open edit description dialog + const handleEditDescription = (file: ContextFile) => { + setEditDescriptionFileName(file.name); + setEditDescriptionValue(file.description || ''); + setIsEditDescriptionOpen(true); }; - // Handle drag and drop for .txt and .md files in the add context dialog textarea - const handleTextAreaDrop = async (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDropHovering(false); + // Delete file from list (used by dropdown) + const handleDeleteFromList = async (file: ContextFile) => { + try { + const api = getElectronAPI(); + await api.deleteFile(file.path); - const files = Array.from(e.dataTransfer.files); - if (files.length === 0) return; + // Remove from metadata + const metadata = await loadMetadata(); + delete metadata.files[file.name]; + await saveMetadata(metadata); - const file = files[0]; // Only handle the first file - const fileName = file.name.toLowerCase(); - - // Only accept .txt and .md files - if (!fileName.endsWith('.txt') && !fileName.endsWith('.md')) { - console.warn('Only .txt and .md files are supported for drag and drop'); - return; - } - - const reader = new FileReader(); - reader.onload = (event) => { - const content = event.target?.result as string; - setNewFileContent(content); - - // Auto-fill filename if empty - if (!newFileName) { - setNewFileName(file.name); + // Clear selection if this was the selected file + if (selectedFile?.path === file.path) { + setSelectedFile(null); + setEditedContent(''); + setHasChanges(false); } - }; - reader.readAsText(file); - }; - const handleTextAreaDragOver = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDropHovering(true); - }; - - const handleTextAreaDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDropHovering(false); + await loadContextFiles(); + } catch (error) { + console.error('Failed to delete file:', error); + } }; if (!currentProject) { @@ -403,6 +644,16 @@ export function ContextView() { return (
+ {/* Hidden file input for import */} + + {/* Header */}
@@ -415,26 +666,63 @@ export function ContextView() {
+ setIsAddDialogOpen(true)} + onClick={() => setIsCreateMarkdownOpen(true)} hotkey={shortcuts.addContextFile} hotkeyActive={false} - data-testid="add-context-file" + data-testid="create-markdown-button" > - - Add File + + Create Markdown
{/* Main content area with file list and editor */}
+ {/* Drop overlay */} + {isDropHovering && ( +
+
+ + Drop files to upload + + Files will be analyzed automatically + +
+
+ )} + + {/* Uploading overlay */} + {isUploading && ( +
+
+ + Uploading {uploadingFileName}... +
+
+ )} + {/* Left Panel - File List */}
@@ -449,47 +737,82 @@ export function ContextView() {

No context files yet.
- Drop files here or click Add File. + Drop files here or use the buttons above.

) : (
- {contextFiles.map((file) => ( -
- - -
- ))} + + + + + + + { + setRenameFileName(file.name); + setSelectedFile(file); + setIsRenameDialogOpen(true); + }} + data-testid={`rename-context-file-${file.name}`} + > + + Rename + + handleDeleteFromList(file)} + className="text-red-500 focus:text-red-500" + data-testid={`delete-context-file-${file.name}`} + > + + Delete + + + +
+ ); + })}
)}
@@ -501,13 +824,13 @@ export function ContextView() { <> {/* File toolbar */}
-
+
{selectedFile.type === 'image' ? ( - + ) : ( - + )} - {selectedFile.name} + {selectedFile.name}
{selectedFile.type === 'text' && isMarkdownFile(selectedFile.name) && ( @@ -519,7 +842,7 @@ export function ContextView() { > {isPreviewMode ? ( <> - + Edit ) : ( @@ -553,8 +876,42 @@ export function ContextView() {
+ {/* Description section */} +
+
+
+
+ + Description + + {generatingDescriptions.has(selectedFile.name) ? ( +
+ + Generating description with AI... +
+ ) : selectedFile.description ? ( +

{selectedFile.description}

+ ) : ( +

+ No description. Click edit to add one. +

+ )} +
+ +
+
+
+ {/* Content area */} -
+
{selectedFile.type === 'image' ? (
- {/* Add File Dialog */} - + {/* Create Markdown Dialog */} + - Add Context File - Add a new text or image file to the context. + Create Markdown Context + + Create a new markdown file to add context for AI prompts. + -
-
- - -
- +
- + setNewFileName(e.target.value)} - placeholder={newFileType === 'text' ? 'context.md' : 'image.png'} - data-testid="new-file-name" + id="markdown-filename" + value={newMarkdownName} + onChange={(e) => setNewMarkdownName(e.target.value)} + placeholder="context-file.md" + data-testid="new-markdown-name" />
- {newFileType === 'text' && ( -
- -
-