mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
fix conflicts
This commit is contained in:
1282
apps/app/server-bundle/package-lock.json
generated
Normal file
1282
apps/app/server-bundle/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
apps/app/server-bundle/package.json
Normal file
15
apps/app/server-bundle/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@automaker/server-bundle",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.61",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"morgan": "^1.10.1",
|
||||
"node-pty": "1.1.0-beta41",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { createSpecRegenerationRoutes } from './routes/app-spec/index.js';
|
||||
import { createClaudeRoutes } from './routes/claude/index.js';
|
||||
import { ClaudeUsageService } from './services/claude-usage-service.js';
|
||||
import { createGitHubRoutes } from './routes/github/index.js';
|
||||
import { createContextRoutes } from './routes/context/index.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -147,6 +148,7 @@ app.use('/api/terminal', createTerminalRoutes());
|
||||
app.use('/api/settings', createSettingsRoutes(settingsService));
|
||||
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||
app.use('/api/github', createGitHubRoutes());
|
||||
app.use('/api/context', createContextRoutes());
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
@@ -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,
|
||||
|
||||
24
apps/server/src/routes/context/index.ts
Normal file
24
apps/server/src/routes/context/index.ts
Normal 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;
|
||||
}
|
||||
220
apps/server/src/routes/context/routes/describe-file.ts
Normal file
220
apps/server/src/routes/context/routes/describe-file.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
416
apps/server/src/routes/context/routes/describe-image.ts
Normal file
416
apps/server/src/routes/context/routes/describe-image.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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!,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -739,7 +747,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
|
||||
@@ -867,7 +878,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,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1050,63 +1061,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
|
||||
*/
|
||||
|
||||
@@ -24,12 +24,12 @@ describe('model-resolver.ts', () => {
|
||||
describe('resolveModelString', () => {
|
||||
it("should resolve 'haiku' alias to full model string", () => {
|
||||
const result = resolveModelString('haiku');
|
||||
expect(result).toBe('claude-haiku-4-5');
|
||||
expect(result).toBe(CLAUDE_MODEL_MAP.haiku);
|
||||
});
|
||||
|
||||
it("should resolve 'sonnet' alias to full model string", () => {
|
||||
const result = resolveModelString('sonnet');
|
||||
expect(result).toBe('claude-sonnet-4-20250514');
|
||||
expect(result).toBe(CLAUDE_MODEL_MAP.sonnet);
|
||||
});
|
||||
|
||||
it("should resolve 'opus' alias to full model string", () => {
|
||||
@@ -50,7 +50,7 @@ describe('model-resolver.ts', () => {
|
||||
});
|
||||
|
||||
it('should pass through full Claude model strings', () => {
|
||||
const models = ['claude-opus-4-5-20251101', 'claude-sonnet-4-20250514', 'claude-haiku-4-5'];
|
||||
const models = [CLAUDE_MODEL_MAP.opus, CLAUDE_MODEL_MAP.sonnet, CLAUDE_MODEL_MAP.haiku];
|
||||
models.forEach((model) => {
|
||||
const result = resolveModelString(model);
|
||||
expect(result).toBe(model);
|
||||
@@ -93,11 +93,11 @@ describe('model-resolver.ts', () => {
|
||||
|
||||
it('should use session model when explicit is not provided', () => {
|
||||
const result = getEffectiveModel(undefined, 'sonnet', 'gpt-5.2');
|
||||
expect(result).toBe('claude-sonnet-4-20250514');
|
||||
expect(result).toBe(CLAUDE_MODEL_MAP.sonnet);
|
||||
});
|
||||
|
||||
it('should use default when neither explicit nor session is provided', () => {
|
||||
const customDefault = 'claude-haiku-4-5';
|
||||
const customDefault = CLAUDE_MODEL_MAP.haiku;
|
||||
const result = getEffectiveModel(undefined, undefined, customDefault);
|
||||
expect(result).toBe(customDefault);
|
||||
});
|
||||
@@ -109,7 +109,7 @@ describe('model-resolver.ts', () => {
|
||||
|
||||
it('should handle explicit empty strings as undefined', () => {
|
||||
const result = getEffectiveModel('', 'haiku');
|
||||
expect(result).toBe('claude-haiku-4-5');
|
||||
expect(result).toBe(CLAUDE_MODEL_MAP.haiku);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -307,10 +307,10 @@ describe('claude-provider.ts', () => {
|
||||
expect(sonnet35).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include Claude 3.5 Haiku', () => {
|
||||
it('should include Claude Haiku 4.5', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
const haiku = models.find((m) => m.id === 'claude-3-5-haiku-20241022');
|
||||
const haiku = models.find((m) => m.id === 'claude-haiku-4-5-20251001');
|
||||
expect(haiku).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -15,6 +15,11 @@ export default defineConfig({
|
||||
'src/**/*.d.ts',
|
||||
'src/index.ts',
|
||||
'src/routes/**', // Routes are better tested with integration tests
|
||||
'src/types/**', // Type re-exports don't need coverage
|
||||
'src/middleware/**', // Middleware needs integration tests
|
||||
'src/lib/enhancement-prompts.ts', // Prompt templates don't need unit tests
|
||||
'src/services/claude-usage-service.ts', // TODO: Add tests for usage tracking
|
||||
'**/libs/**', // Exclude aliased shared packages from server coverage
|
||||
],
|
||||
thresholds: {
|
||||
// Increased thresholds to ensure better code quality
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ export function Sidebar() {
|
||||
const isCreatingSpec = specCreatingForProject !== null;
|
||||
const creatingSpecProjectPath = specCreatingForProject;
|
||||
|
||||
// Auto-collapse sidebar on small screens
|
||||
// Auto-collapse sidebar on small screens and update Electron window minWidth
|
||||
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
||||
|
||||
// Running agents count
|
||||
|
||||
@@ -32,4 +32,17 @@ export function useSidebarAutoCollapse({
|
||||
mediaQuery.addEventListener('change', handleResize);
|
||||
return () => mediaQuery.removeEventListener('change', handleResize);
|
||||
}, [sidebarOpen, toggleSidebar]);
|
||||
|
||||
// Update Electron window minWidth when sidebar state changes
|
||||
// This ensures the window can't be resized smaller than what the kanban board needs
|
||||
useEffect(() => {
|
||||
const electronAPI = (
|
||||
window as unknown as {
|
||||
electronAPI?: { updateMinWidth?: (expanded: boolean) => Promise<void> };
|
||||
}
|
||||
).electronAPI;
|
||||
if (electronAPI?.updateMinWidth) {
|
||||
electronAPI.updateMinWidth(sidebarOpen);
|
||||
}
|
||||
}, [sidebarOpen]);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -38,7 +38,9 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'relative flex flex-col h-full rounded-xl transition-all duration-200',
|
||||
'relative flex flex-col h-full rounded-xl',
|
||||
// Only transition ring/shadow for drag-over effect, not width
|
||||
'transition-[box-shadow,ring] duration-200',
|
||||
!width && 'w-72', // Only apply w-72 if no custom width
|
||||
showBorder && 'border border-border/60',
|
||||
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
|
||||
|
||||
@@ -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 {
|
||||
@@ -63,6 +64,7 @@ interface AddFeatureDialogProps {
|
||||
description: string;
|
||||
images: FeatureImage[];
|
||||
imagePaths: DescriptionImagePath[];
|
||||
textFilePaths: DescriptionTextFilePath[];
|
||||
skipTests: boolean;
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
@@ -104,6 +106,7 @@ export function AddFeatureDialog({
|
||||
description: '',
|
||||
images: [] as FeatureImage[],
|
||||
imagePaths: [] as DescriptionImagePath[],
|
||||
textFilePaths: [] as DescriptionTextFilePath[],
|
||||
skipTests: false,
|
||||
model: 'opus' as AgentModel,
|
||||
thinkingLevel: 'none' as ThinkingLevel,
|
||||
@@ -190,6 +193,7 @@ export function AddFeatureDialog({
|
||||
description: newFeature.description,
|
||||
images: newFeature.images,
|
||||
imagePaths: newFeature.imagePaths,
|
||||
textFilePaths: newFeature.textFilePaths,
|
||||
skipTests: newFeature.skipTests,
|
||||
model: selectedModel,
|
||||
thinkingLevel: normalizedThinking,
|
||||
@@ -206,6 +210,7 @@ export function AddFeatureDialog({
|
||||
description: '',
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
textFilePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
model: 'opus',
|
||||
priority: 2,
|
||||
@@ -327,6 +332,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}
|
||||
|
||||
@@ -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 {
|
||||
@@ -67,6 +68,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;
|
||||
@@ -166,6 +168,7 @@ export function EditFeatureDialog({
|
||||
model: selectedModel,
|
||||
thinkingLevel: normalizedThinking,
|
||||
imagePaths: editingFeature.imagePaths ?? [],
|
||||
textFilePaths: editingFeature.textFilePaths ?? [],
|
||||
branchName: finalBranchName,
|
||||
priority: editingFeature.priority ?? 2,
|
||||
planningMode,
|
||||
@@ -292,6 +295,13 @@ export function EditFeatureDialog({
|
||||
imagePaths: images,
|
||||
})
|
||||
}
|
||||
textFiles={editingFeature.textFilePaths ?? []}
|
||||
onTextFilesChange={(textFiles) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
textFilePaths: textFiles,
|
||||
})
|
||||
}
|
||||
placeholder="Describe the feature..."
|
||||
previewMap={editFeaturePreviewMap}
|
||||
onPreviewMapChange={setEditFeaturePreviewMap}
|
||||
|
||||
@@ -82,17 +82,18 @@ export function KanbanBoard({
|
||||
onArchiveAllVerified,
|
||||
}: KanbanBoardProps) {
|
||||
// Use responsive column widths based on window size
|
||||
const { columnWidth } = useResponsiveKanban(COLUMNS.length);
|
||||
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
|
||||
const { columnWidth, containerStyle } = useResponsiveKanban(COLUMNS.length);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-x-auto px-4 pb-4 relative" style={backgroundImageStyle}>
|
||||
<div className="flex-1 overflow-x-hidden px-5 pb-4 relative" style={backgroundImageStyle}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<div className="flex gap-5 h-full py-1 justify-center">
|
||||
<div className="h-full py-1" style={containerStyle}>
|
||||
{COLUMNS.map((column) => {
|
||||
const columnFeatures = getColumnFeatures(column.id);
|
||||
return (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
export interface ResponsiveKanbanConfig {
|
||||
columnWidth: number;
|
||||
@@ -13,16 +14,21 @@ export interface ResponsiveKanbanConfig {
|
||||
*/
|
||||
const DEFAULT_CONFIG: ResponsiveKanbanConfig = {
|
||||
columnWidth: 288, // 18rem = 288px (w-72)
|
||||
columnMinWidth: 280, // Minimum column width - increased to ensure usability
|
||||
columnMaxWidth: 360, // Maximum column width to ensure responsive scaling
|
||||
columnMinWidth: 280, // Minimum column width - ensures usability
|
||||
columnMaxWidth: Infinity, // No max width - columns scale evenly to fill viewport
|
||||
gap: 20, // gap-5 = 20px
|
||||
padding: 32, // px-4 on both sides = 32px
|
||||
padding: 40, // px-5 on both sides = 40px (matches gap between columns)
|
||||
};
|
||||
|
||||
// Sidebar transition duration (matches sidebar.tsx)
|
||||
const SIDEBAR_TRANSITION_MS = 300;
|
||||
|
||||
export interface UseResponsiveKanbanResult {
|
||||
columnWidth: number;
|
||||
containerStyle: React.CSSProperties;
|
||||
isCompact: boolean;
|
||||
totalBoardWidth: number;
|
||||
isInitialized: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,9 +36,14 @@ export interface UseResponsiveKanbanResult {
|
||||
* Ensures columns scale intelligently to fill available space without
|
||||
* dead space on the right or content being cut off.
|
||||
*
|
||||
* Features:
|
||||
* - Uses useLayoutEffect to calculate width before paint (prevents bounce)
|
||||
* - Observes actual board container for accurate sizing
|
||||
* - Recalculates after sidebar transitions
|
||||
*
|
||||
* @param columnCount - Number of columns in the Kanban board
|
||||
* @param config - Optional configuration for column sizing
|
||||
* @returns Object with calculated column width and container styles
|
||||
* @returns Object with calculated column width, container styles, and metrics
|
||||
*/
|
||||
export function useResponsiveKanban(
|
||||
columnCount: number = 4,
|
||||
@@ -43,68 +54,129 @@ export function useResponsiveKanban(
|
||||
...config,
|
||||
};
|
||||
|
||||
const calculateColumnWidth = useCallback(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_CONFIG.columnWidth;
|
||||
}
|
||||
const sidebarOpen = useAppStore((state) => state.sidebarOpen);
|
||||
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Get the actual board container width
|
||||
// The flex layout already accounts for sidebar width, so we use the container's actual width
|
||||
const boardContainer = document.querySelector('[data-testid="board-view"]')?.parentElement;
|
||||
const containerWidth = boardContainer ? boardContainer.clientWidth : window.innerWidth;
|
||||
const calculateColumnWidth = useCallback(
|
||||
(containerWidth?: number) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_CONFIG.columnWidth;
|
||||
}
|
||||
|
||||
// Get the available width (subtract padding only)
|
||||
const availableWidth = containerWidth - padding;
|
||||
// Get the actual board container width
|
||||
// The flex layout already accounts for sidebar width, so we use the container's actual width
|
||||
let width = containerWidth;
|
||||
if (width === undefined) {
|
||||
const boardContainer = document.querySelector('[data-testid="board-view"]')?.parentElement;
|
||||
width = boardContainer ? boardContainer.clientWidth : window.innerWidth;
|
||||
}
|
||||
|
||||
// Calculate total gap space needed
|
||||
const totalGapWidth = gap * (columnCount - 1);
|
||||
// Get the available width (subtract padding only)
|
||||
const availableWidth = width - padding;
|
||||
|
||||
// Calculate width available for all columns
|
||||
const widthForColumns = availableWidth - totalGapWidth;
|
||||
// Calculate total gap space needed
|
||||
const totalGapWidth = gap * (columnCount - 1);
|
||||
|
||||
// Calculate ideal column width
|
||||
let idealWidth = Math.floor(widthForColumns / columnCount);
|
||||
// Calculate width available for all columns
|
||||
const widthForColumns = availableWidth - totalGapWidth;
|
||||
|
||||
// Clamp to min/max bounds
|
||||
idealWidth = Math.max(columnMinWidth, Math.min(columnMaxWidth, idealWidth));
|
||||
// Calculate ideal column width
|
||||
let idealWidth = Math.floor(widthForColumns / columnCount);
|
||||
|
||||
return idealWidth;
|
||||
}, [columnCount, columnMinWidth, columnMaxWidth, gap, padding]);
|
||||
// Clamp to min/max bounds
|
||||
idealWidth = Math.max(columnMinWidth, Math.min(columnMaxWidth, idealWidth));
|
||||
|
||||
return idealWidth;
|
||||
},
|
||||
[columnCount, columnMinWidth, columnMaxWidth, gap, padding]
|
||||
);
|
||||
|
||||
const [columnWidth, setColumnWidth] = useState<number>(() => calculateColumnWidth());
|
||||
|
||||
// Use useLayoutEffect to calculate width synchronously before paint
|
||||
// This prevents the "bounce" effect when navigating to the kanban view
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const updateWidth = () => {
|
||||
const newWidth = calculateColumnWidth();
|
||||
setColumnWidth(newWidth);
|
||||
setIsInitialized(true);
|
||||
};
|
||||
|
||||
// Calculate immediately before paint
|
||||
updateWidth();
|
||||
}, [calculateColumnWidth]);
|
||||
|
||||
// Set up ResizeObserver for ongoing resize handling
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleResize = () => {
|
||||
const updateWidth = () => {
|
||||
const newWidth = calculateColumnWidth();
|
||||
setColumnWidth(newWidth);
|
||||
};
|
||||
|
||||
// Set initial width
|
||||
handleResize();
|
||||
// Debounced update for smooth resize transitions
|
||||
const scheduleUpdate = () => {
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
resizeTimeoutRef.current = setTimeout(updateWidth, 50);
|
||||
};
|
||||
|
||||
// Use ResizeObserver for more precise updates if available
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver(handleResize);
|
||||
observer.observe(document.body);
|
||||
// Use ResizeObserver on the actual board container for precise updates
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
const boardView = document.querySelector('[data-testid="board-view"]');
|
||||
const container = boardView?.parentElement;
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
if (container && typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
// Use the observed container's width for calculation
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
const containerWidth = entry.contentRect.width;
|
||||
const newWidth = calculateColumnWidth(containerWidth);
|
||||
setColumnWidth(newWidth);
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
}
|
||||
|
||||
// Fallback to window resize event
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('resize', scheduleUpdate);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
window.removeEventListener('resize', scheduleUpdate);
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [calculateColumnWidth]);
|
||||
|
||||
// Re-calculate after sidebar transitions complete
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
const newWidth = calculateColumnWidth();
|
||||
setColumnWidth(newWidth);
|
||||
}, SIDEBAR_TRANSITION_MS + 50); // Wait for transition to complete
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [sidebarOpen, calculateColumnWidth]);
|
||||
|
||||
// Determine if we're in compact mode (columns at minimum width)
|
||||
const isCompact = columnWidth <= columnMinWidth + 10;
|
||||
|
||||
// Container style to center content and prevent overflow
|
||||
// Calculate total board width for container sizing
|
||||
const totalBoardWidth = columnWidth * columnCount + gap * (columnCount - 1);
|
||||
|
||||
// Container style to center content
|
||||
// Use flex layout with justify-center to naturally center columns
|
||||
// The parent container has px-4 padding which provides equal left/right margins
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: `${gap}px`,
|
||||
@@ -116,5 +188,7 @@ export function useResponsiveKanban(
|
||||
columnWidth,
|
||||
containerStyle,
|
||||
isCompact,
|
||||
totalBoardWidth,
|
||||
isInitialized,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -536,6 +536,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
|
||||
|
||||
@@ -1011,6 +1011,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
|
||||
|
||||
236
apps/ui/src/lib/image-utils.ts
Normal file
236
apps/ui/src/lib/image-utils.ts
Normal 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';
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import path from 'path';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import http, { Server } from 'http';
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron';
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
|
||||
|
||||
// Development environment
|
||||
const isDev = !app.isPackaged;
|
||||
@@ -31,6 +31,39 @@ let staticServer: Server | null = null;
|
||||
const SERVER_PORT = 3008;
|
||||
const STATIC_PORT = 3007;
|
||||
|
||||
// ============================================
|
||||
// Window sizing constants for kanban layout
|
||||
// ============================================
|
||||
// Calculation: 4 columns × 280px + 3 gaps × 20px + 40px padding = 1220px board content
|
||||
// With sidebar expanded (288px): 1220 + 288 = 1508px
|
||||
// With sidebar collapsed (64px): 1220 + 64 = 1284px
|
||||
const COLUMN_MIN_WIDTH = 280;
|
||||
const COLUMN_COUNT = 4;
|
||||
const GAP_SIZE = 20;
|
||||
const BOARD_PADDING = 40; // px-5 on both sides = 40px (matches gap between columns)
|
||||
const SIDEBAR_EXPANDED = 288;
|
||||
const SIDEBAR_COLLAPSED = 64;
|
||||
|
||||
const BOARD_CONTENT_MIN =
|
||||
COLUMN_MIN_WIDTH * COLUMN_COUNT + GAP_SIZE * (COLUMN_COUNT - 1) + BOARD_PADDING;
|
||||
const MIN_WIDTH_EXPANDED = BOARD_CONTENT_MIN + SIDEBAR_EXPANDED; // 1500px
|
||||
const MIN_WIDTH_COLLAPSED = BOARD_CONTENT_MIN + SIDEBAR_COLLAPSED; // 1276px
|
||||
const MIN_HEIGHT = 650; // Ensures sidebar content fits without scrolling
|
||||
const DEFAULT_WIDTH = 1600;
|
||||
const DEFAULT_HEIGHT = 950;
|
||||
|
||||
// Window bounds interface (matches @automaker/types WindowBounds)
|
||||
interface WindowBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isMaximized: boolean;
|
||||
}
|
||||
|
||||
// Debounce timer for saving window bounds
|
||||
let saveWindowBoundsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* Get icon path - works in both dev and production, cross-platform
|
||||
*/
|
||||
@@ -56,6 +89,120 @@ function getIconPath(): string | null {
|
||||
return iconPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to window bounds settings file
|
||||
*/
|
||||
function getWindowBoundsPath(): string {
|
||||
return path.join(app.getPath('userData'), 'window-bounds.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load saved window bounds from disk
|
||||
*/
|
||||
function loadWindowBounds(): WindowBounds | null {
|
||||
try {
|
||||
const boundsPath = getWindowBoundsPath();
|
||||
if (fs.existsSync(boundsPath)) {
|
||||
const data = fs.readFileSync(boundsPath, 'utf-8');
|
||||
const bounds = JSON.parse(data) as WindowBounds;
|
||||
// Validate the loaded data has required fields
|
||||
if (
|
||||
typeof bounds.x === 'number' &&
|
||||
typeof bounds.y === 'number' &&
|
||||
typeof bounds.width === 'number' &&
|
||||
typeof bounds.height === 'number'
|
||||
) {
|
||||
return bounds;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Electron] Failed to load window bounds:', (error as Error).message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save window bounds to disk
|
||||
*/
|
||||
function saveWindowBounds(bounds: WindowBounds): void {
|
||||
try {
|
||||
const boundsPath = getWindowBoundsPath();
|
||||
fs.writeFileSync(boundsPath, JSON.stringify(bounds, null, 2), 'utf-8');
|
||||
console.log('[Electron] Window bounds saved');
|
||||
} catch (error) {
|
||||
console.warn('[Electron] Failed to save window bounds:', (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a debounced save of window bounds (500ms delay)
|
||||
*/
|
||||
function scheduleSaveWindowBounds(): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
|
||||
if (saveWindowBoundsTimeout) {
|
||||
clearTimeout(saveWindowBoundsTimeout);
|
||||
}
|
||||
|
||||
saveWindowBoundsTimeout = setTimeout(() => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
|
||||
const isMaximized = mainWindow.isMaximized();
|
||||
// Use getNormalBounds() for maximized windows to save pre-maximized size
|
||||
const bounds = isMaximized ? mainWindow.getNormalBounds() : mainWindow.getBounds();
|
||||
|
||||
saveWindowBounds({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
isMaximized,
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that window bounds are visible on at least one display
|
||||
* Returns adjusted bounds if needed, or null if completely off-screen
|
||||
*/
|
||||
function validateBounds(bounds: WindowBounds): WindowBounds {
|
||||
const displays = screen.getAllDisplays();
|
||||
|
||||
// Check if window center is visible on any display
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
|
||||
let isVisible = false;
|
||||
for (const display of displays) {
|
||||
const { x, y, width, height } = display.workArea;
|
||||
if (centerX >= x && centerX <= x + width && centerY >= y && centerY <= y + height) {
|
||||
isVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isVisible) {
|
||||
// Window is off-screen, reset to primary display
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const { x, y, width, height } = primaryDisplay.workArea;
|
||||
|
||||
return {
|
||||
x: x + Math.floor((width - bounds.width) / 2),
|
||||
y: y + Math.floor((height - bounds.height) / 2),
|
||||
width: Math.min(bounds.width, width),
|
||||
height: Math.min(bounds.height, height),
|
||||
isMaximized: bounds.isMaximized,
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure minimum dimensions
|
||||
return {
|
||||
...bounds,
|
||||
width: Math.max(bounds.width, MIN_WIDTH_EXPANDED),
|
||||
height: Math.max(bounds.height, MIN_HEIGHT),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start static file server for production builds
|
||||
*/
|
||||
@@ -246,11 +393,18 @@ async function waitForServer(maxAttempts = 30): Promise<void> {
|
||||
*/
|
||||
function createWindow(): void {
|
||||
const iconPath = getIconPath();
|
||||
|
||||
// Load and validate saved window bounds
|
||||
const savedBounds = loadWindowBounds();
|
||||
const validBounds = savedBounds ? validateBounds(savedBounds) : null;
|
||||
|
||||
const windowOptions: Electron.BrowserWindowConstructorOptions = {
|
||||
width: 1600,
|
||||
height: 950,
|
||||
minWidth: 1280,
|
||||
minHeight: 768,
|
||||
width: validBounds?.width ?? DEFAULT_WIDTH,
|
||||
height: validBounds?.height ?? DEFAULT_HEIGHT,
|
||||
x: validBounds?.x,
|
||||
y: validBounds?.y,
|
||||
minWidth: MIN_WIDTH_EXPANDED, // 1500px - ensures kanban columns fit with sidebar
|
||||
minHeight: MIN_HEIGHT,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
@@ -266,6 +420,11 @@ function createWindow(): void {
|
||||
|
||||
mainWindow = new BrowserWindow(windowOptions);
|
||||
|
||||
// Restore maximized state if previously maximized
|
||||
if (validBounds?.isMaximized) {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
|
||||
// Load Vite dev server in development or static server in production
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
||||
@@ -280,10 +439,35 @@ function createWindow(): void {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
// Save window bounds on close, resize, and move
|
||||
mainWindow.on('close', () => {
|
||||
// Save immediately before closing (not debounced)
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const isMaximized = mainWindow.isMaximized();
|
||||
const bounds = isMaximized ? mainWindow.getNormalBounds() : mainWindow.getBounds();
|
||||
|
||||
saveWindowBounds({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
isMaximized,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
mainWindow.on('resized', () => {
|
||||
scheduleSaveWindowBounds();
|
||||
});
|
||||
|
||||
mainWindow.on('moved', () => {
|
||||
scheduleSaveWindowBounds();
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
@@ -460,3 +644,17 @@ ipcMain.handle('ping', async () => {
|
||||
ipcMain.handle('server:getUrl', async () => {
|
||||
return `http://localhost:${SERVER_PORT}`;
|
||||
});
|
||||
|
||||
// Window management - update minimum width based on sidebar state
|
||||
ipcMain.handle('window:updateMinWidth', (_, sidebarExpanded: boolean) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
|
||||
const minWidth = sidebarExpanded ? MIN_WIDTH_EXPANDED : MIN_WIDTH_COLLAPSED;
|
||||
mainWindow.setMinimumSize(minWidth, MIN_HEIGHT);
|
||||
|
||||
// If current width is below new minimum, resize window
|
||||
const currentBounds = mainWindow.getBounds();
|
||||
if (currentBounds.width < minWidth) {
|
||||
mainWindow.setSize(minWidth, currentBounds.height);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -43,6 +43,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getPath: (name: string): Promise<string> => ipcRenderer.invoke('app:getPath', name),
|
||||
getVersion: (): Promise<string> => ipcRenderer.invoke('app:getVersion'),
|
||||
isPackaged: (): Promise<boolean> => ipcRenderer.invoke('app:isPackaged'),
|
||||
|
||||
// Window management
|
||||
updateMinWidth: (sidebarExpanded: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke('window:updateMinWidth', sidebarExpanded),
|
||||
});
|
||||
|
||||
console.log('[Preload] Electron API exposed (TypeScript)');
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Project, TrashedProject } from '@/lib/electron';
|
||||
import type {
|
||||
Feature as BaseFeature,
|
||||
FeatureImagePath,
|
||||
AgentModel,
|
||||
PlanningMode,
|
||||
AIProfile,
|
||||
} from '@automaker/types';
|
||||
|
||||
// Re-export ThemeMode for convenience
|
||||
export type { ThemeMode };
|
||||
|
||||
export type ViewMode =
|
||||
| 'welcome'
|
||||
@@ -202,12 +212,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 +239,7 @@ export interface ChatSession {
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
// UI-specific: base64-encoded images (not in shared types)
|
||||
export interface FeatureImage {
|
||||
id: string;
|
||||
data: string; // base64 encoded
|
||||
@@ -228,62 +248,25 @@ export interface FeatureImage {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface FeatureImagePath {
|
||||
id: string;
|
||||
path: string; // Path to the temp file
|
||||
filename: string;
|
||||
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 {
|
||||
export interface Feature extends Omit<
|
||||
BaseFeature,
|
||||
'steps' | 'imagePaths' | 'textFilePaths' | 'status'
|
||||
> {
|
||||
id: string;
|
||||
title?: string;
|
||||
titleGenerating?: boolean;
|
||||
category: string;
|
||||
description: string;
|
||||
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)
|
||||
@@ -535,6 +518,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
|
||||
@@ -1183,7 +1233,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;
|
||||
},
|
||||
@@ -2486,6 +2536,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),
|
||||
}),
|
||||
|
||||
@@ -46,28 +46,25 @@ test.describe('Context View - File Management', () => {
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Click Add File button
|
||||
await clickElement(page, 'add-context-file');
|
||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
||||
// Click Create Markdown button
|
||||
await clickElement(page, 'create-markdown-button');
|
||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Select text type (should be default)
|
||||
await clickElement(page, 'add-text-type');
|
||||
|
||||
// Enter filename
|
||||
await fillInput(page, 'new-file-name', 'test-context.md');
|
||||
await fillInput(page, 'new-markdown-name', 'test-context.md');
|
||||
|
||||
// Enter content
|
||||
const testContent = '# Test Context\n\nThis is test content';
|
||||
await fillInput(page, 'new-file-content', testContent);
|
||||
await fillInput(page, 'new-markdown-content', testContent);
|
||||
|
||||
// Click confirm
|
||||
await clickElement(page, 'confirm-add-file');
|
||||
await clickElement(page, 'confirm-create-markdown');
|
||||
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
@@ -208,36 +205,16 @@ test.describe('Context View - File Management', () => {
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Click Add File button
|
||||
await clickElement(page, 'add-context-file');
|
||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
// Use the hidden file input to upload an image directly
|
||||
// The "Import File" button triggers this input
|
||||
const fileInput = page.locator('[data-testid="file-import-input"]');
|
||||
await fileInput.setInputFiles(TEST_IMAGE_SRC);
|
||||
|
||||
// Select image type
|
||||
await clickElement(page, 'add-image-type');
|
||||
|
||||
// Enter filename
|
||||
await fillInput(page, 'new-file-name', 'test-image.png');
|
||||
|
||||
// Upload image using file input
|
||||
await page.setInputFiles('[data-testid="image-upload-input"]', TEST_IMAGE_SRC);
|
||||
|
||||
// Wait for image preview to appear (indicates upload success)
|
||||
const addDialog = await getByTestId(page, 'add-context-dialog');
|
||||
await addDialog.locator('img').waitFor({ state: 'visible' });
|
||||
|
||||
// Click confirm
|
||||
await clickElement(page, 'confirm-add-file');
|
||||
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
// Wait for file to appear in the list (filename is extracted from path)
|
||||
await waitForContextFile(page, 'logo.png', 10000);
|
||||
|
||||
// Verify file appears in list
|
||||
const fileButton = await getByTestId(page, 'context-file-test-image.png');
|
||||
const fileButton = await getByTestId(page, 'context-file-logo.png');
|
||||
await expect(fileButton).toBeVisible();
|
||||
|
||||
// Click on the image to view it
|
||||
@@ -362,26 +339,23 @@ test.describe('Context View - Drag and Drop', () => {
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Open add file dialog
|
||||
await clickElement(page, 'add-context-file');
|
||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
||||
// Open create markdown dialog
|
||||
await clickElement(page, 'create-markdown-button');
|
||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Ensure text type is selected
|
||||
await clickElement(page, 'add-text-type');
|
||||
|
||||
// Simulate drag and drop of a .md file onto the textarea
|
||||
const droppedContent = '# Dropped Content\n\nThis was dragged and dropped.';
|
||||
await simulateFileDrop(
|
||||
page,
|
||||
'[data-testid="new-file-content"]',
|
||||
'[data-testid="new-markdown-content"]',
|
||||
'dropped-file.md',
|
||||
droppedContent
|
||||
);
|
||||
|
||||
// Wait for content to be populated in textarea
|
||||
const textarea = await getByTestId(page, 'new-file-content');
|
||||
const textarea = await getByTestId(page, 'new-markdown-content');
|
||||
await textarea.waitFor({ state: 'visible' });
|
||||
await expect(textarea).toHaveValue(droppedContent);
|
||||
|
||||
@@ -390,15 +364,15 @@ test.describe('Context View - Drag and Drop', () => {
|
||||
expect(textareaContent).toBe(droppedContent);
|
||||
|
||||
// Verify filename is auto-filled
|
||||
const filenameValue = await page.locator('[data-testid="new-file-name"]').inputValue();
|
||||
const filenameValue = await page.locator('[data-testid="new-markdown-name"]').inputValue();
|
||||
expect(filenameValue).toBe('dropped-file.md');
|
||||
|
||||
// Confirm and create the file
|
||||
await clickElement(page, 'confirm-add-file');
|
||||
await clickElement(page, 'confirm-create-markdown');
|
||||
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
@@ -473,20 +447,19 @@ test.describe('Context View - Edge Cases', () => {
|
||||
await expect(originalFile).toBeVisible();
|
||||
|
||||
// Try to create another file with the same name
|
||||
await clickElement(page, 'add-context-file');
|
||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
||||
await clickElement(page, 'create-markdown-button');
|
||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await clickElement(page, 'add-text-type');
|
||||
await fillInput(page, 'new-file-name', 'test.md');
|
||||
await fillInput(page, 'new-file-content', '# New Content - Overwritten');
|
||||
await fillInput(page, 'new-markdown-name', 'test.md');
|
||||
await fillInput(page, 'new-markdown-content', '# New Content - Overwritten');
|
||||
|
||||
await clickElement(page, 'confirm-add-file');
|
||||
await clickElement(page, 'confirm-create-markdown');
|
||||
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
@@ -518,18 +491,17 @@ test.describe('Context View - Edge Cases', () => {
|
||||
await navigateToContext(page);
|
||||
|
||||
// Test file with parentheses
|
||||
await clickElement(page, 'add-context-file');
|
||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
||||
await clickElement(page, 'create-markdown-button');
|
||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await clickElement(page, 'add-text-type');
|
||||
await fillInput(page, 'new-file-name', 'context (1).md');
|
||||
await fillInput(page, 'new-file-content', 'Content with parentheses in filename');
|
||||
await fillInput(page, 'new-markdown-name', 'context (1).md');
|
||||
await fillInput(page, 'new-markdown-content', 'Content with parentheses in filename');
|
||||
|
||||
await clickElement(page, 'confirm-add-file');
|
||||
await clickElement(page, 'confirm-create-markdown');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
@@ -538,18 +510,17 @@ test.describe('Context View - Edge Cases', () => {
|
||||
await expect(fileWithParens).toBeVisible();
|
||||
|
||||
// Test file with hyphens and underscores
|
||||
await clickElement(page, 'add-context-file');
|
||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
||||
await clickElement(page, 'create-markdown-button');
|
||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await clickElement(page, 'add-text-type');
|
||||
await fillInput(page, 'new-file-name', 'test-file_v2.md');
|
||||
await fillInput(page, 'new-file-content', 'Content with hyphens and underscores');
|
||||
await fillInput(page, 'new-markdown-name', 'test-file_v2.md');
|
||||
await fillInput(page, 'new-markdown-content', 'Content with hyphens and underscores');
|
||||
|
||||
await clickElement(page, 'confirm-add-file');
|
||||
await clickElement(page, 'confirm-create-markdown');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
@@ -582,18 +553,17 @@ test.describe('Context View - Edge Cases', () => {
|
||||
await navigateToContext(page);
|
||||
|
||||
// Create file with empty content
|
||||
await clickElement(page, 'add-context-file');
|
||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
||||
await clickElement(page, 'create-markdown-button');
|
||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await clickElement(page, 'add-text-type');
|
||||
await fillInput(page, 'new-file-name', 'empty-file.md');
|
||||
await fillInput(page, 'new-markdown-name', 'empty-file.md');
|
||||
// Don't fill any content - leave it empty
|
||||
|
||||
await clickElement(page, 'confirm-add-file');
|
||||
await clickElement(page, 'confirm-create-markdown');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
|
||||
@@ -110,9 +110,9 @@ test.describe('Kanban Responsive Scaling Tests', () => {
|
||||
expect(Math.abs(columnWidth - baseWidth)).toBeLessThan(2);
|
||||
}
|
||||
|
||||
// Column width should be within expected bounds (280px min, 360px max)
|
||||
// Column width should be at least minimum (280px)
|
||||
// No max-width - columns scale evenly to fill available viewport
|
||||
expect(baseWidth).toBeGreaterThanOrEqual(280);
|
||||
expect(baseWidth).toBeLessThanOrEqual(360);
|
||||
|
||||
// Columns should not overlap (check x positions)
|
||||
expect(inProgressBox.x).toBeGreaterThan(backlogBox.x + backlogBox.width - 5);
|
||||
@@ -202,4 +202,90 @@ test.describe('Kanban Responsive Scaling Tests', () => {
|
||||
// There should be no horizontal scroll at standard width since columns scale down
|
||||
expect(hasHorizontalScroll).toBe(false);
|
||||
});
|
||||
|
||||
test('kanban columns should fit at minimum width (1500px - Electron minimum)', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Setup project and navigate to board view
|
||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||
await page.goto('/');
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Set viewport to the new Electron minimum width (1500px)
|
||||
await page.setViewportSize({ width: 1500, height: 900 });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Get all four kanban columns
|
||||
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
|
||||
const verifiedColumn = page.locator('[data-testid="kanban-column-verified"]');
|
||||
|
||||
// Verify columns are visible
|
||||
await expect(backlogColumn).toBeVisible();
|
||||
await expect(verifiedColumn).toBeVisible();
|
||||
|
||||
// Check if horizontal scrollbar is present
|
||||
const hasHorizontalScroll = await page.evaluate(() => {
|
||||
const boardContainer = document.querySelector('[data-testid="board-view"]');
|
||||
if (!boardContainer) return false;
|
||||
return boardContainer.scrollWidth > boardContainer.clientWidth;
|
||||
});
|
||||
|
||||
// There should be no horizontal scroll at minimum width
|
||||
expect(hasHorizontalScroll).toBe(false);
|
||||
|
||||
// Verify columns are at least minimum width (280px)
|
||||
const backlogBox = await backlogColumn.boundingBox();
|
||||
expect(backlogBox).not.toBeNull();
|
||||
if (backlogBox) {
|
||||
expect(backlogBox.width).toBeGreaterThanOrEqual(280);
|
||||
}
|
||||
});
|
||||
|
||||
test('kanban columns should scale correctly when sidebar is collapsed', async ({ page }) => {
|
||||
// Setup project and navigate to board view
|
||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||
await page.goto('/');
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Set a viewport size
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Get initial column width
|
||||
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
|
||||
const initialBox = await backlogColumn.boundingBox();
|
||||
expect(initialBox).not.toBeNull();
|
||||
|
||||
// Find and click the sidebar collapse button
|
||||
const collapseButton = page.locator('[data-testid="sidebar-collapse-button"]');
|
||||
if (await collapseButton.isVisible()) {
|
||||
await collapseButton.click();
|
||||
|
||||
// Wait for sidebar transition (300ms) + buffer
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
// Get column width after collapse
|
||||
const collapsedBox = await backlogColumn.boundingBox();
|
||||
expect(collapsedBox).not.toBeNull();
|
||||
|
||||
if (initialBox && collapsedBox) {
|
||||
// Column should be wider or same after sidebar collapse (more space available)
|
||||
// Allow for small variations due to transitions
|
||||
expect(collapsedBox.width).toBeGreaterThanOrEqual(initialBox.width - 5);
|
||||
|
||||
// Width should still be at least minimum
|
||||
expect(collapsedBox.width).toBeGreaterThanOrEqual(280);
|
||||
}
|
||||
|
||||
// Verify no horizontal scrollbar after collapse
|
||||
const hasHorizontalScroll = await page.evaluate(() => {
|
||||
const boardContainer = document.querySelector('[data-testid="board-view"]');
|
||||
if (!boardContainer) return false;
|
||||
return boardContainer.scrollWidth > boardContainer.clientWidth;
|
||||
});
|
||||
expect(hasHorizontalScroll).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,19 +19,29 @@ export async function simulateFileDrop(
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
|
||||
// Create events and explicitly define the dataTransfer property
|
||||
// to ensure it's accessible (some browsers don't properly set it from constructor)
|
||||
const dragOverEvent = new DragEvent('dragover', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperty(dragOverEvent, 'dataTransfer', {
|
||||
value: dataTransfer,
|
||||
writable: false,
|
||||
});
|
||||
|
||||
const dropEvent = new DragEvent('drop', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||
value: dataTransfer,
|
||||
writable: false,
|
||||
});
|
||||
|
||||
// Dispatch drag events
|
||||
target.dispatchEvent(
|
||||
new DragEvent('dragover', {
|
||||
dataTransfer,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
target.dispatchEvent(
|
||||
new DragEvent('drop', {
|
||||
dataTransfer,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
target.dispatchEvent(dragOverEvent);
|
||||
target.dispatchEvent(dropEvent);
|
||||
},
|
||||
{ selector: targetSelector, content: fileContent, name: fileName, mime: mimeType }
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
170
docs/context-files-pattern.md
Normal file
170
docs/context-files-pattern.md
Normal 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`
|
||||
@@ -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;
|
||||
@@ -25,6 +34,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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -70,6 +70,25 @@ export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
/** ModelProvider - AI model provider for credentials and API key management */
|
||||
export type ModelProvider = 'claude';
|
||||
|
||||
/**
|
||||
* WindowBounds - Electron window position and size for persistence
|
||||
*
|
||||
* Stored in global settings to restore window state across sessions.
|
||||
* Includes position (x, y), dimensions (width, height), and maximized state.
|
||||
*/
|
||||
export interface WindowBounds {
|
||||
/** Window X position on screen */
|
||||
x: number;
|
||||
/** Window Y position on screen */
|
||||
y: number;
|
||||
/** Window width in pixels */
|
||||
width: number;
|
||||
/** Window height in pixels */
|
||||
height: number;
|
||||
/** Whether window was maximized when closed */
|
||||
isMaximized: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* KeyboardShortcuts - User-configurable keyboard bindings for common actions
|
||||
*
|
||||
@@ -272,6 +291,10 @@ export interface GlobalSettings {
|
||||
// Session Tracking
|
||||
/** Maps project path -> last selected session ID in that project */
|
||||
lastSelectedSessionByProject: Record<string, string>;
|
||||
|
||||
// Window State (Electron only)
|
||||
/** Persisted window bounds for restoring position/size across sessions */
|
||||
windowBounds?: WindowBounds;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
238
libs/utils/src/context-loader.ts
Normal file
238
libs/utils/src/context-loader.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
42
package.json
42
package.json
@@ -10,24 +10,30 @@
|
||||
"postinstall": "node -e \"const fs=require('fs');if(process.platform==='darwin'){['darwin-arm64','darwin-x64'].forEach(a=>{const p='node_modules/node-pty/prebuilds/'+a+'/spawn-helper';if(fs.existsSync(p))fs.chmodSync(p,0o755)})}\" && node scripts/fix-lockfile-urls.mjs",
|
||||
"fix:lockfile": "node scripts/fix-lockfile-urls.mjs",
|
||||
"dev": "node init.mjs",
|
||||
"dev:web": "npm run dev:web --workspace=apps/ui",
|
||||
"dev:electron": "npm run dev:electron --workspace=apps/ui",
|
||||
"dev:electron:debug": "npm run dev:electron:debug --workspace=apps/ui",
|
||||
"dev:electron:wsl": "npm run dev:electron:wsl --workspace=apps/ui",
|
||||
"dev:electron:wsl:gpu": "npm run dev:electron:wsl:gpu --workspace=apps/ui",
|
||||
"dev:server": "npm run dev --workspace=apps/server",
|
||||
"dev:full": "concurrently \"npm run dev:server\" \"npm run dev:web\"",
|
||||
"_dev:web": "npm run dev:web --workspace=apps/ui",
|
||||
"_dev:electron": "npm run dev:electron --workspace=apps/ui",
|
||||
"_dev:electron:debug": "npm run dev:electron:debug --workspace=apps/ui",
|
||||
"_dev:electron:wsl": "npm run dev:electron:wsl --workspace=apps/ui",
|
||||
"_dev:electron:wsl:gpu": "npm run dev:electron:wsl:gpu --workspace=apps/ui",
|
||||
"_dev:server": "npm run dev --workspace=apps/server",
|
||||
"dev:web": "npm run build:packages && npm run _dev:web",
|
||||
"dev:electron": "npm run build:packages && npm run _dev:electron",
|
||||
"dev:electron:debug": "npm run build:packages && npm run _dev:electron:debug",
|
||||
"dev:electron:wsl": "npm run build:packages && npm run _dev:electron:wsl",
|
||||
"dev:electron:wsl:gpu": "npm run build:packages && npm run _dev:electron:wsl:gpu",
|
||||
"dev:server": "npm run build:packages && npm run _dev:server",
|
||||
"dev:full": "npm run build:packages && concurrently \"npm run _dev:server\" \"npm run _dev:web\"",
|
||||
"build": "npm run build:packages && npm run build --workspace=apps/ui",
|
||||
"build:packages": "npm run build -w @automaker/types -w @automaker/platform -w @automaker/utils -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver -w @automaker/git-utils",
|
||||
"build:server": "npm run build --workspace=apps/server",
|
||||
"build:electron": "npm run build:electron --workspace=apps/ui",
|
||||
"build:electron:dir": "npm run build:electron:dir --workspace=apps/ui",
|
||||
"build:electron:win": "npm run build:electron:win --workspace=apps/ui",
|
||||
"build:electron:win:dir": "npm run build:electron:win:dir --workspace=apps/ui",
|
||||
"build:electron:mac": "npm run build:electron:mac --workspace=apps/ui",
|
||||
"build:electron:mac:dir": "npm run build:electron:mac:dir --workspace=apps/ui",
|
||||
"build:electron:linux": "npm run build:electron:linux --workspace=apps/ui",
|
||||
"build:electron:linux:dir": "npm run build:electron:linux:dir --workspace=apps/ui",
|
||||
"build:packages": "npm run build -w @automaker/types && npm run build -w @automaker/platform && npm run build -w @automaker/utils && npm run build -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver && npm run build -w @automaker/git-utils",
|
||||
"build:server": "npm run build:packages && npm run build --workspace=apps/server",
|
||||
"build:electron": "npm run build:packages && npm run build:electron --workspace=apps/ui",
|
||||
"build:electron:dir": "npm run build:packages && npm run build:electron:dir --workspace=apps/ui",
|
||||
"build:electron:win": "npm run build:packages && npm run build:electron:win --workspace=apps/ui",
|
||||
"build:electron:win:dir": "npm run build:packages && npm run build:electron:win:dir --workspace=apps/ui",
|
||||
"build:electron:mac": "npm run build:packages && npm run build:electron:mac --workspace=apps/ui",
|
||||
"build:electron:mac:dir": "npm run build:packages && npm run build:electron:mac:dir --workspace=apps/ui",
|
||||
"build:electron:linux": "npm run build:packages && npm run build:electron:linux --workspace=apps/ui",
|
||||
"build:electron:linux:dir": "npm run build:packages && npm run build:electron:linux:dir --workspace=apps/ui",
|
||||
"lint": "npm run lint --workspace=apps/ui",
|
||||
"test": "npm run test --workspace=apps/ui",
|
||||
"test:headed": "npm run test:headed --workspace=apps/ui",
|
||||
@@ -38,7 +44,7 @@
|
||||
"lint:lockfile": "! grep -q 'git+ssh://' package-lock.json || (echo 'Error: package-lock.json contains git+ssh:// URLs. Run: git config --global url.\"https://github.com/\".insteadOf \"git@github.com:\"' && exit 1)",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky && npm run build:packages"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,json,css,md,html,yml,yaml}": [
|
||||
|
||||
Reference in New Issue
Block a user