Merge pull request #212 from AutoMaker-Org/improve-context-page

fixing file uploads on context page
This commit is contained in:
Web Dev Cody
2025-12-22 02:27:26 -05:00
committed by GitHub
29 changed files with 2592 additions and 662 deletions

View File

@@ -46,6 +46,7 @@ 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();
@@ -145,6 +146,7 @@ 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);

View File

@@ -153,9 +153,9 @@ export class ClaudeProvider extends BaseProvider {
tier: 'standard' as const,
},
{
id: 'claude-3-5-haiku-20241022',
name: 'Claude 3.5 Haiku',
modelString: 'claude-3-5-haiku-20241022',
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,

View File

@@ -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;
}

View File

@@ -0,0 +1,220 @@
/**
* 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.
*
* SECURITY: This endpoint validates file paths against ALLOWED_ROOT_DIRECTORY
* and reads file content directly (not via Claude's Read tool) to prevent
* arbitrary file reads and prompt injection attacks.
*/
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';
import { PathNotAllowedError } from '@automaker/platform';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import * as secureFs from '../../../lib/secure-fs.js';
import * as path from 'path';
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(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stream: AsyncIterable<any>
): Promise<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
const blocks = msg.message.content as Array<{ type: string; text?: string }>;
for (const block of blocks) {
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<void> {
return async (req: Request, res: Response): Promise<void> => {
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}`);
// Resolve the path for logging and cwd derivation
const resolvedPath = secureFs.resolvePath(filePath);
// Read file content using secureFs (validates path against ALLOWED_ROOT_DIRECTORY)
// This prevents arbitrary file reads (e.g., /etc/passwd, ~/.ssh/id_rsa)
// and prompt injection attacks where malicious filePath values could inject instructions
let fileContent: string;
try {
const content = await secureFs.readFile(resolvedPath, 'utf-8');
fileContent = typeof content === 'string' ? content : content.toString('utf-8');
} catch (readError) {
// Path not allowed - return 403 Forbidden
if (readError instanceof PathNotAllowedError) {
logger.warn(`[DescribeFile] Path not allowed: ${filePath}`);
const response: DescribeFileErrorResponse = {
success: false,
error: 'File path is not within the allowed directory',
};
res.status(403).json(response);
return;
}
// File not found
if (
readError !== null &&
typeof readError === 'object' &&
'code' in readError &&
readError.code === 'ENOENT'
) {
logger.warn(`[DescribeFile] File not found: ${resolvedPath}`);
const response: DescribeFileErrorResponse = {
success: false,
error: `File not found: ${filePath}`,
};
res.status(404).json(response);
return;
}
const errorMessage = readError instanceof Error ? readError.message : 'Unknown error';
logger.error(`[DescribeFile] Failed to read file: ${errorMessage}`);
const response: DescribeFileErrorResponse = {
success: false,
error: `Failed to read file: ${errorMessage}`,
};
res.status(500).json(response);
return;
}
// Truncate very large files to avoid token limits
const MAX_CONTENT_LENGTH = 50000;
const truncated = fileContent.length > MAX_CONTENT_LENGTH;
const contentToAnalyze = truncated
? fileContent.substring(0, MAX_CONTENT_LENGTH)
: fileContent;
// Get the filename for context
const fileName = path.basename(resolvedPath);
// Build prompt with file content passed as structured data
// The file content is included directly, not via tool invocation
const instructionText = `Analyze the following file and 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.
File: ${fileName}${truncated ? ' (truncated)' : ''}`;
const promptContent = [
{ type: 'text' as const, text: instructionText },
{ type: 'text' as const, text: `\n\n--- FILE CONTENT ---\n${contentToAnalyze}` },
];
// Use the file's directory as the working directory
const cwd = path.dirname(resolvedPath);
// Use centralized SDK options with proper cwd validation
// No tools needed since we're passing file content directly
const sdkOptions = createCustomOptions({
cwd,
model: CLAUDE_MODEL_MAP.haiku,
maxTurns: 1,
allowedTools: [],
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
});
const promptGenerator = (async function* () {
yield {
type: 'user' as const,
session_id: '',
message: { role: 'user' as const, content: promptContent },
parent_tool_use_id: null,
};
})();
const stream = query({ prompt: promptGenerator, options: sdkOptions });
// 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);
}
};
}

View File

@@ -0,0 +1,416 @@
/**
* 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');
/**
* Allowlist of safe headers to log
* All other headers are excluded to prevent leaking sensitive values
*/
const SAFE_HEADERS_ALLOWLIST = new Set([
'content-type',
'accept',
'user-agent',
'host',
'referer',
'content-length',
'origin',
'x-request-id',
]);
/**
* Filter request headers to only include safe, non-sensitive values
*/
function filterSafeHeaders(headers: Record<string, unknown>): Record<string, unknown> {
const filtered: Record<string, unknown> = {};
for (const [key, value] of Object.entries(headers)) {
if (SAFE_HEADERS_ALLOWLIST.has(key.toLowerCase())) {
filtered[key] = value;
}
}
return filtered;
}
/**
* 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<any>,
requestId: string
): Promise<string> {
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<void> {
return async (req: Request, res: Response): Promise<void> => {
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)
// Only log safe headers to prevent leaking sensitive values (auth tokens, cookies, etc.)
logger.info(`[${requestId}] ===== POST /api/context/describe-image =====`);
logger.info(`[${requestId}] headers=${JSON.stringify(filterSafeHeaders(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);
}
};
}

View File

@@ -7,7 +7,12 @@ import path from 'path';
import * as secureFs from '../lib/secure-fs.js';
import type { EventEmitter } from '../lib/events.js';
import type { ExecuteOptions } from '@automaker/types';
import { readImageAsBase64, buildPromptWithImages, isAbortError } from '@automaker/utils';
import {
readImageAsBase64,
buildPromptWithImages,
isAbortError,
loadContextFiles,
} from '@automaker/utils';
import { ProviderFactory } from '../providers/provider-factory.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { PathNotAllowedError } from '@automaker/platform';
@@ -178,12 +183,27 @@ export class AgentService {
await this.saveSession(sessionId, session.messages);
try {
// Determine the effective working directory for context loading
const effectiveWorkDir = workingDirectory || session.workingDirectory;
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
projectPath: effectiveWorkDir,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
});
// Build combined system prompt with base prompt and context files
const baseSystemPrompt = this.getSystemPrompt();
const combinedSystemPrompt = contextFilesPrompt
? `${contextFilesPrompt}\n\n${baseSystemPrompt}`
: baseSystemPrompt;
// Build SDK options using centralized configuration
const sdkOptions = createChatOptions({
cwd: workingDirectory || session.workingDirectory,
cwd: effectiveWorkDir,
model: model,
sessionModel: session.model,
systemPrompt: this.getSystemPrompt(),
systemPrompt: combinedSystemPrompt,
abortController: session.abortController!,
});
@@ -203,8 +223,8 @@ export class AgentService {
const options: ExecuteOptions = {
prompt: '', // Will be set below based on images
model: effectiveModel,
cwd: workingDirectory || session.workingDirectory,
systemPrompt: this.getSystemPrompt(),
cwd: effectiveWorkDir,
systemPrompt: combinedSystemPrompt,
maxTurns: maxTurns,
allowedTools: allowedTools,
abortController: session.abortController!,

View File

@@ -11,10 +11,15 @@
import { ProviderFactory } from '../providers/provider-factory.js';
import type { ExecuteOptions, Feature } from '@automaker/types';
import { buildPromptWithImages, isAbortError, classifyError } from '@automaker/utils';
import {
buildPromptWithImages,
isAbortError,
classifyError,
loadContextFiles,
} from '@automaker/utils';
import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver';
import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver';
import { getFeatureDir, getAutomakerDir, getFeaturesDir, getContextDir } from '@automaker/platform';
import { getFeatureDir, getAutomakerDir, getFeaturesDir } from '@automaker/platform';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
@@ -549,7 +554,10 @@ export class AutoModeService {
// Build the prompt - use continuation prompt if provided (for recovery after plan approval)
let prompt: string;
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
const contextFiles = await this.loadContextFiles(projectPath);
const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
});
if (options?.continuationPrompt) {
// Continuation prompt is used when recovering from a plan approval
@@ -595,7 +603,7 @@ export class AutoModeService {
projectPath,
planningMode: feature.planningMode,
requirePlanApproval: feature.requirePlanApproval,
systemPrompt: contextFiles || undefined,
systemPrompt: contextFilesPrompt || undefined,
}
);
@@ -736,7 +744,10 @@ export class AutoModeService {
}
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
const contextFiles = await this.loadContextFiles(projectPath);
const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
});
// Build complete prompt with feature info, previous context, and follow-up instructions
let fullPrompt = `## Follow-up on Feature Implementation
@@ -864,7 +875,7 @@ Address the follow-up instructions above. Review the previous work and make the
projectPath,
planningMode: 'skip', // Follow-ups don't require approval
previousContent: previousContext || undefined,
systemPrompt: contextFiles || undefined,
systemPrompt: contextFilesPrompt || undefined,
}
);
@@ -1044,63 +1055,6 @@ Address the follow-up instructions above. Review the previous work and make the
}
}
/**
* Load context files from .automaker/context/ directory
* These are user-defined context files (CLAUDE.md, CODE_QUALITY.md, etc.)
* that provide project-specific rules and guidelines for the agent.
*/
private async loadContextFiles(projectPath: string): Promise<string> {
// Use path.resolve for cross-platform absolute path handling
const contextDir = path.resolve(getContextDir(projectPath));
try {
// Check if directory exists first
await secureFs.access(contextDir);
const files = await secureFs.readdir(contextDir);
// Filter for text-based context files (case-insensitive for Windows)
const textFiles = files.filter((f) => {
const lower = f.toLowerCase();
return lower.endsWith('.md') || lower.endsWith('.txt');
});
if (textFiles.length === 0) return '';
const contents: string[] = [];
for (const file of textFiles) {
// Use path.join for cross-platform path construction
const filePath = path.join(contextDir, file);
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
contents.push(`## ${file}\n\n${content}`);
}
console.log(`[AutoMode] Loaded ${textFiles.length} context file(s): ${textFiles.join(', ')}`);
return `# ⚠️ CRITICAL: Project Context Files - READ AND FOLLOW STRICTLY
**IMPORTANT**: The following context files contain MANDATORY project-specific rules and conventions. You MUST:
1. Read these rules carefully before taking any action
2. Follow ALL commands exactly as shown (e.g., if the project uses \`pnpm\`, NEVER use \`npm\` or \`npx\`)
3. Follow ALL coding conventions, commit message formats, and architectural patterns specified
4. Reference these rules before running ANY shell commands or making commits
Failure to follow these rules will result in broken builds, failed CI, and rejected commits.
${contents.join('\n\n---\n\n')}
---
**REMINDER**: Before running any command, verify you are using the correct package manager and following the conventions above.
---
`;
} catch {
// Context directory doesn't exist or is empty - this is fine
return '';
}
}
/**
* Analyze project to gather context
*/

