mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +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 { createClaudeRoutes } from './routes/claude/index.js';
|
||||||
import { ClaudeUsageService } from './services/claude-usage-service.js';
|
import { ClaudeUsageService } from './services/claude-usage-service.js';
|
||||||
import { createGitHubRoutes } from './routes/github/index.js';
|
import { createGitHubRoutes } from './routes/github/index.js';
|
||||||
|
import { createContextRoutes } from './routes/context/index.js';
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -147,6 +148,7 @@ app.use('/api/terminal', createTerminalRoutes());
|
|||||||
app.use('/api/settings', createSettingsRoutes(settingsService));
|
app.use('/api/settings', createSettingsRoutes(settingsService));
|
||||||
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||||
app.use('/api/github', createGitHubRoutes());
|
app.use('/api/github', createGitHubRoutes());
|
||||||
|
app.use('/api/context', createContextRoutes());
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|||||||
@@ -153,9 +153,9 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
tier: 'standard' as const,
|
tier: 'standard' as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'claude-3-5-haiku-20241022',
|
id: 'claude-haiku-4-5-20251001',
|
||||||
name: 'Claude 3.5 Haiku',
|
name: 'Claude Haiku 4.5',
|
||||||
modelString: 'claude-3-5-haiku-20241022',
|
modelString: 'claude-haiku-4-5-20251001',
|
||||||
provider: 'anthropic',
|
provider: 'anthropic',
|
||||||
description: 'Fastest Claude model',
|
description: 'Fastest Claude model',
|
||||||
contextWindow: 200000,
|
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 * as secureFs from '../lib/secure-fs.js';
|
||||||
import type { EventEmitter } from '../lib/events.js';
|
import type { EventEmitter } from '../lib/events.js';
|
||||||
import type { ExecuteOptions } from '@automaker/types';
|
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 { ProviderFactory } from '../providers/provider-factory.js';
|
||||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||||
import { PathNotAllowedError } from '@automaker/platform';
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
@@ -178,12 +183,27 @@ export class AgentService {
|
|||||||
await this.saveSession(sessionId, session.messages);
|
await this.saveSession(sessionId, session.messages);
|
||||||
|
|
||||||
try {
|
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
|
// Build SDK options using centralized configuration
|
||||||
const sdkOptions = createChatOptions({
|
const sdkOptions = createChatOptions({
|
||||||
cwd: workingDirectory || session.workingDirectory,
|
cwd: effectiveWorkDir,
|
||||||
model: model,
|
model: model,
|
||||||
sessionModel: session.model,
|
sessionModel: session.model,
|
||||||
systemPrompt: this.getSystemPrompt(),
|
systemPrompt: combinedSystemPrompt,
|
||||||
abortController: session.abortController!,
|
abortController: session.abortController!,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,8 +223,8 @@ export class AgentService {
|
|||||||
const options: ExecuteOptions = {
|
const options: ExecuteOptions = {
|
||||||
prompt: '', // Will be set below based on images
|
prompt: '', // Will be set below based on images
|
||||||
model: effectiveModel,
|
model: effectiveModel,
|
||||||
cwd: workingDirectory || session.workingDirectory,
|
cwd: effectiveWorkDir,
|
||||||
systemPrompt: this.getSystemPrompt(),
|
systemPrompt: combinedSystemPrompt,
|
||||||
maxTurns: maxTurns,
|
maxTurns: maxTurns,
|
||||||
allowedTools: allowedTools,
|
allowedTools: allowedTools,
|
||||||
abortController: session.abortController!,
|
abortController: session.abortController!,
|
||||||
|
|||||||
@@ -11,10 +11,15 @@
|
|||||||
|
|
||||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||||
import type { ExecuteOptions, Feature } from '@automaker/types';
|
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 { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver';
|
||||||
import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-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 { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -549,7 +554,10 @@ export class AutoModeService {
|
|||||||
// Build the prompt - use continuation prompt if provided (for recovery after plan approval)
|
// Build the prompt - use continuation prompt if provided (for recovery after plan approval)
|
||||||
let prompt: string;
|
let prompt: string;
|
||||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
|
// 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) {
|
if (options?.continuationPrompt) {
|
||||||
// Continuation prompt is used when recovering from a plan approval
|
// Continuation prompt is used when recovering from a plan approval
|
||||||
@@ -595,7 +603,7 @@ export class AutoModeService {
|
|||||||
projectPath,
|
projectPath,
|
||||||
planningMode: feature.planningMode,
|
planningMode: feature.planningMode,
|
||||||
requirePlanApproval: feature.requirePlanApproval,
|
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
|
// 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
|
// Build complete prompt with feature info, previous context, and follow-up instructions
|
||||||
let fullPrompt = `## Follow-up on Feature Implementation
|
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,
|
projectPath,
|
||||||
planningMode: 'skip', // Follow-ups don't require approval
|
planningMode: 'skip', // Follow-ups don't require approval
|
||||||
previousContent: previousContext || undefined,
|
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
|
* Analyze project to gather context
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ describe('model-resolver.ts', () => {
|
|||||||
describe('resolveModelString', () => {
|
describe('resolveModelString', () => {
|
||||||
it("should resolve 'haiku' alias to full model string", () => {
|
it("should resolve 'haiku' alias to full model string", () => {
|
||||||
const result = resolveModelString('haiku');
|
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", () => {
|
it("should resolve 'sonnet' alias to full model string", () => {
|
||||||
const result = resolveModelString('sonnet');
|
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", () => {
|
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', () => {
|
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) => {
|
models.forEach((model) => {
|
||||||
const result = resolveModelString(model);
|
const result = resolveModelString(model);
|
||||||
expect(result).toBe(model);
|
expect(result).toBe(model);
|
||||||
@@ -93,11 +93,11 @@ describe('model-resolver.ts', () => {
|
|||||||
|
|
||||||
it('should use session model when explicit is not provided', () => {
|
it('should use session model when explicit is not provided', () => {
|
||||||
const result = getEffectiveModel(undefined, 'sonnet', 'gpt-5.2');
|
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', () => {
|
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);
|
const result = getEffectiveModel(undefined, undefined, customDefault);
|
||||||
expect(result).toBe(customDefault);
|
expect(result).toBe(customDefault);
|
||||||
});
|
});
|
||||||
@@ -109,7 +109,7 @@ describe('model-resolver.ts', () => {
|
|||||||
|
|
||||||
it('should handle explicit empty strings as undefined', () => {
|
it('should handle explicit empty strings as undefined', () => {
|
||||||
const result = getEffectiveModel('', 'haiku');
|
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();
|
expect(sonnet35).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include Claude 3.5 Haiku', () => {
|
it('should include Claude Haiku 4.5', () => {
|
||||||
const models = provider.getAvailableModels();
|
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();
|
expect(haiku).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { ProviderFactory } from '@/providers/provider-factory.js';
|
|||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as imageHandler from '@automaker/utils';
|
import * as imageHandler from '@automaker/utils';
|
||||||
import * as promptBuilder from '@automaker/utils';
|
import * as promptBuilder from '@automaker/utils';
|
||||||
|
import * as contextLoader from '@automaker/utils';
|
||||||
import { collectAsyncGenerator } from '../../utils/helpers.js';
|
import { collectAsyncGenerator } from '../../utils/helpers.js';
|
||||||
|
|
||||||
vi.mock('fs/promises');
|
vi.mock('fs/promises');
|
||||||
vi.mock('@/providers/provider-factory.js');
|
vi.mock('@/providers/provider-factory.js');
|
||||||
vi.mock('@automaker/utils');
|
vi.mock('@automaker/utils');
|
||||||
vi.mock('@automaker/utils');
|
|
||||||
|
|
||||||
describe('agent-service.ts', () => {
|
describe('agent-service.ts', () => {
|
||||||
let service: AgentService;
|
let service: AgentService;
|
||||||
@@ -21,6 +21,12 @@ describe('agent-service.ts', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
service = new AgentService('/test/data', mockEvents as any);
|
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', () => {
|
describe('initialize', () => {
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export default defineConfig({
|
|||||||
'src/**/*.d.ts',
|
'src/**/*.d.ts',
|
||||||
'src/index.ts',
|
'src/index.ts',
|
||||||
'src/routes/**', // Routes are better tested with integration tests
|
'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: {
|
thresholds: {
|
||||||
// Increased thresholds to ensure better code quality
|
// 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 { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings';
|
import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
fileToBase64,
|
||||||
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
validateImageFile,
|
||||||
|
ACCEPTED_IMAGE_TYPES,
|
||||||
|
DEFAULT_MAX_FILE_SIZE,
|
||||||
|
} from '@/lib/image-utils';
|
||||||
|
|
||||||
interface BoardBackgroundModalProps {
|
interface BoardBackgroundModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -71,21 +74,6 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
}
|
}
|
||||||
}, [currentProject, backgroundSettings.imagePath, imageVersion]);
|
}, [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(
|
const processFile = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
if (!currentProject) {
|
if (!currentProject) {
|
||||||
@@ -93,16 +81,10 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file type
|
// Validate file
|
||||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE);
|
||||||
toast.error('Unsupported file type. Please use JPG, PNG, GIF, or WebP.');
|
if (!validation.isValid) {
|
||||||
return;
|
toast.error(validation.error);
|
||||||
}
|
|
||||||
|
|
||||||
// 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.`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function Sidebar() {
|
|||||||
const isCreatingSpec = specCreatingForProject !== null;
|
const isCreatingSpec = specCreatingForProject !== null;
|
||||||
const creatingSpecProjectPath = specCreatingForProject;
|
const creatingSpecProjectPath = specCreatingForProject;
|
||||||
|
|
||||||
// Auto-collapse sidebar on small screens
|
// Auto-collapse sidebar on small screens and update Electron window minWidth
|
||||||
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
||||||
|
|
||||||
// Running agents count
|
// Running agents count
|
||||||
|
|||||||
@@ -32,4 +32,17 @@ export function useSidebarAutoCollapse({
|
|||||||
mediaQuery.addEventListener('change', handleResize);
|
mediaQuery.addEventListener('change', handleResize);
|
||||||
return () => mediaQuery.removeEventListener('change', handleResize);
|
return () => mediaQuery.removeEventListener('change', handleResize);
|
||||||
}, [sidebarOpen, toggleSidebar]);
|
}, [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 React, { useState, useRef, useCallback } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
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 { Textarea } from '@/components/ui/textarea';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
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)
|
// Map to store preview data by image ID (persisted across component re-mounts)
|
||||||
export type ImagePreviewMap = Map<string, string>;
|
export type ImagePreviewMap = Map<string, string>;
|
||||||
|
|
||||||
|
// Re-export for convenience
|
||||||
|
export type { FeatureImagePath, FeatureTextFilePath };
|
||||||
|
|
||||||
interface DescriptionImageDropZoneProps {
|
interface DescriptionImageDropZoneProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
images: FeatureImagePath[];
|
images: FeatureImagePath[];
|
||||||
onImagesChange: (images: FeatureImagePath[]) => void;
|
onImagesChange: (images: FeatureImagePath[]) => void;
|
||||||
|
textFiles?: FeatureTextFilePath[];
|
||||||
|
onTextFilesChange?: (textFiles: FeatureTextFilePath[]) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -25,14 +45,13 @@ interface DescriptionImageDropZoneProps {
|
|||||||
error?: boolean; // Show error state with red border
|
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({
|
export function DescriptionImageDropZone({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
images,
|
images,
|
||||||
onImagesChange,
|
onImagesChange,
|
||||||
|
textFiles = [],
|
||||||
|
onTextFilesChange,
|
||||||
placeholder = 'Describe the feature...',
|
placeholder = 'Describe the feature...',
|
||||||
className,
|
className,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -81,21 +100,6 @@ export function DescriptionImageDropZone({
|
|||||||
[currentProject?.path]
|
[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(
|
const saveImageToTemp = useCallback(
|
||||||
async (base64Data: string, filename: string, mimeType: string): Promise<string | null> => {
|
async (base64Data: string, filename: string, mimeType: string): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
@@ -129,54 +133,89 @@ export function DescriptionImageDropZone({
|
|||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
const newImages: FeatureImagePath[] = [];
|
const newImages: FeatureImagePath[] = [];
|
||||||
|
const newTextFiles: FeatureTextFilePath[] = [];
|
||||||
const newPreviews = new Map(previewImages);
|
const newPreviews = new Map(previewImages);
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Calculate total current files
|
||||||
|
const currentTotalFiles = images.length + textFiles.length;
|
||||||
|
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
// Validate file type
|
// Check if it's a text file
|
||||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
if (isTextFile(file)) {
|
||||||
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
|
const validation = validateTextFile(file, DEFAULT_MAX_TEXT_FILE_SIZE);
|
||||||
continue;
|
if (!validation.isValid) {
|
||||||
}
|
errors.push(validation.error!);
|
||||||
|
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.`);
|
|
||||||
}
|
}
|
||||||
} 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) {
|
if (errors.length > 0) {
|
||||||
console.warn('Image upload errors:', errors);
|
console.warn('File upload errors:', errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newImages.length > 0) {
|
if (newImages.length > 0) {
|
||||||
@@ -184,15 +223,21 @@ export function DescriptionImageDropZone({
|
|||||||
setPreviewImages(newPreviews);
|
setPreviewImages(newPreviews);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (newTextFiles.length > 0 && onTextFilesChange) {
|
||||||
|
onTextFilesChange([...textFiles, ...newTextFiles]);
|
||||||
|
}
|
||||||
|
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
disabled,
|
disabled,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
images,
|
images,
|
||||||
|
textFiles,
|
||||||
maxFiles,
|
maxFiles,
|
||||||
maxFileSize,
|
maxFileSize,
|
||||||
onImagesChange,
|
onImagesChange,
|
||||||
|
onTextFilesChange,
|
||||||
previewImages,
|
previewImages,
|
||||||
saveImageToTemp,
|
saveImageToTemp,
|
||||||
]
|
]
|
||||||
@@ -263,6 +308,15 @@ export function DescriptionImageDropZone({
|
|||||||
[images, onImagesChange]
|
[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
|
// Handle paste events to detect and process images from clipboard
|
||||||
// Works across all OS (Windows, Linux, macOS)
|
// Works across all OS (Windows, Linux, macOS)
|
||||||
const handlePaste = useCallback(
|
const handlePaste = useCallback(
|
||||||
@@ -314,11 +368,11 @@ export function DescriptionImageDropZone({
|
|||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
accept={ACCEPTED_IMAGE_TYPES.join(',')}
|
accept={[...ACCEPTED_IMAGE_TYPES, ...ACCEPTED_TEXT_EXTENSIONS].join(',')}
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
data-testid="description-image-input"
|
data-testid="description-file-input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Drop zone wrapper */}
|
{/* Drop zone wrapper */}
|
||||||
@@ -338,7 +392,7 @@ export function DescriptionImageDropZone({
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-2 text-blue-400">
|
<div className="flex flex-col items-center gap-2 text-blue-400">
|
||||||
<ImageIcon className="w-8 h-8" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -359,7 +413,7 @@ export function DescriptionImageDropZone({
|
|||||||
|
|
||||||
{/* Hint text */}
|
{/* Hint text */}
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Paste, drag and drop images, or{' '}
|
Paste, drag and drop files, or{' '}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleBrowseClick}
|
onClick={handleBrowseClick}
|
||||||
@@ -368,29 +422,33 @@ export function DescriptionImageDropZone({
|
|||||||
>
|
>
|
||||||
browse
|
browse
|
||||||
</button>{' '}
|
</button>{' '}
|
||||||
to attach context images
|
to attach context (images, .txt, .md)
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Processing indicator */}
|
{/* Processing indicator */}
|
||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
<span>Saving images...</span>
|
<span>Processing files...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Image previews */}
|
{/* File previews (images and text files) */}
|
||||||
{images.length > 0 && (
|
{(images.length > 0 || textFiles.length > 0) && (
|
||||||
<div className="mt-3 space-y-2" data-testid="description-image-previews">
|
<div className="mt-3 space-y-2" data-testid="description-file-previews">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-medium text-foreground">
|
<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>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onImagesChange([]);
|
onImagesChange([]);
|
||||||
setPreviewImages(new Map());
|
setPreviewImages(new Map());
|
||||||
|
if (onTextFilesChange) {
|
||||||
|
onTextFilesChange([]);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -399,6 +457,7 @@ export function DescriptionImageDropZone({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{/* Image previews */}
|
||||||
{images.map((image) => (
|
{images.map((image) => (
|
||||||
<div
|
<div
|
||||||
key={image.id}
|
key={image.id}
|
||||||
@@ -445,6 +504,38 @@ export function DescriptionImageDropZone({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import React, { useState, useRef, useCallback } from 'react';
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ImageIcon, X, Upload } from 'lucide-react';
|
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 {
|
export interface FeatureImage {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,13 +27,10 @@ interface FeatureImageUploadProps {
|
|||||||
disabled?: boolean;
|
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({
|
export function FeatureImageUpload({
|
||||||
images,
|
images,
|
||||||
onImagesChange,
|
onImagesChange,
|
||||||
maxFiles = 5,
|
maxFiles = DEFAULT_MAX_FILES,
|
||||||
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
||||||
className,
|
className,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -34,21 +39,6 @@ export function FeatureImageUpload({
|
|||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
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(
|
const processFiles = useCallback(
|
||||||
async (files: FileList) => {
|
async (files: FileList) => {
|
||||||
if (disabled || isProcessing) return;
|
if (disabled || isProcessing) return;
|
||||||
@@ -58,16 +48,10 @@ export function FeatureImageUpload({
|
|||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
// Validate file type
|
// Validate file
|
||||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
const validation = validateImageFile(file, maxFileSize);
|
||||||
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
|
if (!validation.isValid) {
|
||||||
continue;
|
errors.push(validation.error!);
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +64,7 @@ export function FeatureImageUpload({
|
|||||||
try {
|
try {
|
||||||
const base64 = await fileToBase64(file);
|
const base64 = await fileToBase64(file);
|
||||||
const imageAttachment: FeatureImage = {
|
const imageAttachment: FeatureImage = {
|
||||||
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
id: generateImageId(),
|
||||||
data: base64,
|
data: base64,
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import React, { useState, useRef, useCallback } from 'react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ImageIcon, X, Upload } from 'lucide-react';
|
import { ImageIcon, X, Upload } from 'lucide-react';
|
||||||
import type { ImageAttachment } from '@/store/app-store';
|
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 {
|
interface ImageDropZoneProps {
|
||||||
onImagesSelected: (images: ImageAttachment[]) => void;
|
onImagesSelected: (images: ImageAttachment[]) => void;
|
||||||
@@ -13,12 +22,9 @@ interface ImageDropZoneProps {
|
|||||||
images?: ImageAttachment[]; // Optional controlled images prop
|
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({
|
export function ImageDropZone({
|
||||||
onImagesSelected,
|
onImagesSelected,
|
||||||
maxFiles = 5,
|
maxFiles = DEFAULT_MAX_FILES,
|
||||||
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
@@ -53,16 +59,10 @@ export function ImageDropZone({
|
|||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
// Validate file type
|
// Validate file
|
||||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
const validation = validateImageFile(file, maxFileSize);
|
||||||
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
|
if (!validation.isValid) {
|
||||||
continue;
|
errors.push(validation.error!);
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ export function ImageDropZone({
|
|||||||
try {
|
try {
|
||||||
const base64 = await fileToBase64(file);
|
const base64 = await fileToBase64(file);
|
||||||
const imageAttachment: ImageAttachment = {
|
const imageAttachment: ImageAttachment = {
|
||||||
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
id: generateImageId(),
|
||||||
data: base64,
|
data: base64,
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
@@ -89,7 +89,6 @@ export function ImageDropZone({
|
|||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
console.warn('Image upload errors:', errors);
|
console.warn('Image upload errors:', errors);
|
||||||
// You could show these errors to the user via a toast or notification
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newImages.length > 0) {
|
if (newImages.length > 0) {
|
||||||
@@ -282,26 +281,3 @@ export function ImageDropZone({
|
|||||||
</div>
|
</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,
|
X,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
FileText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useElectronAgent } from '@/hooks/use-electron-agent';
|
import { useElectronAgent } from '@/hooks/use-electron-agent';
|
||||||
import { SessionManager } from '@/components/session-manager';
|
import { SessionManager } from '@/components/session-manager';
|
||||||
import { Markdown } from '@/components/ui/markdown';
|
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 {
|
import {
|
||||||
useKeyboardShortcuts,
|
useKeyboardShortcuts,
|
||||||
useKeyboardShortcutsConfig,
|
useKeyboardShortcutsConfig,
|
||||||
@@ -40,6 +55,7 @@ export function AgentView() {
|
|||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
||||||
|
const [selectedTextFiles, setSelectedTextFiles] = useState<TextFileAttachment[]>([]);
|
||||||
const [showImageDropZone, setShowImageDropZone] = useState(false);
|
const [showImageDropZone, setShowImageDropZone] = useState(false);
|
||||||
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
||||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||||
@@ -116,17 +132,23 @@ export function AgentView() {
|
|||||||
}, [currentProject?.path]);
|
}, [currentProject?.path]);
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
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 messageContent = input;
|
||||||
const messageImages = selectedImages;
|
const messageImages = selectedImages;
|
||||||
|
const messageTextFiles = selectedTextFiles;
|
||||||
|
|
||||||
setInput('');
|
setInput('');
|
||||||
setSelectedImages([]);
|
setSelectedImages([]);
|
||||||
|
setSelectedTextFiles([]);
|
||||||
setShowImageDropZone(false);
|
setShowImageDropZone(false);
|
||||||
|
|
||||||
await sendMessage(messageContent, messageImages);
|
await sendMessage(messageContent, messageImages, messageTextFiles);
|
||||||
}, [input, selectedImages, isProcessing, sendMessage]);
|
}, [input, selectedImages, selectedTextFiles, isProcessing, sendMessage]);
|
||||||
|
|
||||||
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
|
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
|
||||||
setSelectedImages(images);
|
setSelectedImages(images);
|
||||||
@@ -136,84 +158,99 @@ export function AgentView() {
|
|||||||
setShowImageDropZone(!showImageDropZone);
|
setShowImageDropZone(!showImageDropZone);
|
||||||
}, [showImageDropZone]);
|
}, [showImageDropZone]);
|
||||||
|
|
||||||
// Helper function to convert file to base64
|
// Process dropped files (images and text files)
|
||||||
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
|
|
||||||
const processDroppedFiles = useCallback(
|
const processDroppedFiles = useCallback(
|
||||||
async (files: FileList) => {
|
async (files: FileList) => {
|
||||||
if (isProcessing) return;
|
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 newImages: ImageAttachment[] = [];
|
||||||
|
const newTextFiles: TextFileAttachment[] = [];
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
// Validate file type
|
// Check if it's a text file
|
||||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
if (isTextFile(file)) {
|
||||||
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
|
const validation = validateTextFile(file);
|
||||||
continue;
|
if (!validation.isValid) {
|
||||||
}
|
errors.push(validation.error!);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate file size
|
// Check if we've reached max files
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
const totalFiles =
|
||||||
const maxSizeMB = MAX_FILE_SIZE / (1024 * 1024);
|
newImages.length +
|
||||||
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
|
selectedImages.length +
|
||||||
continue;
|
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
|
try {
|
||||||
if (newImages.length + selectedImages.length >= MAX_FILES) {
|
const content = await fileToText(file);
|
||||||
errors.push(`Maximum ${MAX_FILES} images allowed.`);
|
const textFileAttachment: TextFileAttachment = {
|
||||||
break;
|
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 {
|
// Check if we've reached max files
|
||||||
const base64 = await fileToBase64(file);
|
const totalFiles =
|
||||||
const imageAttachment: ImageAttachment = {
|
newImages.length +
|
||||||
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
selectedImages.length +
|
||||||
data: base64,
|
newTextFiles.length +
|
||||||
mimeType: file.type,
|
selectedTextFiles.length;
|
||||||
filename: file.name,
|
if (totalFiles >= DEFAULT_MAX_FILES) {
|
||||||
size: file.size,
|
errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`);
|
||||||
};
|
break;
|
||||||
newImages.push(imageAttachment);
|
}
|
||||||
} catch (error) {
|
|
||||||
errors.push(`${file.name}: Failed to process image.`);
|
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) {
|
if (errors.length > 0) {
|
||||||
console.warn('Image upload errors:', errors);
|
console.warn('File upload errors:', errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newImages.length > 0) {
|
if (newImages.length > 0) {
|
||||||
setSelectedImages((prev) => [...prev, ...newImages]);
|
setSelectedImages((prev) => [...prev, ...newImages]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (newTextFiles.length > 0) {
|
||||||
|
setSelectedTextFiles((prev) => [...prev, ...newTextFiles]);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[isProcessing, selectedImages, fileToBase64]
|
[isProcessing, selectedImages, selectedTextFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove individual image
|
// Remove individual image
|
||||||
@@ -221,6 +258,11 @@ export function AgentView() {
|
|||||||
setSelectedImages((prev) => prev.filter((img) => img.id !== imageId));
|
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
|
// Drag and drop handlers for the input area
|
||||||
const handleDragEnter = useCallback(
|
const handleDragEnter = useCallback(
|
||||||
(e: React.DragEvent) => {
|
(e: React.DragEvent) => {
|
||||||
@@ -720,16 +762,19 @@ export function AgentView() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Selected Images Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
|
{/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
|
||||||
{selectedImages.length > 0 && !showImageDropZone && (
|
{(selectedImages.length > 0 || selectedTextFiles.length > 0) && !showImageDropZone && (
|
||||||
<div className="mb-4 space-y-2">
|
<div className="mb-4 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-medium text-foreground">
|
<p className="text-xs font-medium text-foreground">
|
||||||
{selectedImages.length} image
|
{selectedImages.length + selectedTextFiles.length} file
|
||||||
{selectedImages.length > 1 ? 's' : ''} attached
|
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''} attached
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedImages([])}
|
onClick={() => {
|
||||||
|
setSelectedImages([]);
|
||||||
|
setSelectedTextFiles([]);
|
||||||
|
}}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
>
|
>
|
||||||
@@ -737,6 +782,7 @@ export function AgentView() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{/* Image attachments */}
|
||||||
{selectedImages.map((image) => (
|
{selectedImages.map((image) => (
|
||||||
<div
|
<div
|
||||||
key={image.id}
|
key={image.id}
|
||||||
@@ -773,6 +819,35 @@ export function AgentView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -792,7 +867,7 @@ export function AgentView() {
|
|||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder={
|
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}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
@@ -803,14 +878,15 @@ export function AgentView() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all',
|
'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',
|
'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'
|
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">
|
<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 + selectedTextFiles.length} file
|
||||||
{selectedImages.length > 1 ? 's' : ''}
|
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isDragOver && (
|
{isDragOver && (
|
||||||
@@ -821,7 +897,7 @@ export function AgentView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Image Attachment Button */}
|
{/* File Attachment Button */}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -830,9 +906,10 @@ export function AgentView() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'h-11 w-11 rounded-xl border-border',
|
'h-11 w-11 rounded-xl border-border',
|
||||||
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
|
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" />
|
<Paperclip className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -841,7 +918,11 @@ export function AgentView() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={
|
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"
|
className="h-11 px-4 rounded-xl"
|
||||||
data-testid="send-message"
|
data-testid="send-message"
|
||||||
@@ -862,12 +943,3 @@ export function AgentView() {
|
|||||||
</div>
|
</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
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={cn(
|
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
|
!width && 'w-72', // Only apply w-72 if no custom width
|
||||||
showBorder && 'border border-border/60',
|
showBorder && 'border border-border/60',
|
||||||
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
|
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
|||||||
import {
|
import {
|
||||||
DescriptionImageDropZone,
|
DescriptionImageDropZone,
|
||||||
FeatureImagePath as DescriptionImagePath,
|
FeatureImagePath as DescriptionImagePath,
|
||||||
|
FeatureTextFilePath as DescriptionTextFilePath,
|
||||||
ImagePreviewMap,
|
ImagePreviewMap,
|
||||||
} from '@/components/ui/description-image-dropzone';
|
} from '@/components/ui/description-image-dropzone';
|
||||||
import {
|
import {
|
||||||
@@ -63,6 +64,7 @@ interface AddFeatureDialogProps {
|
|||||||
description: string;
|
description: string;
|
||||||
images: FeatureImage[];
|
images: FeatureImage[];
|
||||||
imagePaths: DescriptionImagePath[];
|
imagePaths: DescriptionImagePath[];
|
||||||
|
textFilePaths: DescriptionTextFilePath[];
|
||||||
skipTests: boolean;
|
skipTests: boolean;
|
||||||
model: AgentModel;
|
model: AgentModel;
|
||||||
thinkingLevel: ThinkingLevel;
|
thinkingLevel: ThinkingLevel;
|
||||||
@@ -104,6 +106,7 @@ export function AddFeatureDialog({
|
|||||||
description: '',
|
description: '',
|
||||||
images: [] as FeatureImage[],
|
images: [] as FeatureImage[],
|
||||||
imagePaths: [] as DescriptionImagePath[],
|
imagePaths: [] as DescriptionImagePath[],
|
||||||
|
textFilePaths: [] as DescriptionTextFilePath[],
|
||||||
skipTests: false,
|
skipTests: false,
|
||||||
model: 'opus' as AgentModel,
|
model: 'opus' as AgentModel,
|
||||||
thinkingLevel: 'none' as ThinkingLevel,
|
thinkingLevel: 'none' as ThinkingLevel,
|
||||||
@@ -190,6 +193,7 @@ export function AddFeatureDialog({
|
|||||||
description: newFeature.description,
|
description: newFeature.description,
|
||||||
images: newFeature.images,
|
images: newFeature.images,
|
||||||
imagePaths: newFeature.imagePaths,
|
imagePaths: newFeature.imagePaths,
|
||||||
|
textFilePaths: newFeature.textFilePaths,
|
||||||
skipTests: newFeature.skipTests,
|
skipTests: newFeature.skipTests,
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
thinkingLevel: normalizedThinking,
|
thinkingLevel: normalizedThinking,
|
||||||
@@ -206,6 +210,7 @@ export function AddFeatureDialog({
|
|||||||
description: '',
|
description: '',
|
||||||
images: [],
|
images: [],
|
||||||
imagePaths: [],
|
imagePaths: [],
|
||||||
|
textFilePaths: [],
|
||||||
skipTests: defaultSkipTests,
|
skipTests: defaultSkipTests,
|
||||||
model: 'opus',
|
model: 'opus',
|
||||||
priority: 2,
|
priority: 2,
|
||||||
@@ -327,6 +332,10 @@ export function AddFeatureDialog({
|
|||||||
}}
|
}}
|
||||||
images={newFeature.imagePaths}
|
images={newFeature.imagePaths}
|
||||||
onImagesChange={(images) => setNewFeature({ ...newFeature, imagePaths: images })}
|
onImagesChange={(images) => setNewFeature({ ...newFeature, imagePaths: images })}
|
||||||
|
textFiles={newFeature.textFilePaths}
|
||||||
|
onTextFilesChange={(textFiles) =>
|
||||||
|
setNewFeature({ ...newFeature, textFilePaths: textFiles })
|
||||||
|
}
|
||||||
placeholder="Describe the feature..."
|
placeholder="Describe the feature..."
|
||||||
previewMap={newFeaturePreviewMap}
|
previewMap={newFeaturePreviewMap}
|
||||||
onPreviewMapChange={setNewFeaturePreviewMap}
|
onPreviewMapChange={setNewFeaturePreviewMap}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
|||||||
import {
|
import {
|
||||||
DescriptionImageDropZone,
|
DescriptionImageDropZone,
|
||||||
FeatureImagePath as DescriptionImagePath,
|
FeatureImagePath as DescriptionImagePath,
|
||||||
|
FeatureTextFilePath as DescriptionTextFilePath,
|
||||||
ImagePreviewMap,
|
ImagePreviewMap,
|
||||||
} from '@/components/ui/description-image-dropzone';
|
} from '@/components/ui/description-image-dropzone';
|
||||||
import {
|
import {
|
||||||
@@ -67,6 +68,7 @@ interface EditFeatureDialogProps {
|
|||||||
model: AgentModel;
|
model: AgentModel;
|
||||||
thinkingLevel: ThinkingLevel;
|
thinkingLevel: ThinkingLevel;
|
||||||
imagePaths: DescriptionImagePath[];
|
imagePaths: DescriptionImagePath[];
|
||||||
|
textFilePaths: DescriptionTextFilePath[];
|
||||||
branchName: string; // Can be empty string to use current branch
|
branchName: string; // Can be empty string to use current branch
|
||||||
priority: number;
|
priority: number;
|
||||||
planningMode: PlanningMode;
|
planningMode: PlanningMode;
|
||||||
@@ -166,6 +168,7 @@ export function EditFeatureDialog({
|
|||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
thinkingLevel: normalizedThinking,
|
thinkingLevel: normalizedThinking,
|
||||||
imagePaths: editingFeature.imagePaths ?? [],
|
imagePaths: editingFeature.imagePaths ?? [],
|
||||||
|
textFilePaths: editingFeature.textFilePaths ?? [],
|
||||||
branchName: finalBranchName,
|
branchName: finalBranchName,
|
||||||
priority: editingFeature.priority ?? 2,
|
priority: editingFeature.priority ?? 2,
|
||||||
planningMode,
|
planningMode,
|
||||||
@@ -292,6 +295,13 @@ export function EditFeatureDialog({
|
|||||||
imagePaths: images,
|
imagePaths: images,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
textFiles={editingFeature.textFilePaths ?? []}
|
||||||
|
onTextFilesChange={(textFiles) =>
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
textFilePaths: textFiles,
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="Describe the feature..."
|
placeholder="Describe the feature..."
|
||||||
previewMap={editFeaturePreviewMap}
|
previewMap={editFeaturePreviewMap}
|
||||||
onPreviewMapChange={setEditFeaturePreviewMap}
|
onPreviewMapChange={setEditFeaturePreviewMap}
|
||||||
|
|||||||
@@ -82,17 +82,18 @@ export function KanbanBoard({
|
|||||||
onArchiveAllVerified,
|
onArchiveAllVerified,
|
||||||
}: KanbanBoardProps) {
|
}: KanbanBoardProps) {
|
||||||
// Use responsive column widths based on window size
|
// 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 (
|
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
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={collisionDetectionStrategy}
|
collisionDetection={collisionDetectionStrategy}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
>
|
>
|
||||||
<div className="flex gap-5 h-full py-1 justify-center">
|
<div className="h-full py-1" style={containerStyle}>
|
||||||
{COLUMNS.map((column) => {
|
{COLUMNS.map((column) => {
|
||||||
const columnFeatures = getColumnFeatures(column.id);
|
const columnFeatures = getColumnFeatures(column.id);
|
||||||
return (
|
return (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import type { Message, StreamEvent } from '@/types/electron';
|
import type { Message, StreamEvent } from '@/types/electron';
|
||||||
import { useMessageQueue } from './use-message-queue';
|
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 { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { sanitizeFilename } from '@/lib/image-utils';
|
||||||
|
|
||||||
interface UseElectronAgentOptions {
|
interface UseElectronAgentOptions {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -15,12 +16,22 @@ interface UseElectronAgentResult {
|
|||||||
messages: Message[];
|
messages: Message[];
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
sendMessage: (content: string, images?: ImageAttachment[]) => Promise<void>;
|
sendMessage: (
|
||||||
|
content: string,
|
||||||
|
images?: ImageAttachment[],
|
||||||
|
textFiles?: TextFileAttachment[]
|
||||||
|
) => Promise<void>;
|
||||||
stopExecution: () => Promise<void>;
|
stopExecution: () => Promise<void>;
|
||||||
clearHistory: () => Promise<void>;
|
clearHistory: () => Promise<void>;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
// Queue-related state
|
// 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;
|
isQueueProcessing: boolean;
|
||||||
clearMessageQueue: () => void;
|
clearMessageQueue: () => void;
|
||||||
}
|
}
|
||||||
@@ -46,7 +57,7 @@ export function useElectronAgent({
|
|||||||
|
|
||||||
// Send message directly to the agent (bypassing queue)
|
// Send message directly to the agent (bypassing queue)
|
||||||
const sendMessageDirectly = useCallback(
|
const sendMessageDirectly = useCallback(
|
||||||
async (content: string, images?: ImageAttachment[]) => {
|
async (content: string, images?: ImageAttachment[], textFiles?: TextFileAttachment[]) => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.agent) {
|
if (!api?.agent) {
|
||||||
setError('API not available');
|
setError('API not available');
|
||||||
@@ -64,8 +75,20 @@ export function useElectronAgent({
|
|||||||
console.log('[useElectronAgent] Sending message directly', {
|
console.log('[useElectronAgent] Sending message directly', {
|
||||||
hasImages: images && images.length > 0,
|
hasImages: images && images.length > 0,
|
||||||
imageCount: 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
|
// Save images to .automaker/images and get paths
|
||||||
let imagePaths: string[] | undefined;
|
let imagePaths: string[] | undefined;
|
||||||
if (images && images.length > 0 && api.saveImageToTemp) {
|
if (images && images.length > 0 && api.saveImageToTemp) {
|
||||||
@@ -73,7 +96,7 @@ export function useElectronAgent({
|
|||||||
for (const image of images) {
|
for (const image of images) {
|
||||||
const result = await api.saveImageToTemp(
|
const result = await api.saveImageToTemp(
|
||||||
image.data,
|
image.data,
|
||||||
image.filename,
|
sanitizeFilename(image.filename),
|
||||||
image.mimeType,
|
image.mimeType,
|
||||||
workingDirectory // Pass workingDirectory as projectPath
|
workingDirectory // Pass workingDirectory as projectPath
|
||||||
);
|
);
|
||||||
@@ -88,7 +111,7 @@ export function useElectronAgent({
|
|||||||
|
|
||||||
const result = await api.agent!.send(
|
const result = await api.agent!.send(
|
||||||
sessionId,
|
sessionId,
|
||||||
content,
|
messageContent,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
imagePaths,
|
imagePaths,
|
||||||
model
|
model
|
||||||
@@ -114,7 +137,11 @@ export function useElectronAgent({
|
|||||||
const { queuedMessages, isProcessingQueue, addToQueue, clearQueue, processNext } =
|
const { queuedMessages, isProcessingQueue, addToQueue, clearQueue, processNext } =
|
||||||
useMessageQueue({
|
useMessageQueue({
|
||||||
onProcessNext: async (queuedMessage) => {
|
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
|
// Send a message to the agent
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
async (content: string, images?: ImageAttachment[]) => {
|
async (content: string, images?: ImageAttachment[], textFiles?: TextFileAttachment[]) => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.agent) {
|
if (!api?.agent) {
|
||||||
setError('API not available');
|
setError('API not available');
|
||||||
@@ -307,8 +334,20 @@ export function useElectronAgent({
|
|||||||
console.log('[useElectronAgent] Sending message', {
|
console.log('[useElectronAgent] Sending message', {
|
||||||
hasImages: images && images.length > 0,
|
hasImages: images && images.length > 0,
|
||||||
imageCount: 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
|
// Save images to .automaker/images and get paths
|
||||||
let imagePaths: string[] | undefined;
|
let imagePaths: string[] | undefined;
|
||||||
if (images && images.length > 0 && api.saveImageToTemp) {
|
if (images && images.length > 0 && api.saveImageToTemp) {
|
||||||
@@ -316,7 +355,7 @@ export function useElectronAgent({
|
|||||||
for (const image of images) {
|
for (const image of images) {
|
||||||
const result = await api.saveImageToTemp(
|
const result = await api.saveImageToTemp(
|
||||||
image.data,
|
image.data,
|
||||||
image.filename,
|
sanitizeFilename(image.filename),
|
||||||
image.mimeType,
|
image.mimeType,
|
||||||
workingDirectory // Pass workingDirectory as projectPath
|
workingDirectory // Pass workingDirectory as projectPath
|
||||||
);
|
);
|
||||||
@@ -331,7 +370,7 @@ export function useElectronAgent({
|
|||||||
|
|
||||||
const result = await api.agent!.send(
|
const result = await api.agent!.send(
|
||||||
sessionId,
|
sessionId,
|
||||||
content,
|
messageContent,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
imagePaths,
|
imagePaths,
|
||||||
model
|
model
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import type { ImageAttachment } from '@/store/app-store';
|
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
|
||||||
|
|
||||||
export interface QueuedMessage {
|
export interface QueuedMessage {
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
images?: ImageAttachment[];
|
images?: ImageAttachment[];
|
||||||
|
textFiles?: TextFileAttachment[];
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,7 +16,11 @@ interface UseMessageQueueOptions {
|
|||||||
interface UseMessageQueueResult {
|
interface UseMessageQueueResult {
|
||||||
queuedMessages: QueuedMessage[];
|
queuedMessages: QueuedMessage[];
|
||||||
isProcessingQueue: boolean;
|
isProcessingQueue: boolean;
|
||||||
addToQueue: (content: string, images?: ImageAttachment[]) => void;
|
addToQueue: (
|
||||||
|
content: string,
|
||||||
|
images?: ImageAttachment[],
|
||||||
|
textFiles?: TextFileAttachment[]
|
||||||
|
) => void;
|
||||||
clearQueue: () => void;
|
clearQueue: () => void;
|
||||||
removeFromQueue: (messageId: string) => void;
|
removeFromQueue: (messageId: string) => void;
|
||||||
processNext: () => Promise<void>;
|
processNext: () => Promise<void>;
|
||||||
@@ -31,16 +36,20 @@ export function useMessageQueue({ onProcessNext }: UseMessageQueueOptions): UseM
|
|||||||
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
|
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
|
||||||
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
|
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
|
||||||
|
|
||||||
const addToQueue = useCallback((content: string, images?: ImageAttachment[]) => {
|
const addToQueue = useCallback(
|
||||||
const queuedMessage: QueuedMessage = {
|
(content: string, images?: ImageAttachment[], textFiles?: TextFileAttachment[]) => {
|
||||||
id: `queued-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
const queuedMessage: QueuedMessage = {
|
||||||
content: content.trim(),
|
id: `queued-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
images,
|
content: content.trim(),
|
||||||
timestamp: new Date(),
|
images,
|
||||||
};
|
textFiles,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
setQueuedMessages((prev) => [...prev, queuedMessage]);
|
setQueuedMessages((prev) => [...prev, queuedMessage]);
|
||||||
}, []);
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const removeFromQueue = useCallback((messageId: string) => {
|
const removeFromQueue = useCallback((messageId: string) => {
|
||||||
setQueuedMessages((prev) => prev.filter((msg) => msg.id !== messageId));
|
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 {
|
export interface ResponsiveKanbanConfig {
|
||||||
columnWidth: number;
|
columnWidth: number;
|
||||||
@@ -13,16 +14,21 @@ export interface ResponsiveKanbanConfig {
|
|||||||
*/
|
*/
|
||||||
const DEFAULT_CONFIG: ResponsiveKanbanConfig = {
|
const DEFAULT_CONFIG: ResponsiveKanbanConfig = {
|
||||||
columnWidth: 288, // 18rem = 288px (w-72)
|
columnWidth: 288, // 18rem = 288px (w-72)
|
||||||
columnMinWidth: 280, // Minimum column width - increased to ensure usability
|
columnMinWidth: 280, // Minimum column width - ensures usability
|
||||||
columnMaxWidth: 360, // Maximum column width to ensure responsive scaling
|
columnMaxWidth: Infinity, // No max width - columns scale evenly to fill viewport
|
||||||
gap: 20, // gap-5 = 20px
|
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 {
|
export interface UseResponsiveKanbanResult {
|
||||||
columnWidth: number;
|
columnWidth: number;
|
||||||
containerStyle: React.CSSProperties;
|
containerStyle: React.CSSProperties;
|
||||||
isCompact: boolean;
|
isCompact: boolean;
|
||||||
|
totalBoardWidth: number;
|
||||||
|
isInitialized: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,9 +36,14 @@ export interface UseResponsiveKanbanResult {
|
|||||||
* Ensures columns scale intelligently to fill available space without
|
* Ensures columns scale intelligently to fill available space without
|
||||||
* dead space on the right or content being cut off.
|
* 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 columnCount - Number of columns in the Kanban board
|
||||||
* @param config - Optional configuration for column sizing
|
* @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(
|
export function useResponsiveKanban(
|
||||||
columnCount: number = 4,
|
columnCount: number = 4,
|
||||||
@@ -43,68 +54,129 @@ export function useResponsiveKanban(
|
|||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateColumnWidth = useCallback(() => {
|
const sidebarOpen = useAppStore((state) => state.sidebarOpen);
|
||||||
if (typeof window === 'undefined') {
|
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
return DEFAULT_CONFIG.columnWidth;
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
}
|
|
||||||
|
|
||||||
// Get the actual board container width
|
const calculateColumnWidth = useCallback(
|
||||||
// The flex layout already accounts for sidebar width, so we use the container's actual width
|
(containerWidth?: number) => {
|
||||||
const boardContainer = document.querySelector('[data-testid="board-view"]')?.parentElement;
|
if (typeof window === 'undefined') {
|
||||||
const containerWidth = boardContainer ? boardContainer.clientWidth : window.innerWidth;
|
return DEFAULT_CONFIG.columnWidth;
|
||||||
|
}
|
||||||
|
|
||||||
// Get the available width (subtract padding only)
|
// Get the actual board container width
|
||||||
const availableWidth = containerWidth - padding;
|
// 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
|
// Get the available width (subtract padding only)
|
||||||
const totalGapWidth = gap * (columnCount - 1);
|
const availableWidth = width - padding;
|
||||||
|
|
||||||
// Calculate width available for all columns
|
// Calculate total gap space needed
|
||||||
const widthForColumns = availableWidth - totalGapWidth;
|
const totalGapWidth = gap * (columnCount - 1);
|
||||||
|
|
||||||
// Calculate ideal column width
|
// Calculate width available for all columns
|
||||||
let idealWidth = Math.floor(widthForColumns / columnCount);
|
const widthForColumns = availableWidth - totalGapWidth;
|
||||||
|
|
||||||
// Clamp to min/max bounds
|
// Calculate ideal column width
|
||||||
idealWidth = Math.max(columnMinWidth, Math.min(columnMaxWidth, idealWidth));
|
let idealWidth = Math.floor(widthForColumns / columnCount);
|
||||||
|
|
||||||
return idealWidth;
|
// Clamp to min/max bounds
|
||||||
}, [columnCount, columnMinWidth, columnMaxWidth, gap, padding]);
|
idealWidth = Math.max(columnMinWidth, Math.min(columnMaxWidth, idealWidth));
|
||||||
|
|
||||||
|
return idealWidth;
|
||||||
|
},
|
||||||
|
[columnCount, columnMinWidth, columnMaxWidth, gap, padding]
|
||||||
|
);
|
||||||
|
|
||||||
const [columnWidth, setColumnWidth] = useState<number>(() => calculateColumnWidth());
|
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(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
const handleResize = () => {
|
const updateWidth = () => {
|
||||||
const newWidth = calculateColumnWidth();
|
const newWidth = calculateColumnWidth();
|
||||||
setColumnWidth(newWidth);
|
setColumnWidth(newWidth);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set initial width
|
// Debounced update for smooth resize transitions
|
||||||
handleResize();
|
const scheduleUpdate = () => {
|
||||||
|
if (resizeTimeoutRef.current) {
|
||||||
|
clearTimeout(resizeTimeoutRef.current);
|
||||||
|
}
|
||||||
|
resizeTimeoutRef.current = setTimeout(updateWidth, 50);
|
||||||
|
};
|
||||||
|
|
||||||
// Use ResizeObserver for more precise updates if available
|
// Use ResizeObserver on the actual board container for precise updates
|
||||||
if (typeof ResizeObserver !== 'undefined') {
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
const observer = new ResizeObserver(handleResize);
|
const boardView = document.querySelector('[data-testid="board-view"]');
|
||||||
observer.observe(document.body);
|
const container = boardView?.parentElement;
|
||||||
|
|
||||||
return () => {
|
if (container && typeof ResizeObserver !== 'undefined') {
|
||||||
observer.disconnect();
|
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
|
// Fallback to window resize event
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', scheduleUpdate);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', handleResize);
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', scheduleUpdate);
|
||||||
|
if (resizeTimeoutRef.current) {
|
||||||
|
clearTimeout(resizeTimeoutRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [calculateColumnWidth]);
|
}, [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)
|
// Determine if we're in compact mode (columns at minimum width)
|
||||||
const isCompact = columnWidth <= columnMinWidth + 10;
|
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 = {
|
const containerStyle: React.CSSProperties = {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: `${gap}px`,
|
gap: `${gap}px`,
|
||||||
@@ -116,5 +188,7 @@ export function useResponsiveKanban(
|
|||||||
columnWidth,
|
columnWidth,
|
||||||
containerStyle,
|
containerStyle,
|
||||||
isCompact,
|
isCompact,
|
||||||
|
totalBoardWidth,
|
||||||
|
isInitialized,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -536,6 +536,18 @@ export interface ElectronAPI {
|
|||||||
claude?: {
|
claude?: {
|
||||||
getUsage: () => Promise<ClaudeUsageResponse>;
|
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
|
// Note: Window interface is declared in @/types/electron.d.ts
|
||||||
|
|||||||
@@ -1011,6 +1011,25 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
claude = {
|
claude = {
|
||||||
getUsage: (): Promise<ClaudeUsageResponse> => this.get('/api/claude/usage'),
|
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
|
// 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 { spawn, ChildProcess } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import http, { Server } from 'http';
|
import http, { Server } from 'http';
|
||||||
import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron';
|
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
|
||||||
|
|
||||||
// Development environment
|
// Development environment
|
||||||
const isDev = !app.isPackaged;
|
const isDev = !app.isPackaged;
|
||||||
@@ -31,6 +31,39 @@ let staticServer: Server | null = null;
|
|||||||
const SERVER_PORT = 3008;
|
const SERVER_PORT = 3008;
|
||||||
const STATIC_PORT = 3007;
|
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
|
* Get icon path - works in both dev and production, cross-platform
|
||||||
*/
|
*/
|
||||||
@@ -56,6 +89,120 @@ function getIconPath(): string | null {
|
|||||||
return iconPath;
|
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
|
* Start static file server for production builds
|
||||||
*/
|
*/
|
||||||
@@ -246,11 +393,18 @@ async function waitForServer(maxAttempts = 30): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
const iconPath = getIconPath();
|
const iconPath = getIconPath();
|
||||||
|
|
||||||
|
// Load and validate saved window bounds
|
||||||
|
const savedBounds = loadWindowBounds();
|
||||||
|
const validBounds = savedBounds ? validateBounds(savedBounds) : null;
|
||||||
|
|
||||||
const windowOptions: Electron.BrowserWindowConstructorOptions = {
|
const windowOptions: Electron.BrowserWindowConstructorOptions = {
|
||||||
width: 1600,
|
width: validBounds?.width ?? DEFAULT_WIDTH,
|
||||||
height: 950,
|
height: validBounds?.height ?? DEFAULT_HEIGHT,
|
||||||
minWidth: 1280,
|
x: validBounds?.x,
|
||||||
minHeight: 768,
|
y: validBounds?.y,
|
||||||
|
minWidth: MIN_WIDTH_EXPANDED, // 1500px - ensures kanban columns fit with sidebar
|
||||||
|
minHeight: MIN_HEIGHT,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -266,6 +420,11 @@ function createWindow(): void {
|
|||||||
|
|
||||||
mainWindow = new BrowserWindow(windowOptions);
|
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
|
// Load Vite dev server in development or static server in production
|
||||||
if (VITE_DEV_SERVER_URL) {
|
if (VITE_DEV_SERVER_URL) {
|
||||||
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
||||||
@@ -280,10 +439,35 @@ function createWindow(): void {
|
|||||||
mainWindow.webContents.openDevTools();
|
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.on('closed', () => {
|
||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mainWindow.on('resized', () => {
|
||||||
|
scheduleSaveWindowBounds();
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('moved', () => {
|
||||||
|
scheduleSaveWindowBounds();
|
||||||
|
});
|
||||||
|
|
||||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
shell.openExternal(url);
|
shell.openExternal(url);
|
||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
@@ -460,3 +644,17 @@ ipcMain.handle('ping', async () => {
|
|||||||
ipcMain.handle('server:getUrl', async () => {
|
ipcMain.handle('server:getUrl', async () => {
|
||||||
return `http://localhost:${SERVER_PORT}`;
|
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),
|
getPath: (name: string): Promise<string> => ipcRenderer.invoke('app:getPath', name),
|
||||||
getVersion: (): Promise<string> => ipcRenderer.invoke('app:getVersion'),
|
getVersion: (): Promise<string> => ipcRenderer.invoke('app:getVersion'),
|
||||||
isPackaged: (): Promise<boolean> => ipcRenderer.invoke('app:isPackaged'),
|
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)');
|
console.log('[Preload] Electron API exposed (TypeScript)');
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { Project, TrashedProject } from '@/lib/electron';
|
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 =
|
export type ViewMode =
|
||||||
| 'welcome'
|
| 'welcome'
|
||||||
@@ -202,12 +212,21 @@ export interface ImageAttachment {
|
|||||||
size?: number; // file size in bytes - optional for messages from server
|
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 {
|
export interface ChatMessage {
|
||||||
id: string;
|
id: string;
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
images?: ImageAttachment[];
|
images?: ImageAttachment[];
|
||||||
|
textFiles?: TextFileAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatSession {
|
export interface ChatSession {
|
||||||
@@ -220,6 +239,7 @@ export interface ChatSession {
|
|||||||
archived: boolean;
|
archived: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UI-specific: base64-encoded images (not in shared types)
|
||||||
export interface FeatureImage {
|
export interface FeatureImage {
|
||||||
id: string;
|
id: string;
|
||||||
data: string; // base64 encoded
|
data: string; // base64 encoded
|
||||||
@@ -228,62 +248,25 @@ export interface FeatureImage {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeatureImagePath {
|
|
||||||
id: string;
|
|
||||||
path: string; // Path to the temp file
|
|
||||||
filename: string;
|
|
||||||
mimeType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Available models for feature execution
|
// Available models for feature execution
|
||||||
export type ClaudeModel = 'opus' | 'sonnet' | 'haiku';
|
export type ClaudeModel = 'opus' | 'sonnet' | 'haiku';
|
||||||
export type AgentModel = ClaudeModel;
|
|
||||||
|
|
||||||
// Model provider type
|
export interface Feature extends Omit<
|
||||||
export type ModelProvider = 'claude';
|
BaseFeature,
|
||||||
|
'steps' | 'imagePaths' | 'textFilePaths' | 'status'
|
||||||
// Thinking level (budget_tokens) options
|
> {
|
||||||
export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
|
||||||
|
|
||||||
// Planning mode for feature specifications
|
|
||||||
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
|
||||||
|
|
||||||
// AI Provider Profile - user-defined presets for model configurations
|
|
||||||
export interface AIProfile {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
model: AgentModel;
|
|
||||||
thinkingLevel: ThinkingLevel;
|
|
||||||
provider: ModelProvider;
|
|
||||||
isBuiltIn: boolean; // Built-in profiles cannot be deleted
|
|
||||||
icon?: string; // Optional icon name from lucide
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Feature {
|
|
||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
titleGenerating?: boolean;
|
titleGenerating?: boolean;
|
||||||
category: string;
|
category: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
steps: string[]; // Required in UI (not optional)
|
||||||
status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed';
|
status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed';
|
||||||
images?: FeatureImage[];
|
images?: FeatureImage[]; // UI-specific base64 images
|
||||||
imagePaths?: FeatureImagePath[]; // Paths to temp files for agent context
|
imagePaths?: FeatureImagePath[]; // Stricter type than base (no string | union)
|
||||||
startedAt?: string; // ISO timestamp for when the card moved to in_progress
|
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
|
||||||
skipTests?: boolean; // When true, skip TDD approach and require manual verification
|
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
|
||||||
summary?: string; // Summary of what was done/modified by the agent
|
prUrl?: string; // UI-specific: Pull request URL
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parsed task from spec (for spec and full planning modes)
|
// Parsed task from spec (for spec and full planning modes)
|
||||||
@@ -535,6 +518,73 @@ export interface AppState {
|
|||||||
planContent: string;
|
planContent: string;
|
||||||
planningMode: 'lite' | 'spec' | 'full';
|
planningMode: 'lite' | 'spec' | 'full';
|
||||||
} | null;
|
} | 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
|
// Default background settings for board backgrounds
|
||||||
@@ -1183,7 +1233,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
|
|
||||||
addFeature: (feature) => {
|
addFeature: (feature) => {
|
||||||
const id = feature.id || `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
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] });
|
set({ features: [...get().features, featureWithId] });
|
||||||
return featureWithId;
|
return featureWithId;
|
||||||
},
|
},
|
||||||
@@ -2486,6 +2536,15 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
// Plan Approval actions
|
// Plan Approval actions
|
||||||
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),
|
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
|
||||||
reset: () => set(initialState),
|
reset: () => set(initialState),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -46,28 +46,25 @@ test.describe('Context View - File Management', () => {
|
|||||||
|
|
||||||
await navigateToContext(page);
|
await navigateToContext(page);
|
||||||
|
|
||||||
// Click Add File button
|
// Click Create Markdown button
|
||||||
await clickElement(page, 'add-context-file');
|
await clickElement(page, 'create-markdown-button');
|
||||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Select text type (should be default)
|
|
||||||
await clickElement(page, 'add-text-type');
|
|
||||||
|
|
||||||
// Enter filename
|
// Enter filename
|
||||||
await fillInput(page, 'new-file-name', 'test-context.md');
|
await fillInput(page, 'new-markdown-name', 'test-context.md');
|
||||||
|
|
||||||
// Enter content
|
// Enter content
|
||||||
const testContent = '# Test Context\n\nThis is test 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
|
// Click confirm
|
||||||
await clickElement(page, 'confirm-add-file');
|
await clickElement(page, 'confirm-create-markdown');
|
||||||
|
|
||||||
// Wait for dialog to close
|
// Wait for dialog to close
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||||
{ timeout: 5000 }
|
{ timeout: 5000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -208,36 +205,16 @@ test.describe('Context View - File Management', () => {
|
|||||||
|
|
||||||
await navigateToContext(page);
|
await navigateToContext(page);
|
||||||
|
|
||||||
// Click Add File button
|
// Use the hidden file input to upload an image directly
|
||||||
await clickElement(page, 'add-context-file');
|
// The "Import File" button triggers this input
|
||||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
const fileInput = page.locator('[data-testid="file-import-input"]');
|
||||||
timeout: 5000,
|
await fileInput.setInputFiles(TEST_IMAGE_SRC);
|
||||||
});
|
|
||||||
|
|
||||||
// Select image type
|
// Wait for file to appear in the list (filename is extracted from path)
|
||||||
await clickElement(page, 'add-image-type');
|
await waitForContextFile(page, 'logo.png', 10000);
|
||||||
|
|
||||||
// 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 }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify file appears in list
|
// 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();
|
await expect(fileButton).toBeVisible();
|
||||||
|
|
||||||
// Click on the image to view it
|
// Click on the image to view it
|
||||||
@@ -362,26 +339,23 @@ test.describe('Context View - Drag and Drop', () => {
|
|||||||
|
|
||||||
await navigateToContext(page);
|
await navigateToContext(page);
|
||||||
|
|
||||||
// Open add file dialog
|
// Open create markdown dialog
|
||||||
await clickElement(page, 'add-context-file');
|
await clickElement(page, 'create-markdown-button');
|
||||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure text type is selected
|
|
||||||
await clickElement(page, 'add-text-type');
|
|
||||||
|
|
||||||
// Simulate drag and drop of a .md file onto the textarea
|
// Simulate drag and drop of a .md file onto the textarea
|
||||||
const droppedContent = '# Dropped Content\n\nThis was dragged and dropped.';
|
const droppedContent = '# Dropped Content\n\nThis was dragged and dropped.';
|
||||||
await simulateFileDrop(
|
await simulateFileDrop(
|
||||||
page,
|
page,
|
||||||
'[data-testid="new-file-content"]',
|
'[data-testid="new-markdown-content"]',
|
||||||
'dropped-file.md',
|
'dropped-file.md',
|
||||||
droppedContent
|
droppedContent
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for content to be populated in textarea
|
// 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 textarea.waitFor({ state: 'visible' });
|
||||||
await expect(textarea).toHaveValue(droppedContent);
|
await expect(textarea).toHaveValue(droppedContent);
|
||||||
|
|
||||||
@@ -390,15 +364,15 @@ test.describe('Context View - Drag and Drop', () => {
|
|||||||
expect(textareaContent).toBe(droppedContent);
|
expect(textareaContent).toBe(droppedContent);
|
||||||
|
|
||||||
// Verify filename is auto-filled
|
// 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');
|
expect(filenameValue).toBe('dropped-file.md');
|
||||||
|
|
||||||
// Confirm and create the file
|
// Confirm and create the file
|
||||||
await clickElement(page, 'confirm-add-file');
|
await clickElement(page, 'confirm-create-markdown');
|
||||||
|
|
||||||
// Wait for dialog to close
|
// Wait for dialog to close
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||||
{ timeout: 5000 }
|
{ timeout: 5000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -473,20 +447,19 @@ test.describe('Context View - Edge Cases', () => {
|
|||||||
await expect(originalFile).toBeVisible();
|
await expect(originalFile).toBeVisible();
|
||||||
|
|
||||||
// Try to create another file with the same name
|
// Try to create another file with the same name
|
||||||
await clickElement(page, 'add-context-file');
|
await clickElement(page, 'create-markdown-button');
|
||||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await clickElement(page, 'add-text-type');
|
await fillInput(page, 'new-markdown-name', 'test.md');
|
||||||
await fillInput(page, 'new-file-name', 'test.md');
|
await fillInput(page, 'new-markdown-content', '# New Content - Overwritten');
|
||||||
await fillInput(page, 'new-file-content', '# New Content - Overwritten');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-add-file');
|
await clickElement(page, 'confirm-create-markdown');
|
||||||
|
|
||||||
// Wait for dialog to close
|
// Wait for dialog to close
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||||
{ timeout: 5000 }
|
{ timeout: 5000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -518,18 +491,17 @@ test.describe('Context View - Edge Cases', () => {
|
|||||||
await navigateToContext(page);
|
await navigateToContext(page);
|
||||||
|
|
||||||
// Test file with parentheses
|
// Test file with parentheses
|
||||||
await clickElement(page, 'add-context-file');
|
await clickElement(page, 'create-markdown-button');
|
||||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await clickElement(page, 'add-text-type');
|
await fillInput(page, 'new-markdown-name', 'context (1).md');
|
||||||
await fillInput(page, 'new-file-name', 'context (1).md');
|
await fillInput(page, 'new-markdown-content', 'Content with parentheses in filename');
|
||||||
await fillInput(page, 'new-file-content', 'Content with parentheses in filename');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-add-file');
|
await clickElement(page, 'confirm-create-markdown');
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||||
{ timeout: 5000 }
|
{ timeout: 5000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -538,18 +510,17 @@ test.describe('Context View - Edge Cases', () => {
|
|||||||
await expect(fileWithParens).toBeVisible();
|
await expect(fileWithParens).toBeVisible();
|
||||||
|
|
||||||
// Test file with hyphens and underscores
|
// Test file with hyphens and underscores
|
||||||
await clickElement(page, 'add-context-file');
|
await clickElement(page, 'create-markdown-button');
|
||||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await clickElement(page, 'add-text-type');
|
await fillInput(page, 'new-markdown-name', 'test-file_v2.md');
|
||||||
await fillInput(page, 'new-file-name', 'test-file_v2.md');
|
await fillInput(page, 'new-markdown-content', 'Content with hyphens and underscores');
|
||||||
await fillInput(page, 'new-file-content', 'Content with hyphens and underscores');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-add-file');
|
await clickElement(page, 'confirm-create-markdown');
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||||
{ timeout: 5000 }
|
{ timeout: 5000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -582,18 +553,17 @@ test.describe('Context View - Edge Cases', () => {
|
|||||||
await navigateToContext(page);
|
await navigateToContext(page);
|
||||||
|
|
||||||
// Create file with empty content
|
// Create file with empty content
|
||||||
await clickElement(page, 'add-context-file');
|
await clickElement(page, 'create-markdown-button');
|
||||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await clickElement(page, 'add-text-type');
|
await fillInput(page, 'new-markdown-name', 'empty-file.md');
|
||||||
await fillInput(page, 'new-file-name', 'empty-file.md');
|
|
||||||
// Don't fill any content - leave it empty
|
// Don't fill any content - leave it empty
|
||||||
|
|
||||||
await clickElement(page, 'confirm-add-file');
|
await clickElement(page, 'confirm-create-markdown');
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||||
{ timeout: 5000 }
|
{ timeout: 5000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -110,9 +110,9 @@ test.describe('Kanban Responsive Scaling Tests', () => {
|
|||||||
expect(Math.abs(columnWidth - baseWidth)).toBeLessThan(2);
|
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).toBeGreaterThanOrEqual(280);
|
||||||
expect(baseWidth).toBeLessThanOrEqual(360);
|
|
||||||
|
|
||||||
// Columns should not overlap (check x positions)
|
// Columns should not overlap (check x positions)
|
||||||
expect(inProgressBox.x).toBeGreaterThan(backlogBox.x + backlogBox.width - 5);
|
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
|
// There should be no horizontal scroll at standard width since columns scale down
|
||||||
expect(hasHorizontalScroll).toBe(false);
|
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();
|
const dataTransfer = new DataTransfer();
|
||||||
dataTransfer.items.add(file);
|
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
|
// Dispatch drag events
|
||||||
target.dispatchEvent(
|
target.dispatchEvent(dragOverEvent);
|
||||||
new DragEvent('dragover', {
|
target.dispatchEvent(dropEvent);
|
||||||
dataTransfer,
|
|
||||||
bubbles: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
target.dispatchEvent(
|
|
||||||
new DragEvent('drop', {
|
|
||||||
dataTransfer,
|
|
||||||
bubbles: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
{ selector: targetSelector, content: fileContent, name: fileName, mime: mimeType }
|
{ 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.goto('/context');
|
||||||
await page.waitForLoadState('networkidle');
|
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
|
// Wait for the context view to be visible
|
||||||
await waitForElement(page, 'context-view', { timeout: 10000 });
|
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;
|
[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 {
|
export interface Feature {
|
||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -25,6 +34,7 @@ export interface Feature {
|
|||||||
spec?: string;
|
spec?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
imagePaths?: Array<string | FeatureImagePath | { path: string; [key: string]: unknown }>;
|
imagePaths?: Array<string | FeatureImagePath | { path: string; [key: string]: unknown }>;
|
||||||
|
textFilePaths?: FeatureTextFilePath[];
|
||||||
// Branch info - worktree path is derived at runtime from branchName
|
// Branch info - worktree path is derived at runtime from branchName
|
||||||
branchName?: string; // Name of the feature branch (undefined = use current worktree)
|
branchName?: string; // Name of the feature branch (undefined = use current worktree)
|
||||||
skipTests?: boolean;
|
skipTests?: boolean;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export type {
|
|||||||
} from './provider.js';
|
} from './provider.js';
|
||||||
|
|
||||||
// Feature types
|
// Feature types
|
||||||
export type { Feature, FeatureImagePath, FeatureStatus } from './feature.js';
|
export type { Feature, FeatureImagePath, FeatureTextFilePath, FeatureStatus } from './feature.js';
|
||||||
|
|
||||||
// Session types
|
// Session types
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Model alias mapping for Claude models
|
* Model alias mapping for Claude models
|
||||||
*/
|
*/
|
||||||
export const CLAUDE_MODEL_MAP: Record<string, string> = {
|
export const CLAUDE_MODEL_MAP: Record<string, string> = {
|
||||||
haiku: 'claude-haiku-4-5',
|
haiku: 'claude-haiku-4-5-20251001',
|
||||||
sonnet: 'claude-sonnet-4-20250514',
|
sonnet: 'claude-sonnet-4-5-20250929',
|
||||||
opus: 'claude-opus-4-5-20251101',
|
opus: 'claude-opus-4-5-20251101',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,25 @@ export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
|||||||
/** ModelProvider - AI model provider for credentials and API key management */
|
/** ModelProvider - AI model provider for credentials and API key management */
|
||||||
export type ModelProvider = 'claude';
|
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
|
* KeyboardShortcuts - User-configurable keyboard bindings for common actions
|
||||||
*
|
*
|
||||||
@@ -272,6 +291,10 @@ export interface GlobalSettings {
|
|||||||
// Session Tracking
|
// Session Tracking
|
||||||
/** Maps project path -> last selected session ID in that project */
|
/** Maps project path -> last selected session ID in that project */
|
||||||
lastSelectedSessionByProject: Record<string, string>;
|
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
|
// Path utilities
|
||||||
export { normalizePath, pathsEqual } from './path-utils.js';
|
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",
|
"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",
|
"fix:lockfile": "node scripts/fix-lockfile-urls.mjs",
|
||||||
"dev": "node init.mjs",
|
"dev": "node init.mjs",
|
||||||
"dev:web": "npm run dev:web --workspace=apps/ui",
|
"_dev:web": "npm run dev:web --workspace=apps/ui",
|
||||||
"dev:electron": "npm run dev:electron --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:debug": "npm run dev:electron:debug --workspace=apps/ui",
|
||||||
"dev:electron:wsl": "npm run dev:electron:wsl --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:electron:wsl:gpu": "npm run dev:electron:wsl:gpu --workspace=apps/ui",
|
||||||
"dev:server": "npm run dev --workspace=apps/server",
|
"_dev:server": "npm run dev --workspace=apps/server",
|
||||||
"dev:full": "concurrently \"npm run dev:server\" \"npm run dev:web\"",
|
"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": "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: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 --workspace=apps/server",
|
"build:server": "npm run build:packages && npm run build --workspace=apps/server",
|
||||||
"build:electron": "npm run build:electron --workspace=apps/ui",
|
"build:electron": "npm run build:packages && npm run build:electron --workspace=apps/ui",
|
||||||
"build:electron:dir": "npm run build:electron:dir --workspace=apps/ui",
|
"build:electron:dir": "npm run build:packages && npm run build:electron:dir --workspace=apps/ui",
|
||||||
"build:electron:win": "npm run build:electron:win --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:electron:win:dir --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:electron:mac --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:electron:mac:dir --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:electron:linux --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:electron:linux:dir --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",
|
"lint": "npm run lint --workspace=apps/ui",
|
||||||
"test": "npm run test --workspace=apps/ui",
|
"test": "npm run test --workspace=apps/ui",
|
||||||
"test:headed": "npm run test:headed --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)",
|
"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": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"prepare": "husky"
|
"prepare": "husky && npm run build:packages"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx,json,css,md,html,yml,yaml}": [
|
"*.{js,jsx,ts,tsx,json,css,md,html,yml,yaml}": [
|
||||||
|
|||||||
Reference in New Issue
Block a user