View File

@@ -4,12 +4,12 @@ import { ProviderFactory } from '@/providers/provider-factory.js';
import * as fs from 'fs/promises';
import * as imageHandler from '@automaker/utils';
import * as promptBuilder from '@automaker/utils';
import * as contextLoader from '@automaker/utils';
import { collectAsyncGenerator } from '../../utils/helpers.js';
vi.mock('fs/promises');
vi.mock('@/providers/provider-factory.js');
vi.mock('@automaker/utils');
vi.mock('@automaker/utils');
describe('agent-service.ts', () => {
let service: AgentService;
@@ -21,6 +21,12 @@ describe('agent-service.ts', () => {
beforeEach(() => {
vi.clearAllMocks();
service = new AgentService('/test/data', mockEvents as any);
// Mock loadContextFiles to return empty context by default
vi.mocked(contextLoader.loadContextFiles).mockResolvedValue({
files: [],
formattedPrompt: '',
});
});
describe('initialize', () => {

View File

@@ -16,9 +16,12 @@ 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
import {
fileToBase64,
validateImageFile,
ACCEPTED_IMAGE_TYPES,
DEFAULT_MAX_FILE_SIZE,
} from '@/lib/image-utils';
interface BoardBackgroundModalProps {
open: boolean;
@@ -71,21 +74,6 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
}
}, [currentProject, backgroundSettings.imagePath, imageVersion]);
const fileToBase64 = (file: File): Promise<string> => {
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) {
@@ -93,16 +81,10 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
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;
}

View File

@@ -1,18 +1,38 @@
import React, { useState, useRef, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { ImageIcon, X, Loader2 } from 'lucide-react';
import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore, type FeatureImagePath } from '@/store/app-store';
import { useAppStore, type FeatureImagePath, type FeatureTextFilePath } from '@/store/app-store';
import {
sanitizeFilename,
fileToBase64,
fileToText,
isTextFile,
isImageFile,
validateTextFile,
getTextFileMimeType,
generateFileId,
ACCEPTED_IMAGE_TYPES,
ACCEPTED_TEXT_EXTENSIONS,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_TEXT_FILE_SIZE,
formatFileSize,
} from '@/lib/image-utils';
// Map to store preview data by image ID (persisted across component re-mounts)
export type ImagePreviewMap = Map<string, string>;
// 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<string> => {
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<string | null> => {
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({
>
<div className="flex flex-col items-center gap-2 text-blue-400">
<ImageIcon className="w-8 h-8" />
<span className="text-sm font-medium">Drop images here</span>
<span className="text-sm font-medium">Drop files here</span>
</div>
</div>
)}
@@ -359,7 +413,7 @@ export function DescriptionImageDropZone({
{/* Hint text */}
<p className="text-xs text-muted-foreground mt-1">
Paste, drag and drop images, or{' '}
Paste, drag and drop files, or{' '}
<button
type="button"
onClick={handleBrowseClick}
@@ -368,29 +422,33 @@ export function DescriptionImageDropZone({
>
browse
</button>{' '}
to attach context images
to attach context (images, .txt, .md)
</p>
{/* Processing indicator */}
{isProcessing && (
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Saving images...</span>
<span>Processing files...</span>
</div>
)}
{/* Image previews */}
{images.length > 0 && (
<div className="mt-3 space-y-2" data-testid="description-image-previews">
{/* File previews (images and text files) */}
{(images.length > 0 || textFiles.length > 0) && (
<div className="mt-3 space-y-2" data-testid="description-file-previews">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{images.length} image{images.length > 1 ? 's' : ''} attached
{images.length + textFiles.length} file
{images.length + textFiles.length > 1 ? 's' : ''} attached
</p>
<button
type="button"
onClick={() => {
onImagesChange([]);
setPreviewImages(new Map());
if (onTextFilesChange) {
onTextFilesChange([]);
}
}}
className="text-xs text-muted-foreground hover:text-foreground"
disabled={disabled}
@@ -399,6 +457,7 @@ export function DescriptionImageDropZone({
</button>
</div>
<div className="flex flex-wrap gap-2">
{/* Image previews */}
{images.map((image) => (
<div
key={image.id}
@@ -445,6 +504,38 @@ export function DescriptionImageDropZone({
</div>
</div>
))}
{/* Text file previews */}
{textFiles.map((file) => (
<div
key={file.id}
className="relative group rounded-md border border-muted bg-muted/50 overflow-hidden"
data-testid={`description-text-file-preview-${file.id}`}
>
{/* Text file icon */}
<div className="w-16 h-16 flex items-center justify-center bg-zinc-800">
<FileText className="w-6 h-6 text-muted-foreground" />
</div>
{/* Remove button */}
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeTextFile(file.id);
}}
className="absolute top-0.5 right-0.5 p-0.5 rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
data-testid={`remove-description-text-file-${file.id}`}
>
<X className="h-3 w-3" />
</button>
)}
{/* Filename and size tooltip on hover */}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[10px] text-white truncate">{file.filename}</p>
<p className="text-[9px] text-white/70">{formatFileSize(file.content.length)}</p>
</div>
</div>
))}
</div>
</div>
)}

View File

@@ -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<HTMLInputElement>(null);
const fileToBase64 = (file: File): Promise<string> => {
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,

View File

@@ -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({
</div>
);
}
function fileToBase64(file: File): Promise<string> {
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];
}

View File

@@ -16,12 +16,27 @@ 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,
formatFileSize,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_FILES,
} from '@/lib/image-utils';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
@@ -40,6 +55,7 @@ export function AgentView() {
const shortcuts = useKeyboardShortcutsConfig();
const [input, setInput] = useState('');
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [selectedTextFiles, setSelectedTextFiles] = useState<TextFileAttachment[]>([]);
const [showImageDropZone, setShowImageDropZone] = useState(false);
const [currentTool, setCurrentTool] = useState<string | null>(null);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
@@ -116,17 +132,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 +158,99 @@ export function AgentView() {
setShowImageDropZone(!showImageDropZone);
}, [showImageDropZone]);
// Helper function to convert file to base64
const fileToBase64 = useCallback((file: File): Promise<string> => {
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 +258,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 +762,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 && (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{selectedImages.length} image
{selectedImages.length > 1 ? 's' : ''} attached
{selectedImages.length + selectedTextFiles.length} file
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''} attached
</p>
<button
onClick={() => setSelectedImages([])}
onClick={() => {
setSelectedImages([]);
setSelectedTextFiles([]);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
disabled={isProcessing}
>
@@ -737,6 +782,7 @@ export function AgentView() {
</button>
</div>
<div className="flex flex-wrap gap-2">
{/* Image attachments */}
{selectedImages.map((image) => (
<div
key={image.id}
@@ -773,6 +819,35 @@ export function AgentView() {
)}
</div>
))}
{/* Text file attachments */}
{selectedTextFiles.map((file) => (
<div
key={file.id}
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
>
{/* File icon */}
<div className="w-8 h-8 rounded-md bg-muted flex-shrink-0 flex items-center justify-center">
<FileText className="w-4 h-4 text-muted-foreground" />
</div>
{/* File info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate max-w-24">
{file.filename}
</p>
<p className="text-[10px] text-muted-foreground">
{formatFileSize(file.size)}
</p>
</div>
{/* Remove button */}
<button
onClick={() => removeTextFile(file.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
disabled={isProcessing}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
</div>
)}
@@ -792,7 +867,7 @@ export function AgentView() {
<Input
ref={inputRef}
placeholder={
isDragOver ? 'Drop your images here...' : 'Describe what you want to build...'
isDragOver ? 'Drop your files here...' : 'Describe what you want to build...'
}
value={input}
onChange={(e) => setInput(e.target.value)}
@@ -803,14 +878,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 && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
{selectedImages.length} image
{selectedImages.length > 1 ? 's' : ''}
{selectedImages.length + selectedTextFiles.length} file
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''}
</div>
)}
{isDragOver && (
@@ -821,7 +897,7 @@ export function AgentView() {
)}
</div>
{/* Image Attachment Button */}
{/* File Attachment Button */}
<Button
variant="outline"
size="icon"
@@ -830,9 +906,10 @@ export function AgentView() {
className={cn(
'h-11 w-11 rounded-xl border-border',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
selectedImages.length > 0 && 'border-primary/30 text-primary'
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
'border-primary/30 text-primary'
)}
title="Attach images"
title="Attach files (images, .txt, .md)"
>
<Paperclip className="w-4 h-4" />
</Button>
@@ -841,7 +918,11 @@ export function AgentView() {
<Button
onClick={handleSend}
disabled={
(!input.trim() && selectedImages.length === 0) || isProcessing || !isConnected
(!input.trim() &&
selectedImages.length === 0 &&
selectedTextFiles.length === 0) ||
isProcessing ||
!isConnected
}
className="h-11 px-4 rounded-xl"
data-testid="send-message"
@@ -862,12 +943,3 @@ export function AgentView() {
</div>
);
}
// Helper function to format file size
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];
}

View File

@@ -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 {
@@ -64,6 +65,7 @@ interface AddFeatureDialogProps {
steps: string[];
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
@@ -106,6 +108,7 @@ export function AddFeatureDialog({
steps: [''],
images: [] as FeatureImage[],
imagePaths: [] as DescriptionImagePath[],
textFilePaths: [] as DescriptionTextFilePath[],
skipTests: false,
model: 'opus' as AgentModel,
thinkingLevel: 'none' as ThinkingLevel,
@@ -193,6 +196,7 @@ export function AddFeatureDialog({
steps: newFeature.steps.filter((s) => s.trim()),
images: newFeature.images,
imagePaths: newFeature.imagePaths,
textFilePaths: newFeature.textFilePaths,
skipTests: newFeature.skipTests,
model: selectedModel,
thinkingLevel: normalizedThinking,
@@ -210,6 +214,7 @@ export function AddFeatureDialog({
steps: [''],
images: [],
imagePaths: [],
textFilePaths: [],
skipTests: defaultSkipTests,
model: 'opus',
priority: 2,
@@ -331,6 +336,10 @@ export function AddFeatureDialog({
}}
images={newFeature.imagePaths}
onImagesChange={(images) => setNewFeature({ ...newFeature, imagePaths: images })}
textFiles={newFeature.textFilePaths}
onTextFilesChange={(textFiles) =>
setNewFeature({ ...newFeature, textFilePaths: textFiles })
}
placeholder="Describe the feature..."
previewMap={newFeaturePreviewMap}
onPreviewMapChange={setNewFeaturePreviewMap}

View File

@@ -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}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import type { Message, StreamEvent } from '@/types/electron';
import { useMessageQueue } from './use-message-queue';
import type { ImageAttachment } from '@/store/app-store';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { sanitizeFilename } from '@/lib/image-utils';
interface UseElectronAgentOptions {
sessionId: string;
@@ -15,12 +16,22 @@ interface UseElectronAgentResult {
messages: Message[];
isProcessing: boolean;
isConnected: boolean;
sendMessage: (content: string, images?: ImageAttachment[]) => Promise<void>;
sendMessage: (
content: string,
images?: ImageAttachment[],
textFiles?: TextFileAttachment[]
) => Promise<void>;
stopExecution: () => Promise<void>;
clearHistory: () => Promise<void>;
error: string | null;
// Queue-related state
queuedMessages: { id: string; content: string; images?: ImageAttachment[]; timestamp: Date }[];
queuedMessages: {
id: string;
content: string;
images?: ImageAttachment[];
textFiles?: TextFileAttachment[];
timestamp: Date;
}[];
isQueueProcessing: boolean;
clearMessageQueue: () => void;
}
@@ -46,7 +57,7 @@ export function useElectronAgent({
// Send message directly to the agent (bypassing queue)
const sendMessageDirectly = useCallback(
async (content: string, images?: ImageAttachment[]) => {
async (content: string, images?: ImageAttachment[], textFiles?: TextFileAttachment[]) => {
const api = getElectronAPI();
if (!api?.agent) {
setError('API not available');
@@ -64,8 +75,20 @@ export function useElectronAgent({
console.log('[useElectronAgent] Sending message directly', {
hasImages: images && images.length > 0,
imageCount: images?.length || 0,
hasTextFiles: textFiles && textFiles.length > 0,
textFileCount: textFiles?.length || 0,
});
// Build message content with text file context prepended
let messageContent = content;
if (textFiles && textFiles.length > 0) {
const contextParts = textFiles.map((file) => {
return `<file name="${file.filename}">\n${file.content}\n</file>`;
});
const contextBlock = `Here are some files for context:\n\n${contextParts.join('\n\n')}\n\n`;
messageContent = contextBlock + content;
}
// Save images to .automaker/images and get paths
let imagePaths: string[] | undefined;
if (images && images.length > 0 && api.saveImageToTemp) {
@@ -73,7 +96,7 @@ export function useElectronAgent({
for (const image of images) {
const result = await api.saveImageToTemp(
image.data,
image.filename,
sanitizeFilename(image.filename),
image.mimeType,
workingDirectory // Pass workingDirectory as projectPath
);
@@ -88,7 +111,7 @@ export function useElectronAgent({
const result = await api.agent!.send(
sessionId,
content,
messageContent,
workingDirectory,
imagePaths,
model
@@ -114,7 +137,11 @@ export function useElectronAgent({
const { queuedMessages, isProcessingQueue, addToQueue, clearQueue, processNext } =
useMessageQueue({
onProcessNext: async (queuedMessage) => {
await sendMessageDirectly(queuedMessage.content, queuedMessage.images);
await sendMessageDirectly(
queuedMessage.content,
queuedMessage.images,
queuedMessage.textFiles
);
},
});
@@ -288,7 +315,7 @@ export function useElectronAgent({
// Send a message to the agent
const sendMessage = useCallback(
async (content: string, images?: ImageAttachment[]) => {
async (content: string, images?: ImageAttachment[], textFiles?: TextFileAttachment[]) => {
const api = getElectronAPI();
if (!api?.agent) {
setError('API not available');
@@ -307,8 +334,20 @@ export function useElectronAgent({
console.log('[useElectronAgent] Sending message', {
hasImages: images && images.length > 0,
imageCount: images?.length || 0,
hasTextFiles: textFiles && textFiles.length > 0,
textFileCount: textFiles?.length || 0,
});
// Build message content with text file context prepended
let messageContent = content;
if (textFiles && textFiles.length > 0) {
const contextParts = textFiles.map((file) => {
return `<file name="${file.filename}">\n${file.content}\n</file>`;
});
const contextBlock = `Here are some files for context:\n\n${contextParts.join('\n\n')}\n\n`;
messageContent = contextBlock + content;
}
// Save images to .automaker/images and get paths
let imagePaths: string[] | undefined;
if (images && images.length > 0 && api.saveImageToTemp) {
@@ -316,7 +355,7 @@ export function useElectronAgent({
for (const image of images) {
const result = await api.saveImageToTemp(
image.data,
image.filename,
sanitizeFilename(image.filename),
image.mimeType,
workingDirectory // Pass workingDirectory as projectPath
);
@@ -331,7 +370,7 @@ export function useElectronAgent({
const result = await api.agent!.send(
sessionId,
content,
messageContent,
workingDirectory,
imagePaths,
model

View File

@@ -1,10 +1,11 @@
import { useState, useCallback } from 'react';
import type { ImageAttachment } from '@/store/app-store';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
export interface QueuedMessage {
id: string;
content: string;
images?: ImageAttachment[];
textFiles?: TextFileAttachment[];
timestamp: Date;
}
@@ -15,7 +16,11 @@ interface UseMessageQueueOptions {
interface UseMessageQueueResult {
queuedMessages: QueuedMessage[];
isProcessingQueue: boolean;
addToQueue: (content: string, images?: ImageAttachment[]) => void;
addToQueue: (
content: string,
images?: ImageAttachment[],
textFiles?: TextFileAttachment[]
) => void;
clearQueue: () => void;
removeFromQueue: (messageId: string) => void;
processNext: () => Promise<void>;
@@ -31,16 +36,20 @@ export function useMessageQueue({ onProcessNext }: UseMessageQueueOptions): UseM
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
const addToQueue = useCallback((content: string, images?: ImageAttachment[]) => {
const queuedMessage: QueuedMessage = {
id: `queued-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
content: content.trim(),
images,
timestamp: new Date(),
};
const addToQueue = useCallback(
(content: string, images?: ImageAttachment[], textFiles?: TextFileAttachment[]) => {
const queuedMessage: QueuedMessage = {
id: `queued-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
content: content.trim(),
images,
textFiles,
timestamp: new Date(),
};
setQueuedMessages((prev) => [...prev, queuedMessage]);
}, []);
setQueuedMessages((prev) => [...prev, queuedMessage]);
},
[]
);
const removeFromQueue = useCallback((messageId: string) => {
setQueuedMessages((prev) => prev.filter((msg) => msg.id !== messageId));

View File

@@ -470,6 +470,18 @@ export interface ElectronAPI {
claude?: {
getUsage: () => Promise<ClaudeUsageResponse>;
};
context?: {
describeImage: (imagePath: string) => Promise<{
success: boolean;
description?: string;
error?: string;
}>;
describeFile: (filePath: string) => Promise<{
success: boolean;
description?: string;
error?: string;
}>;
};
}
// Note: Window interface is declared in @/types/electron.d.ts

View File

@@ -1001,6 +1001,25 @@ export class HttpApiClient implements ElectronAPI {
claude = {
getUsage: (): Promise<ClaudeUsageResponse> => this.get('/api/claude/usage'),
};
// Context API
context = {
describeImage: (
imagePath: string
): Promise<{
success: boolean;
description?: string;
error?: string;
}> => this.post('/api/context/describe-image', { imagePath }),
describeFile: (
filePath: string
): Promise<{
success: boolean;
description?: string;
error?: string;
}> => this.post('/api/context/describe-file', { filePath }),
};
}
// Singleton instance

View File

@@ -0,0 +1,236 @@
/**
* Shared utilities for image and file handling across the UI
*/
// Accepted image MIME types
export const ACCEPTED_IMAGE_TYPES = [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
];
// Accepted text file MIME types
export const ACCEPTED_TEXT_TYPES = ['text/plain', 'text/markdown', 'text/x-markdown'];
// File extensions for text files (used for validation when MIME type is unreliable)
export const ACCEPTED_TEXT_EXTENSIONS = ['.txt', '.md'];
// Default max file size (10MB)
export const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
// Default max text file size (1MB - text files should be smaller)
export const DEFAULT_MAX_TEXT_FILE_SIZE = 1 * 1024 * 1024;
// Default max number of files
export const DEFAULT_MAX_FILES = 5;
/**
* Sanitize a filename by replacing spaces and special characters with underscores.
* This is important for:
* - Mac screenshot filenames that contain Unicode narrow no-break spaces (U+202F)
* - Filenames with regular spaces
* - Filenames with special characters that may cause path issues
*
* @param filename - The original filename
* @returns A sanitized filename safe for file system operations
*/
export function sanitizeFilename(filename: string): string {
const lastDot = filename.lastIndexOf('.');
const name = lastDot > 0 ? filename.substring(0, lastDot) : filename;
const ext = lastDot > 0 ? filename.substring(lastDot) : '';
const sanitized = name
.replace(/[\s\u00A0\u202F\u2009\u200A]+/g, '_') // Various space characters (regular, non-breaking, narrow no-break, thin, hair)
.replace(/[^a-zA-Z0-9_-]/g, '_') // Non-alphanumeric chars
.replace(/_+/g, '_') // Collapse multiple underscores
.replace(/^_|_$/g, ''); // Trim leading/trailing underscores
return `${sanitized || 'image'}${ext}`;
}
/**
* Convert a File object to a base64 data URL string
*
* @param file - The file to convert
* @returns Promise resolving to a base64 data URL string
*/
export function fileToBase64(file: File): Promise<string> {
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);
});
}
/**
* Extract the base64 data from a data URL (removes the prefix)
*
* @param dataUrl - The full data URL (e.g., "data:image/png;base64,...")
* @returns The base64 data without the prefix
*/
export function extractBase64Data(dataUrl: string): string {
return dataUrl.split(',')[1] || dataUrl;
}
/**
* Format file size in human-readable format
*
* @param bytes - File size in bytes
* @returns Formatted string (e.g., "1.5 MB")
*/
export 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];
}
/**
* Validate an image file for upload
*
* @param file - The file to validate
* @param maxFileSize - Maximum file size in bytes (default: 10MB)
* @returns Object with isValid boolean and optional error message
*/
export function validateImageFile(
file: File,
maxFileSize: number = DEFAULT_MAX_FILE_SIZE
): { isValid: boolean; error?: string } {
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
return {
isValid: false,
error: `${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`,
};
}
// Validate file size
if (file.size > maxFileSize) {
const maxSizeMB = maxFileSize / (1024 * 1024);
return {
isValid: false,
error: `${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`,
};
}
return { isValid: true };
}
/**
* Generate a unique image ID
*
* @returns A unique ID string for an image attachment
*/
export function generateImageId(): string {
return `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}
/**
* Generate a unique file ID
*
* @returns A unique ID string for a file attachment
*/
export function generateFileId(): string {
return `file-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}
/**
* Check if a file is a text file by extension or MIME type
*
* @param file - The file to check
* @returns True if the file is a text file
*/
export function isTextFile(file: File): boolean {
const extension = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
const isTextExtension = ACCEPTED_TEXT_EXTENSIONS.includes(extension);
const isTextMime = ACCEPTED_TEXT_TYPES.includes(file.type);
return isTextExtension || isTextMime;
}
/**
* Check if a file is an image file by MIME type
*
* @param file - The file to check
* @returns True if the file is an image file
*/
export function isImageFile(file: File): boolean {
return ACCEPTED_IMAGE_TYPES.includes(file.type);
}
/**
* Validate a text file for upload
*
* @param file - The file to validate
* @param maxFileSize - Maximum file size in bytes (default: 1MB)
* @returns Object with isValid boolean and optional error message
*/
export function validateTextFile(
file: File,
maxFileSize: number = DEFAULT_MAX_TEXT_FILE_SIZE
): { isValid: boolean; error?: string } {
const extension = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
// Validate file type by extension (MIME types for text files are often unreliable)
if (!ACCEPTED_TEXT_EXTENSIONS.includes(extension)) {
return {
isValid: false,
error: `${file.name}: Unsupported file type. Please use .txt or .md files.`,
};
}
// Validate file size
if (file.size > maxFileSize) {
const maxSizeMB = maxFileSize / (1024 * 1024);
return {
isValid: false,
error: `${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`,
};
}
return { isValid: true };
}
/**
* Read text content from a file
*
* @param file - The file to read
* @returns Promise resolving to the text content
*/
export function fileToText(file: File): Promise<string> {
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 text'));
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
}
/**
* Get the MIME type for a text file based on extension
*
* @param filename - The filename to check
* @returns The MIME type for the file
*/
export function getTextFileMimeType(filename: string): string {
const extension = filename.toLowerCase().slice(filename.lastIndexOf('.'));
if (extension === '.md') {
return 'text/markdown';
}
return 'text/plain';
}

View File

@@ -1,6 +1,20 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Project, TrashedProject } from '@/lib/electron';
import type {
Feature as BaseFeature,
FeatureImagePath,
FeatureTextFilePath,
AgentModel,
PlanningMode,
ThinkingLevel,
ModelProvider,
AIProfile,
ThemeMode,
} from '@automaker/types';
// Re-export ThemeMode for convenience
export type { ThemeMode };
export type ViewMode =
| 'welcome'
@@ -202,12 +216,21 @@ export interface ImageAttachment {
size?: number; // file size in bytes - optional for messages from server
}
export interface TextFileAttachment {
id: string;
content: string; // text content of the file
mimeType: string; // e.g., "text/plain", "text/markdown"
filename: string;
size: number; // file size in bytes
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
images?: ImageAttachment[];
textFiles?: TextFileAttachment[];
}
export interface ChatSession {
@@ -220,6 +243,18 @@ export interface ChatSession {
archived: boolean;
}
// Re-export for backward compatibility
export type {
FeatureImagePath,
FeatureTextFilePath,
AgentModel,
PlanningMode,
ThinkingLevel,
ModelProvider,
AIProfile,
};
// UI-specific: base64-encoded images (not in shared types)
export interface FeatureImage {
id: string;
data: string; // base64 encoded
@@ -235,56 +270,18 @@ export interface FeatureImagePath {
mimeType: string;
}
// Available models for feature execution
export type ClaudeModel = 'opus' | 'sonnet' | 'haiku';
export type AgentModel = ClaudeModel;
// Model provider type
export type ModelProvider = 'claude';
// Thinking level (budget_tokens) options
export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
// Planning mode for feature specifications
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
// AI Provider Profile - user-defined presets for model configurations
export interface AIProfile {
id: string;
name: string;
description: string;
model: AgentModel;
thinkingLevel: ThinkingLevel;
provider: ModelProvider;
isBuiltIn: boolean; // Built-in profiles cannot be deleted
icon?: string; // Optional icon name from lucide
}
export interface Feature {
id: string;
title?: string;
titleGenerating?: boolean;
category: string;
description: string;
steps: string[];
// UI-specific Feature extension with UI-only fields and stricter types
export interface Feature extends Omit<
BaseFeature,
'steps' | 'imagePaths' | 'textFilePaths' | 'status'
> {
steps: string[]; // Required in UI (not optional)
status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed';
images?: FeatureImage[];
imagePaths?: FeatureImagePath[]; // Paths to temp files for agent context
startedAt?: string; // ISO timestamp for when the card moved to in_progress
skipTests?: boolean; // When true, skip TDD approach and require manual verification
summary?: string; // Summary of what was done/modified by the agent
model?: AgentModel; // Model to use for this feature (defaults to opus)
thinkingLevel?: ThinkingLevel; // Thinking level for extended thinking (defaults to none)
error?: string; // Error message if the agent errored during processing
priority?: number; // Priority: 1 = high, 2 = medium, 3 = low
dependencies?: string[]; // Array of feature IDs this feature depends on
// Branch info - worktree path is derived at runtime from branchName
branchName?: string; // Name of the feature branch (undefined = use current worktree)
justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes)
planningMode?: PlanningMode; // Planning mode for this feature
planSpec?: PlanSpec; // Generated spec/plan data
requirePlanApproval?: boolean; // Whether to pause and require manual approval before implementation
prUrl?: string; // Pull request URL when a PR has been created for this feature
images?: FeatureImage[]; // UI-specific base64 images
imagePaths?: FeatureImagePath[]; // Stricter type than base (no string | union)
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
prUrl?: string; // UI-specific: Pull request URL
}
// Parsed task from spec (for spec and full planning modes)
@@ -536,6 +533,73 @@ export interface AppState {
planContent: string;
planningMode: 'lite' | 'spec' | 'full';
} | null;
// Claude Usage Tracking
claudeRefreshInterval: number; // Refresh interval in seconds (default: 60)
claudeUsage: ClaudeUsage | null;
claudeUsageLastUpdated: number | null;
}
// Claude Usage interface matching the server response
export type ClaudeUsage = {
sessionTokensUsed: number;
sessionLimit: number;
sessionPercentage: number;
sessionResetTime: string;
sessionResetText: string;
weeklyTokensUsed: number;
weeklyLimit: number;
weeklyPercentage: number;
weeklyResetTime: string;
weeklyResetText: string;
sonnetWeeklyTokensUsed: number;
sonnetWeeklyPercentage: number;
sonnetResetText: string;
costUsed: number | null;
costLimit: number | null;
costCurrency: string | null;
lastUpdated: string;
userTimezone: string;
};
// Response type for Claude usage API (can be success or error)
export type ClaudeUsageResponse = ClaudeUsage | { error: string; message?: string };
/**
* Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit)
* Returns true if any limit is reached, meaning auto mode should pause feature pickup.
*/
export function isClaudeUsageAtLimit(claudeUsage: ClaudeUsage | null): boolean {
if (!claudeUsage) {
// No usage data available - don't block
return false;
}
// Check session limit (5-hour window)
if (claudeUsage.sessionPercentage >= 100) {
return true;
}
// Check weekly limit
if (claudeUsage.weeklyPercentage >= 100) {
return true;
}
// Check cost limit (if configured)
if (
claudeUsage.costLimit !== null &&
claudeUsage.costLimit > 0 &&
claudeUsage.costUsed !== null &&
claudeUsage.costUsed >= claudeUsage.costLimit
) {
return true;
}
return false;
}
// Default background settings for board backgrounds
@@ -1184,7 +1248,7 @@ export const useAppStore = create<AppState & AppActions>()(
addFeature: (feature) => {
const id = feature.id || `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const featureWithId = { ...feature, id } as Feature;
const featureWithId = { ...feature, id } as unknown as Feature;
set({ features: [...get().features, featureWithId] });
return featureWithId;
},
@@ -2487,6 +2551,15 @@ export const useAppStore = create<AppState & AppActions>()(
// Plan Approval actions
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),
// Claude Usage Tracking actions
setClaudeRefreshInterval: (interval: number) => set({ claudeRefreshInterval: interval }),
setClaudeUsageLastUpdated: (timestamp: number) => set({ claudeUsageLastUpdated: timestamp }),
setClaudeUsage: (usage: ClaudeUsage | null) =>
set({
claudeUsage: usage,
claudeUsageLastUpdated: usage ? Date.now() : null,
}),
// Reset
reset: () => set(initialState),
}),

View File

@@ -24,6 +24,18 @@ export async function navigateToContext(page: Page): Promise<void> {
await page.goto('/context');
await page.waitForLoadState('networkidle');
// Wait for loading to complete (if present)
const loadingElement = page.locator('[data-testid="context-view-loading"]');
try {
const loadingVisible = await loadingElement.isVisible({ timeout: 2000 });
if (loadingVisible) {
// Wait for loading to disappear (context view will appear)
await loadingElement.waitFor({ state: 'hidden', timeout: 10000 });
}
} catch {
// Loading element not found or already hidden, continue
}
// Wait for the context view to be visible
await waitForElement(page, 'context-view', { timeout: 10000 });
}

View File

@@ -0,0 +1,170 @@
# Context Files System
This document describes how context files work in Automaker and how to use them in agent prompts.
## Overview
Context files are user-defined documents stored in `.automaker/context/` that provide project-specific rules, conventions, and guidelines for AI agents. They are automatically loaded and prepended to agent prompts.
## Directory Structure
```
{projectPath}/.automaker/context/
├── CLAUDE.md # Project rules and conventions
├── CODE_QUALITY.md # Code quality guidelines
├── context-metadata.json # File descriptions
└── ... (any .md or .txt files)
```
## Metadata
File descriptions are stored in `context-metadata.json`:
```json
{
"files": {
"CLAUDE.md": {
"description": "Project-specific rules including package manager, commit conventions, and architectural patterns"
},
"CODE_QUALITY.md": {
"description": "Code quality standards, testing requirements, and linting rules"
}
}
}
```
## Shared Utility
The `loadContextFiles` function from `@automaker/utils` provides a unified way to load context files:
```typescript
import { loadContextFiles } from '@automaker/utils';
// Load context files from a project
const { formattedPrompt, files } = await loadContextFiles({
projectPath: '/path/to/project',
// Optional: inject custom fs module for secure operations
fsModule: secureFs,
});
// formattedPrompt contains the formatted system prompt
// files contains metadata about each loaded file
```
### Return Value
```typescript
interface ContextFilesResult {
files: ContextFileInfo[]; // Individual file info
formattedPrompt: string; // Formatted prompt ready to use
}
interface ContextFileInfo {
name: string; // File name (e.g., "CLAUDE.md")
path: string; // Full path to file
content: string; // File contents
description?: string; // From metadata (explains when/why to use)
}
```
## Usage in Services
### Auto-Mode Service (Feature Execution)
```typescript
import { loadContextFiles } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
// In executeFeature() or followUpFeature()
const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
});
// Pass as system prompt
await this.runAgent(workDir, featureId, prompt, abortController, projectPath, imagePaths, model, {
projectPath,
systemPrompt: contextFilesPrompt || undefined,
});
```
### Agent Service (Chat Sessions)
```typescript
import { loadContextFiles } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
// In sendMessage()
const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
projectPath: effectiveWorkDir,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
});
// Combine with base system prompt
const combinedSystemPrompt = contextFilesPrompt
? `${contextFilesPrompt}\n\n${baseSystemPrompt}`
: baseSystemPrompt;
```
## Formatted Prompt Structure
The formatted prompt includes:
1. **Header** - Emphasizes that these are project-specific rules
2. **File Entries** - Each file with:
- File name
- Full path (for agents to read more if needed)
- Purpose/description (from metadata)
- Full file content
3. **Reminder** - Reinforces that agents must follow the conventions
Example output:
```markdown
# Project Context Files
The following context files provide project-specific rules, conventions, and guidelines.
Each file serves a specific purpose - use the description to understand when to reference it.
If you need more details about a context file, you can read the full file at the path provided.
**IMPORTANT**: You MUST follow the rules and conventions specified in these files.
- Follow ALL commands exactly as shown (e.g., if the project uses `pnpm`, NEVER use `npm` or `npx`)
- Follow ALL coding conventions, commit message formats, and architectural patterns specified
- Reference these rules before running ANY shell commands or making commits
---
## CLAUDE.md
**Path:** `/path/to/project/.automaker/context/CLAUDE.md`
**Purpose:** Project-specific rules including package manager, commit conventions, and architectural patterns
[File content here]
---
## CODE_QUALITY.md
**Path:** `/path/to/project/.automaker/context/CODE_QUALITY.md`
**Purpose:** Code quality standards, testing requirements, and linting rules
[File content here]
---
**REMINDER**: Before taking any action, verify you are following the conventions specified above.
```
## Best Practices
1. **Add descriptions** - Always add descriptions to `context-metadata.json` so agents understand when to reference each file
2. **Be specific** - Context files should contain concrete rules, not general guidelines
3. **Include examples** - Show correct command usage, commit formats, etc.
4. **Keep focused** - Each file should have a single purpose
## File Locations
- **Shared Utility**: `libs/utils/src/context-loader.ts`
- **Auto-Mode Service**: `apps/server/src/services/auto-mode-service.ts`
- **Agent Service**: `apps/server/src/services/agent-service.ts`

View File

@@ -12,6 +12,15 @@ export interface FeatureImagePath {
[key: string]: unknown;
}
export interface FeatureTextFilePath {
id: string;
path: string;
filename: string;
mimeType: string;
content: string; // Text content of the file
[key: string]: unknown;
}
export interface Feature {
id: string;
title?: string;
@@ -26,6 +35,7 @@ export interface Feature {
spec?: string;
model?: string;
imagePaths?: Array<string | FeatureImagePath | { path: string; [key: string]: unknown }>;
textFilePaths?: FeatureTextFilePath[];
// Branch info - worktree path is derived at runtime from branchName
branchName?: string; // Name of the feature branch (undefined = use current worktree)
skipTests?: boolean;

View File

@@ -16,7 +16,7 @@ export type {
} from './provider.js';
// Feature types
export type { Feature, FeatureImagePath, FeatureStatus } from './feature.js';
export type { Feature, FeatureImagePath, FeatureTextFilePath, FeatureStatus } from './feature.js';
// Session types
export type {

View File

@@ -2,8 +2,8 @@
* Model alias mapping for Claude models
*/
export const CLAUDE_MODEL_MAP: Record<string, string> = {
haiku: 'claude-haiku-4-5',
sonnet: 'claude-sonnet-4-20250514',
haiku: 'claude-haiku-4-5-20251001',
sonnet: 'claude-sonnet-4-5-20250929',
opus: 'claude-opus-4-5-20251101',
} as const;

View File

@@ -0,0 +1,238 @@
/**
* Context Loader - Loads project context files for agent prompts
*
* Provides a shared utility to load context files from .automaker/context/
* and format them as system prompt content. Used by both auto-mode-service
* and agent-service to ensure all agents are aware of project context.
*
* Context files contain project-specific rules, conventions, and guidelines
* that agents must follow when working on the project.
*/
import path from 'path';
import fs from 'fs/promises';
/**
* Metadata structure for context files
* Stored in {projectPath}/.automaker/context/context-metadata.json
*/
export interface ContextMetadata {
files: Record<string, { description: string }>;
}
/**
* Individual context file with metadata
*/
export interface ContextFileInfo {
name: string;
path: string;
content: string;
description?: string;
}
/**
* Result of loading context files
*/
export interface ContextFilesResult {
files: ContextFileInfo[];
formattedPrompt: string;
}
/**
* Options for loading context files
*/
export interface LoadContextFilesOptions {
/** Project path to load context from */
projectPath: string;
/** Optional custom secure fs module (for dependency injection) */
fsModule?: {
access: (path: string) => Promise<void>;
readdir: (path: string) => Promise<string[]>;
readFile: (path: string, encoding: string) => Promise<string>;
};
}
/**
* Get the context directory path for a project
*/
function getContextDir(projectPath: string): string {
return path.join(projectPath, '.automaker', 'context');
}
/**
* Load context metadata from the metadata file
*/
async function loadContextMetadata(
contextDir: string,
fsModule: typeof fs
): Promise<ContextMetadata> {
const metadataPath = path.join(contextDir, 'context-metadata.json');
try {
const content = await fsModule.readFile(metadataPath, 'utf-8');
return JSON.parse(content);
} catch {
// Metadata file doesn't exist yet - that's fine
return { files: {} };
}
}
/**
* Format a single context file entry for the prompt
*/
function formatContextFileEntry(file: ContextFileInfo): string {
const header = `## ${file.name}`;
const pathInfo = `**Path:** \`${file.path}\``;
let descriptionInfo = '';
if (file.description) {
descriptionInfo = `\n**Purpose:** ${file.description}`;
}
return `${header}\n${pathInfo}${descriptionInfo}\n\n${file.content}`;
}
/**
* Build the formatted system prompt from context files
*/
function buildContextPrompt(files: ContextFileInfo[]): string {
if (files.length === 0) {
return '';
}
const formattedFiles = files.map(formatContextFileEntry);
return `# Project Context Files
The following context files provide project-specific rules, conventions, and guidelines.
Each file serves a specific purpose - use the description to understand when to reference it.
If you need more details about a context file, you can read the full file at the path provided.
**IMPORTANT**: You MUST follow the rules and conventions specified in these files.
- Follow ALL commands exactly as shown (e.g., if the project uses \`pnpm\`, NEVER use \`npm\` or \`npx\`)
- Follow ALL coding conventions, commit message formats, and architectural patterns specified
- Reference these rules before running ANY shell commands or making commits
---
${formattedFiles.join('\n\n---\n\n')}
---
**REMINDER**: Before taking any action, verify you are following the conventions specified above.
`;
}
/**
* Load context files from a project's .automaker/context/ directory
*
* This function loads all .md and .txt files from the context directory,
* along with their metadata (descriptions), and formats them into a
* system prompt that can be prepended to agent prompts.
*
* @param options - Configuration options
* @returns Promise resolving to context files and formatted prompt
*
* @example
* ```typescript
* const { formattedPrompt, files } = await loadContextFiles({
* projectPath: '/path/to/project'
* });
*
* // Use as system prompt
* const executeOptions = {
* prompt: userPrompt,
* systemPrompt: formattedPrompt,
* };
* ```
*/
export async function loadContextFiles(
options: LoadContextFilesOptions
): Promise<ContextFilesResult> {
const { projectPath, fsModule = fs } = options;
const contextDir = path.resolve(getContextDir(projectPath));
try {
// Check if directory exists
await fsModule.access(contextDir);
// Read directory contents
const allFiles = await fsModule.readdir(contextDir);
// Filter for text-based context files (case-insensitive for cross-platform)
const textFiles = allFiles.filter((f) => {
const lower = f.toLowerCase();
return (lower.endsWith('.md') || lower.endsWith('.txt')) && f !== 'context-metadata.json';
});
if (textFiles.length === 0) {
return { files: [], formattedPrompt: '' };
}
// Load metadata for descriptions
const metadata = await loadContextMetadata(contextDir, fsModule as typeof fs);
// Load each file with its content and metadata
const files: ContextFileInfo[] = [];
for (const fileName of textFiles) {
const filePath = path.join(contextDir, fileName);
try {
const content = await fsModule.readFile(filePath, 'utf-8');
files.push({
name: fileName,
path: filePath,
content,
description: metadata.files[fileName]?.description,
});
} catch (error) {
console.warn(`[ContextLoader] Failed to read context file ${fileName}:`, error);
}
}
const formattedPrompt = buildContextPrompt(files);
console.log(
`[ContextLoader] Loaded ${files.length} context file(s): ${files.map((f) => f.name).join(', ')}`
);
return { files, formattedPrompt };
} catch {
// Context directory doesn't exist or is inaccessible - this is fine
return { files: [], formattedPrompt: '' };
}
}
/**
* Get a summary of available context files (names and descriptions only)
* Useful for informing the agent about what context is available without
* loading full content.
*/
export async function getContextFilesSummary(
options: LoadContextFilesOptions
): Promise<Array<{ name: string; path: string; description?: string }>> {
const { projectPath, fsModule = fs } = options;
const contextDir = path.resolve(getContextDir(projectPath));
try {
await fsModule.access(contextDir);
const allFiles = await fsModule.readdir(contextDir);
const textFiles = allFiles.filter((f) => {
const lower = f.toLowerCase();
return (lower.endsWith('.md') || lower.endsWith('.txt')) && f !== 'context-metadata.json';
});
if (textFiles.length === 0) {
return [];
}
const metadata = await loadContextMetadata(contextDir, fsModule as typeof fs);
return textFiles.map((fileName) => ({
name: fileName,
path: path.join(contextDir, fileName),
description: metadata.files[fileName]?.description,
}));
} catch {
return [];
}
}

View File

@@ -44,3 +44,13 @@ export { mkdirSafe, existsSafe } from './fs-utils.js';
// Path utilities
export { normalizePath, pathsEqual } from './path-utils.js';
// Context file loading
export {
loadContextFiles,
getContextFilesSummary,
type ContextMetadata,
type ContextFileInfo,
type ContextFilesResult,
type LoadContextFilesOptions,
} from './context-loader.js';