mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge upstream/v0.12.0rc into feature/fedora-rpm-support
Resolved conflict in backlog-plan/common.ts: - Kept local (stricter) validation: Array.isArray(parsed?.result?.changes) - This ensures type safety for the changes array
This commit is contained in:
@@ -44,6 +44,11 @@ CORS_ORIGIN=http://localhost:3007
|
||||
# OPTIONAL - Server
|
||||
# ============================================
|
||||
|
||||
# Host to bind the server to (default: 0.0.0.0)
|
||||
# Use 0.0.0.0 to listen on all interfaces (recommended for Docker/remote access)
|
||||
# Use 127.0.0.1 or localhost to restrict to local connections only
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Port to run the server on
|
||||
PORT=3008
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
|
||||
@@ -79,11 +79,17 @@ import { createIdeationRoutes } from './routes/ideation/index.js';
|
||||
import { IdeationService } from './services/ideation-service.js';
|
||||
import { getDevServerService } from './services/dev-server-service.js';
|
||||
import { eventHookService } from './services/event-hook-service.js';
|
||||
import { createNotificationsRoutes } from './routes/notifications/index.js';
|
||||
import { getNotificationService } from './services/notification-service.js';
|
||||
import { createEventHistoryRoutes } from './routes/event-history/index.js';
|
||||
import { getEventHistoryService } from './services/event-history-service.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3008', 10);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const HOSTNAME = process.env.HOSTNAME || 'localhost';
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
const ENABLE_REQUEST_LOGGING_DEFAULT = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
|
||||
|
||||
@@ -208,8 +214,15 @@ const ideationService = new IdeationService(events, settingsService, featureLoad
|
||||
const devServerService = getDevServerService();
|
||||
devServerService.setEventEmitter(events);
|
||||
|
||||
// Initialize Event Hook Service for custom event triggers
|
||||
eventHookService.initialize(events, settingsService);
|
||||
// Initialize Notification Service with event emitter for real-time updates
|
||||
const notificationService = getNotificationService();
|
||||
notificationService.setEventEmitter(events);
|
||||
|
||||
// Initialize Event History Service
|
||||
const eventHistoryService = getEventHistoryService();
|
||||
|
||||
// Initialize Event Hook Service for custom event triggers (with history storage)
|
||||
eventHookService.initialize(events, settingsService, eventHistoryService);
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
@@ -264,7 +277,7 @@ app.get('/api/health/detailed', createDetailedHandler());
|
||||
app.use('/api/fs', createFsRoutes(events));
|
||||
app.use('/api/agent', createAgentRoutes(agentService, events));
|
||||
app.use('/api/sessions', createSessionsRoutes(agentService));
|
||||
app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService));
|
||||
app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService, events));
|
||||
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
||||
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
|
||||
@@ -285,6 +298,8 @@ app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||
app.use('/api/mcp', createMCPRoutes(mcpTestService));
|
||||
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
||||
app.use('/api/notifications', createNotificationsRoutes(notificationService));
|
||||
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
@@ -596,8 +611,8 @@ terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage
|
||||
});
|
||||
|
||||
// Start server with error handling for port conflicts
|
||||
const startServer = (port: number) => {
|
||||
server.listen(port, () => {
|
||||
const startServer = (port: number, host: string) => {
|
||||
server.listen(port, host, () => {
|
||||
const terminalStatus = isTerminalEnabled()
|
||||
? isTerminalPasswordRequired()
|
||||
? 'enabled (password protected)'
|
||||
@@ -608,10 +623,11 @@ const startServer = (port: number) => {
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ Automaker Backend Server ║
|
||||
╠═══════════════════════════════════════════════════════╣
|
||||
║ HTTP API: http://localhost:${portStr} ║
|
||||
║ WebSocket: ws://localhost:${portStr}/api/events ║
|
||||
║ Terminal: ws://localhost:${portStr}/api/terminal/ws ║
|
||||
║ Health: http://localhost:${portStr}/api/health ║
|
||||
║ Listening: ${host}:${port}${' '.repeat(Math.max(0, 34 - host.length - port.toString().length))}║
|
||||
║ HTTP API: http://${HOSTNAME}:${portStr} ║
|
||||
║ WebSocket: ws://${HOSTNAME}:${portStr}/api/events ║
|
||||
║ Terminal: ws://${HOSTNAME}:${portStr}/api/terminal/ws ║
|
||||
║ Health: http://${HOSTNAME}:${portStr}/api/health ║
|
||||
║ Terminal: ${terminalStatus.padEnd(37)}║
|
||||
╚═══════════════════════════════════════════════════════╝
|
||||
`);
|
||||
@@ -645,7 +661,7 @@ const startServer = (port: number) => {
|
||||
});
|
||||
};
|
||||
|
||||
startServer(PORT);
|
||||
startServer(PORT, HOST);
|
||||
|
||||
// Global error handlers to prevent crashes from uncaught errors
|
||||
process.on('unhandledRejection', (reason: unknown, _promise: Promise<unknown>) => {
|
||||
|
||||
@@ -11,8 +11,12 @@ export { specOutputSchema } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Escape special XML characters
|
||||
* Handles undefined/null values by converting them to empty strings
|
||||
*/
|
||||
function escapeXml(str: string): string {
|
||||
export function escapeXml(str: string | undefined | null): string {
|
||||
if (str == null) {
|
||||
return '';
|
||||
}
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
|
||||
611
apps/server/src/lib/xml-extractor.ts
Normal file
611
apps/server/src/lib/xml-extractor.ts
Normal file
@@ -0,0 +1,611 @@
|
||||
/**
|
||||
* XML Extraction Utilities
|
||||
*
|
||||
* Robust XML parsing utilities for extracting and updating sections
|
||||
* from app_spec.txt XML content. Uses regex-based parsing which is
|
||||
* sufficient for our controlled XML structure.
|
||||
*
|
||||
* Note: If more complex XML parsing is needed in the future, consider
|
||||
* using a library like 'fast-xml-parser' or 'xml2js'.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { SpecOutput } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('XmlExtractor');
|
||||
|
||||
/**
|
||||
* Represents an implemented feature extracted from XML
|
||||
*/
|
||||
export interface ImplementedFeature {
|
||||
name: string;
|
||||
description: string;
|
||||
file_locations?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger interface for optional custom logging
|
||||
*/
|
||||
export interface XmlExtractorLogger {
|
||||
debug: (message: string, ...args: unknown[]) => void;
|
||||
warn?: (message: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for XML extraction operations
|
||||
*/
|
||||
export interface ExtractXmlOptions {
|
||||
/** Custom logger (defaults to internal logger) */
|
||||
logger?: XmlExtractorLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special XML characters
|
||||
* Handles undefined/null values by converting them to empty strings
|
||||
*/
|
||||
export function escapeXml(str: string | undefined | null): string {
|
||||
if (str == null) {
|
||||
return '';
|
||||
}
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unescape XML entities back to regular characters
|
||||
*/
|
||||
export function unescapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the content of a specific XML section
|
||||
*
|
||||
* @param xmlContent - The full XML content
|
||||
* @param tagName - The tag name to extract (e.g., 'implemented_features')
|
||||
* @param options - Optional extraction options
|
||||
* @returns The content between the tags, or null if not found
|
||||
*/
|
||||
export function extractXmlSection(
|
||||
xmlContent: string,
|
||||
tagName: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string | null {
|
||||
const log = options.logger || logger;
|
||||
|
||||
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'i');
|
||||
const match = xmlContent.match(regex);
|
||||
|
||||
if (match) {
|
||||
log.debug(`Extracted <${tagName}> section`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
log.debug(`Section <${tagName}> not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all values from repeated XML elements
|
||||
*
|
||||
* @param xmlContent - The XML content to search
|
||||
* @param tagName - The tag name to extract values from
|
||||
* @param options - Optional extraction options
|
||||
* @returns Array of extracted values (unescaped)
|
||||
*/
|
||||
export function extractXmlElements(
|
||||
xmlContent: string,
|
||||
tagName: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string[] {
|
||||
const log = options.logger || logger;
|
||||
const values: string[] = [];
|
||||
|
||||
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'g');
|
||||
const matches = xmlContent.matchAll(regex);
|
||||
|
||||
for (const match of matches) {
|
||||
values.push(unescapeXml(match[1].trim()));
|
||||
}
|
||||
|
||||
log.debug(`Extracted ${values.length} <${tagName}> elements`);
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract implemented features from app_spec.txt XML content
|
||||
*
|
||||
* @param specContent - The full XML content of app_spec.txt
|
||||
* @param options - Optional extraction options
|
||||
* @returns Array of implemented features with name, description, and optional file_locations
|
||||
*/
|
||||
export function extractImplementedFeatures(
|
||||
specContent: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): ImplementedFeature[] {
|
||||
const log = options.logger || logger;
|
||||
const features: ImplementedFeature[] = [];
|
||||
|
||||
// Match <implemented_features>...</implemented_features> section
|
||||
const implementedSection = extractXmlSection(specContent, 'implemented_features', options);
|
||||
|
||||
if (!implementedSection) {
|
||||
log.debug('No implemented_features section found');
|
||||
return features;
|
||||
}
|
||||
|
||||
// Extract individual feature blocks
|
||||
const featureRegex = /<feature>([\s\S]*?)<\/feature>/g;
|
||||
const featureMatches = implementedSection.matchAll(featureRegex);
|
||||
|
||||
for (const featureMatch of featureMatches) {
|
||||
const featureContent = featureMatch[1];
|
||||
|
||||
// Extract name
|
||||
const nameMatch = featureContent.match(/<name>([\s\S]*?)<\/name>/);
|
||||
const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : '';
|
||||
|
||||
// Extract description
|
||||
const descMatch = featureContent.match(/<description>([\s\S]*?)<\/description>/);
|
||||
const description = descMatch ? unescapeXml(descMatch[1].trim()) : '';
|
||||
|
||||
// Extract file_locations if present
|
||||
const locationsSection = extractXmlSection(featureContent, 'file_locations', options);
|
||||
const file_locations = locationsSection
|
||||
? extractXmlElements(locationsSection, 'location', options)
|
||||
: undefined;
|
||||
|
||||
if (name) {
|
||||
features.push({
|
||||
name,
|
||||
description,
|
||||
...(file_locations && file_locations.length > 0 ? { file_locations } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(`Extracted ${features.length} implemented features`);
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract only the feature names from implemented_features section
|
||||
*
|
||||
* @param specContent - The full XML content of app_spec.txt
|
||||
* @param options - Optional extraction options
|
||||
* @returns Array of feature names
|
||||
*/
|
||||
export function extractImplementedFeatureNames(
|
||||
specContent: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string[] {
|
||||
const features = extractImplementedFeatures(specContent, options);
|
||||
return features.map((f) => f.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate XML for a single implemented feature
|
||||
*
|
||||
* @param feature - The feature to convert to XML
|
||||
* @param indent - The base indentation level (default: 2 spaces)
|
||||
* @returns XML string for the feature
|
||||
*/
|
||||
export function featureToXml(feature: ImplementedFeature, indent: string = ' '): string {
|
||||
const i2 = indent.repeat(2);
|
||||
const i3 = indent.repeat(3);
|
||||
const i4 = indent.repeat(4);
|
||||
|
||||
let xml = `${i2}<feature>
|
||||
${i3}<name>${escapeXml(feature.name)}</name>
|
||||
${i3}<description>${escapeXml(feature.description)}</description>`;
|
||||
|
||||
if (feature.file_locations && feature.file_locations.length > 0) {
|
||||
xml += `
|
||||
${i3}<file_locations>
|
||||
${feature.file_locations.map((loc) => `${i4}<location>${escapeXml(loc)}</location>`).join('\n')}
|
||||
${i3}</file_locations>`;
|
||||
}
|
||||
|
||||
xml += `
|
||||
${i2}</feature>`;
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate XML for an array of implemented features
|
||||
*
|
||||
* @param features - Array of features to convert to XML
|
||||
* @param indent - The base indentation level (default: 2 spaces)
|
||||
* @returns XML string for the implemented_features section content
|
||||
*/
|
||||
export function featuresToXml(features: ImplementedFeature[], indent: string = ' '): string {
|
||||
return features.map((f) => featureToXml(f, indent)).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the implemented_features section in XML content
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param newFeatures - The new features to set
|
||||
* @param options - Optional extraction options
|
||||
* @returns Updated XML content with the new implemented_features section
|
||||
*/
|
||||
export function updateImplementedFeaturesSection(
|
||||
specContent: string,
|
||||
newFeatures: ImplementedFeature[],
|
||||
options: ExtractXmlOptions = {}
|
||||
): string {
|
||||
const log = options.logger || logger;
|
||||
const indent = ' ';
|
||||
|
||||
// Generate new section content
|
||||
const newSectionContent = featuresToXml(newFeatures, indent);
|
||||
|
||||
// Build the new section
|
||||
const newSection = `<implemented_features>
|
||||
${newSectionContent}
|
||||
${indent}</implemented_features>`;
|
||||
|
||||
// Check if section exists
|
||||
const sectionRegex = /<implemented_features>[\s\S]*?<\/implemented_features>/;
|
||||
|
||||
if (sectionRegex.test(specContent)) {
|
||||
log.debug('Replacing existing implemented_features section');
|
||||
return specContent.replace(sectionRegex, newSection);
|
||||
}
|
||||
|
||||
// If section doesn't exist, try to insert after core_capabilities
|
||||
const coreCapabilitiesEnd = '</core_capabilities>';
|
||||
const insertIndex = specContent.indexOf(coreCapabilitiesEnd);
|
||||
|
||||
if (insertIndex !== -1) {
|
||||
const insertPosition = insertIndex + coreCapabilitiesEnd.length;
|
||||
log.debug('Inserting implemented_features after core_capabilities');
|
||||
return (
|
||||
specContent.slice(0, insertPosition) +
|
||||
'\n\n' +
|
||||
indent +
|
||||
newSection +
|
||||
specContent.slice(insertPosition)
|
||||
);
|
||||
}
|
||||
|
||||
// As a fallback, insert before </project_specification>
|
||||
const projectSpecEnd = '</project_specification>';
|
||||
const fallbackIndex = specContent.indexOf(projectSpecEnd);
|
||||
|
||||
if (fallbackIndex !== -1) {
|
||||
log.debug('Inserting implemented_features before </project_specification>');
|
||||
return (
|
||||
specContent.slice(0, fallbackIndex) +
|
||||
indent +
|
||||
newSection +
|
||||
'\n' +
|
||||
specContent.slice(fallbackIndex)
|
||||
);
|
||||
}
|
||||
|
||||
log.warn?.('Could not find appropriate insertion point for implemented_features');
|
||||
log.debug('Could not find appropriate insertion point for implemented_features');
|
||||
return specContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new feature to the implemented_features section
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param newFeature - The feature to add
|
||||
* @param options - Optional extraction options
|
||||
* @returns Updated XML content with the new feature added
|
||||
*/
|
||||
export function addImplementedFeature(
|
||||
specContent: string,
|
||||
newFeature: ImplementedFeature,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string {
|
||||
const log = options.logger || logger;
|
||||
|
||||
// Extract existing features
|
||||
const existingFeatures = extractImplementedFeatures(specContent, options);
|
||||
|
||||
// Check for duplicates by name
|
||||
const isDuplicate = existingFeatures.some(
|
||||
(f) => f.name.toLowerCase() === newFeature.name.toLowerCase()
|
||||
);
|
||||
|
||||
if (isDuplicate) {
|
||||
log.debug(`Feature "${newFeature.name}" already exists, skipping`);
|
||||
return specContent;
|
||||
}
|
||||
|
||||
// Add the new feature
|
||||
const updatedFeatures = [...existingFeatures, newFeature];
|
||||
|
||||
log.debug(`Adding feature "${newFeature.name}"`);
|
||||
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a feature from the implemented_features section by name
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param featureName - The name of the feature to remove
|
||||
* @param options - Optional extraction options
|
||||
* @returns Updated XML content with the feature removed
|
||||
*/
|
||||
export function removeImplementedFeature(
|
||||
specContent: string,
|
||||
featureName: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string {
|
||||
const log = options.logger || logger;
|
||||
|
||||
// Extract existing features
|
||||
const existingFeatures = extractImplementedFeatures(specContent, options);
|
||||
|
||||
// Filter out the feature to remove
|
||||
const updatedFeatures = existingFeatures.filter(
|
||||
(f) => f.name.toLowerCase() !== featureName.toLowerCase()
|
||||
);
|
||||
|
||||
if (updatedFeatures.length === existingFeatures.length) {
|
||||
log.debug(`Feature "${featureName}" not found, no changes made`);
|
||||
return specContent;
|
||||
}
|
||||
|
||||
log.debug(`Removing feature "${featureName}"`);
|
||||
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing feature in the implemented_features section
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param featureName - The name of the feature to update
|
||||
* @param updates - Partial updates to apply to the feature
|
||||
* @param options - Optional extraction options
|
||||
* @returns Updated XML content with the feature modified
|
||||
*/
|
||||
export function updateImplementedFeature(
|
||||
specContent: string,
|
||||
featureName: string,
|
||||
updates: Partial<ImplementedFeature>,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string {
|
||||
const log = options.logger || logger;
|
||||
|
||||
// Extract existing features
|
||||
const existingFeatures = extractImplementedFeatures(specContent, options);
|
||||
|
||||
// Find and update the feature
|
||||
let found = false;
|
||||
const updatedFeatures = existingFeatures.map((f) => {
|
||||
if (f.name.toLowerCase() === featureName.toLowerCase()) {
|
||||
found = true;
|
||||
return {
|
||||
...f,
|
||||
...updates,
|
||||
// Preserve the original name if not explicitly updated
|
||||
name: updates.name ?? f.name,
|
||||
};
|
||||
}
|
||||
return f;
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
log.debug(`Feature "${featureName}" not found, no changes made`);
|
||||
return specContent;
|
||||
}
|
||||
|
||||
log.debug(`Updating feature "${featureName}"`);
|
||||
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature exists in the implemented_features section
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param featureName - The name of the feature to check
|
||||
* @param options - Optional extraction options
|
||||
* @returns True if the feature exists
|
||||
*/
|
||||
export function hasImplementedFeature(
|
||||
specContent: string,
|
||||
featureName: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): boolean {
|
||||
const features = extractImplementedFeatures(specContent, options);
|
||||
return features.some((f) => f.name.toLowerCase() === featureName.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert extracted features to SpecOutput.implemented_features format
|
||||
*
|
||||
* @param features - Array of extracted features
|
||||
* @returns Features in SpecOutput format
|
||||
*/
|
||||
export function toSpecOutputFeatures(
|
||||
features: ImplementedFeature[]
|
||||
): SpecOutput['implemented_features'] {
|
||||
return features.map((f) => ({
|
||||
name: f.name,
|
||||
description: f.description,
|
||||
...(f.file_locations && f.file_locations.length > 0
|
||||
? { file_locations: f.file_locations }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SpecOutput.implemented_features to ImplementedFeature format
|
||||
*
|
||||
* @param specFeatures - Features from SpecOutput
|
||||
* @returns Features in ImplementedFeature format
|
||||
*/
|
||||
export function fromSpecOutputFeatures(
|
||||
specFeatures: SpecOutput['implemented_features']
|
||||
): ImplementedFeature[] {
|
||||
return specFeatures.map((f) => ({
|
||||
name: f.name,
|
||||
description: f.description,
|
||||
...(f.file_locations && f.file_locations.length > 0
|
||||
? { file_locations: f.file_locations }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a roadmap phase extracted from XML
|
||||
*/
|
||||
export interface RoadmapPhase {
|
||||
name: string;
|
||||
status: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the technology stack from app_spec.txt XML content
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param options - Optional extraction options
|
||||
* @returns Array of technology names
|
||||
*/
|
||||
export function extractTechnologyStack(
|
||||
specContent: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string[] {
|
||||
const log = options.logger || logger;
|
||||
|
||||
const techSection = extractXmlSection(specContent, 'technology_stack', options);
|
||||
if (!techSection) {
|
||||
log.debug('No technology_stack section found');
|
||||
return [];
|
||||
}
|
||||
|
||||
const technologies = extractXmlElements(techSection, 'technology', options);
|
||||
log.debug(`Extracted ${technologies.length} technologies`);
|
||||
return technologies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the technology_stack section in XML content
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param technologies - The new technology list
|
||||
* @param options - Optional extraction options
|
||||
* @returns Updated XML content
|
||||
*/
|
||||
export function updateTechnologyStack(
|
||||
specContent: string,
|
||||
technologies: string[],
|
||||
options: ExtractXmlOptions = {}
|
||||
): string {
|
||||
const log = options.logger || logger;
|
||||
const indent = ' ';
|
||||
const i2 = indent.repeat(2);
|
||||
|
||||
// Generate new section content
|
||||
const techXml = technologies
|
||||
.map((t) => `${i2}<technology>${escapeXml(t)}</technology>`)
|
||||
.join('\n');
|
||||
const newSection = `<technology_stack>\n${techXml}\n${indent}</technology_stack>`;
|
||||
|
||||
// Check if section exists
|
||||
const sectionRegex = /<technology_stack>[\s\S]*?<\/technology_stack>/;
|
||||
|
||||
if (sectionRegex.test(specContent)) {
|
||||
log.debug('Replacing existing technology_stack section');
|
||||
return specContent.replace(sectionRegex, newSection);
|
||||
}
|
||||
|
||||
log.debug('No technology_stack section found to update');
|
||||
return specContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract roadmap phases from app_spec.txt XML content
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param options - Optional extraction options
|
||||
* @returns Array of roadmap phases
|
||||
*/
|
||||
export function extractRoadmapPhases(
|
||||
specContent: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): RoadmapPhase[] {
|
||||
const log = options.logger || logger;
|
||||
const phases: RoadmapPhase[] = [];
|
||||
|
||||
const roadmapSection = extractXmlSection(specContent, 'implementation_roadmap', options);
|
||||
if (!roadmapSection) {
|
||||
log.debug('No implementation_roadmap section found');
|
||||
return phases;
|
||||
}
|
||||
|
||||
// Extract individual phase blocks
|
||||
const phaseRegex = /<phase>([\s\S]*?)<\/phase>/g;
|
||||
const phaseMatches = roadmapSection.matchAll(phaseRegex);
|
||||
|
||||
for (const phaseMatch of phaseMatches) {
|
||||
const phaseContent = phaseMatch[1];
|
||||
|
||||
const nameMatch = phaseContent.match(/<name>([\s\S]*?)<\/name>/);
|
||||
const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : '';
|
||||
|
||||
const statusMatch = phaseContent.match(/<status>([\s\S]*?)<\/status>/);
|
||||
const status = statusMatch ? unescapeXml(statusMatch[1].trim()) : 'pending';
|
||||
|
||||
const descMatch = phaseContent.match(/<description>([\s\S]*?)<\/description>/);
|
||||
const description = descMatch ? unescapeXml(descMatch[1].trim()) : undefined;
|
||||
|
||||
if (name) {
|
||||
phases.push({ name, status, description });
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(`Extracted ${phases.length} roadmap phases`);
|
||||
return phases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a roadmap phase status in XML content
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param phaseName - The name of the phase to update
|
||||
* @param newStatus - The new status value
|
||||
* @param options - Optional extraction options
|
||||
* @returns Updated XML content
|
||||
*/
|
||||
export function updateRoadmapPhaseStatus(
|
||||
specContent: string,
|
||||
phaseName: string,
|
||||
newStatus: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string {
|
||||
const log = options.logger || logger;
|
||||
|
||||
// Find the phase and update its status
|
||||
// Match the phase block containing the specific name
|
||||
const phaseRegex = new RegExp(
|
||||
`(<phase>\\s*<name>\\s*${escapeXml(phaseName)}\\s*<\\/name>\\s*<status>)[\\s\\S]*?(<\\/status>)`,
|
||||
'i'
|
||||
);
|
||||
|
||||
if (phaseRegex.test(specContent)) {
|
||||
log.debug(`Updating phase "${phaseName}" status to "${newStatus}"`);
|
||||
return specContent.replace(phaseRegex, `$1${escapeXml(newStatus)}$2`);
|
||||
}
|
||||
|
||||
log.debug(`Phase "${phaseName}" not found`);
|
||||
return specContent;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type SubprocessOptions,
|
||||
type WslCliResult,
|
||||
} from '@automaker/platform';
|
||||
import { calculateReasoningTimeout } from '@automaker/types';
|
||||
import { createLogger, isAbortError } from '@automaker/utils';
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
@@ -107,6 +108,15 @@ export interface CliDetectionResult {
|
||||
// Create logger for CLI operations
|
||||
const cliLogger = createLogger('CliProvider');
|
||||
|
||||
/**
|
||||
* Base timeout for CLI operations in milliseconds.
|
||||
* CLI tools have longer startup and processing times compared to direct API calls,
|
||||
* so we use a higher base timeout (120s) than the default provider timeout (30s).
|
||||
* This is multiplied by reasoning effort multipliers when applicable.
|
||||
* @see calculateReasoningTimeout from @automaker/types
|
||||
*/
|
||||
const CLI_BASE_TIMEOUT_MS = 120000;
|
||||
|
||||
/**
|
||||
* Abstract base class for CLI-based providers
|
||||
*
|
||||
@@ -450,6 +460,10 @@ export abstract class CliProvider extends BaseProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate dynamic timeout based on reasoning effort.
|
||||
// This addresses GitHub issue #530 where reasoning models with 'xhigh' effort would timeout.
|
||||
const timeout = calculateReasoningTimeout(options.reasoningEffort, CLI_BASE_TIMEOUT_MS);
|
||||
|
||||
// WSL strategy
|
||||
if (this.useWsl && this.wslCliPath) {
|
||||
const wslCwd = windowsToWslPath(cwd);
|
||||
@@ -473,7 +487,7 @@ export abstract class CliProvider extends BaseProvider {
|
||||
cwd, // Windows cwd for spawn
|
||||
env: filteredEnv,
|
||||
abortController: options.abortController,
|
||||
timeout: 120000, // CLI operations may take longer
|
||||
timeout,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -488,7 +502,7 @@ export abstract class CliProvider extends BaseProvider {
|
||||
cwd,
|
||||
env: filteredEnv,
|
||||
abortController: options.abortController,
|
||||
timeout: 120000,
|
||||
timeout,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -501,7 +515,7 @@ export abstract class CliProvider extends BaseProvider {
|
||||
cwd,
|
||||
env: filteredEnv,
|
||||
abortController: options.abortController,
|
||||
timeout: 120000,
|
||||
timeout,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
CODEX_MODEL_MAP,
|
||||
supportsReasoningEffort,
|
||||
validateBareModelId,
|
||||
calculateReasoningTimeout,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
type CodexApprovalPolicy,
|
||||
type CodexSandboxMode,
|
||||
type CodexAuthStatus,
|
||||
@@ -91,7 +93,14 @@ const CODEX_ITEM_TYPES = {
|
||||
const SYSTEM_PROMPT_LABEL = 'System instructions';
|
||||
const HISTORY_HEADER = 'Current request:\n';
|
||||
const TEXT_ENCODING = 'utf-8';
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
/**
|
||||
* Default timeout for Codex CLI operations in milliseconds.
|
||||
* This is the "no output" timeout - if the CLI doesn't produce any JSONL output
|
||||
* for this duration, the process is killed. For reasoning models with high
|
||||
* reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout().
|
||||
* @see calculateReasoningTimeout from @automaker/types
|
||||
*/
|
||||
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
||||
const CONTEXT_WINDOW_256K = 256000;
|
||||
const MAX_OUTPUT_32K = 32000;
|
||||
const MAX_OUTPUT_16K = 16000;
|
||||
@@ -814,13 +823,19 @@ export class CodexProvider extends BaseProvider {
|
||||
envOverrides[OPENAI_API_KEY_ENV] = executionPlan.openAiApiKey;
|
||||
}
|
||||
|
||||
// Calculate dynamic timeout based on reasoning effort.
|
||||
// Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time
|
||||
// for the model to generate reasoning tokens before producing output.
|
||||
// This fixes GitHub issue #530 where features would get stuck with reasoning models.
|
||||
const timeout = calculateReasoningTimeout(options.reasoningEffort, CODEX_CLI_TIMEOUT_MS);
|
||||
|
||||
const stream = spawnJSONLProcess({
|
||||
command: commandPath,
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
env: envOverrides,
|
||||
abortController: options.abortController,
|
||||
timeout: DEFAULT_TIMEOUT_MS,
|
||||
timeout,
|
||||
stdinData: promptText, // Pass prompt via stdin
|
||||
});
|
||||
|
||||
|
||||
@@ -6,8 +6,17 @@ import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
// Types for running generation
|
||||
export type GenerationType = 'spec_regeneration' | 'feature_generation' | 'sync';
|
||||
|
||||
interface RunningGeneration {
|
||||
isRunning: boolean;
|
||||
type: GenerationType;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
// Shared state for tracking generation status - scoped by project path
|
||||
const runningProjects = new Map<string, boolean>();
|
||||
const runningProjects = new Map<string, RunningGeneration>();
|
||||
const abortControllers = new Map<string, AbortController>();
|
||||
|
||||
/**
|
||||
@@ -17,16 +26,21 @@ export function getSpecRegenerationStatus(projectPath?: string): {
|
||||
isRunning: boolean;
|
||||
currentAbortController: AbortController | null;
|
||||
projectPath?: string;
|
||||
type?: GenerationType;
|
||||
startedAt?: string;
|
||||
} {
|
||||
if (projectPath) {
|
||||
const generation = runningProjects.get(projectPath);
|
||||
return {
|
||||
isRunning: runningProjects.get(projectPath) || false,
|
||||
isRunning: generation?.isRunning || false,
|
||||
currentAbortController: abortControllers.get(projectPath) || null,
|
||||
projectPath,
|
||||
type: generation?.type,
|
||||
startedAt: generation?.startedAt,
|
||||
};
|
||||
}
|
||||
// Fallback: check if any project is running (for backward compatibility)
|
||||
const isAnyRunning = Array.from(runningProjects.values()).some((running) => running);
|
||||
const isAnyRunning = Array.from(runningProjects.values()).some((g) => g.isRunning);
|
||||
return { isRunning: isAnyRunning, currentAbortController: null };
|
||||
}
|
||||
|
||||
@@ -46,10 +60,15 @@ export function getRunningProjectPath(): string | null {
|
||||
export function setRunningState(
|
||||
projectPath: string,
|
||||
running: boolean,
|
||||
controller: AbortController | null = null
|
||||
controller: AbortController | null = null,
|
||||
type: GenerationType = 'spec_regeneration'
|
||||
): void {
|
||||
if (running) {
|
||||
runningProjects.set(projectPath, true);
|
||||
runningProjects.set(projectPath, {
|
||||
isRunning: true,
|
||||
type,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
if (controller) {
|
||||
abortControllers.set(projectPath, controller);
|
||||
}
|
||||
@@ -59,6 +78,33 @@ export function setRunningState(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all running spec/feature generations for the running agents view
|
||||
*/
|
||||
export function getAllRunningGenerations(): Array<{
|
||||
projectPath: string;
|
||||
type: GenerationType;
|
||||
startedAt: string;
|
||||
}> {
|
||||
const results: Array<{
|
||||
projectPath: string;
|
||||
type: GenerationType;
|
||||
startedAt: string;
|
||||
}> = [];
|
||||
|
||||
for (const [projectPath, generation] of runningProjects.entries()) {
|
||||
if (generation.isRunning) {
|
||||
results.push({
|
||||
projectPath,
|
||||
type: generation.type,
|
||||
startedAt: generation.startedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to log authentication status
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
@@ -56,13 +57,45 @@ export async function generateFeaturesFromSpec(
|
||||
// Get customized prompts from settings
|
||||
const prompts = await getPromptCustomization(settingsService, '[FeatureGeneration]');
|
||||
|
||||
// Load existing features to prevent duplicates
|
||||
const featureLoader = new FeatureLoader();
|
||||
const existingFeatures = await featureLoader.getAll(projectPath);
|
||||
|
||||
logger.info(`Found ${existingFeatures.length} existing features to exclude from generation`);
|
||||
|
||||
// Build existing features context for the prompt
|
||||
let existingFeaturesContext = '';
|
||||
if (existingFeatures.length > 0) {
|
||||
const featuresList = existingFeatures
|
||||
.map(
|
||||
(f) =>
|
||||
`- "${f.title}" (ID: ${f.id}): ${f.description?.substring(0, 100) || 'No description'}`
|
||||
)
|
||||
.join('\n');
|
||||
existingFeaturesContext = `
|
||||
|
||||
## EXISTING FEATURES (DO NOT REGENERATE THESE)
|
||||
|
||||
The following ${existingFeatures.length} features already exist in the project. You MUST NOT generate features that duplicate or overlap with these:
|
||||
|
||||
${featuresList}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
- DO NOT generate any features with the same or similar titles as the existing features listed above
|
||||
- DO NOT generate features that cover the same functionality as existing features
|
||||
- ONLY generate NEW features that are not yet in the system
|
||||
- If a feature from the roadmap already exists, skip it entirely
|
||||
- Generate unique feature IDs that do not conflict with existing IDs: ${existingFeatures.map((f) => f.id).join(', ')}
|
||||
`;
|
||||
}
|
||||
|
||||
const prompt = `Based on this project specification:
|
||||
|
||||
${spec}
|
||||
|
||||
${existingFeaturesContext}
|
||||
${prompts.appSpec.generateFeaturesFromSpecPrompt}
|
||||
|
||||
Generate ${featureCount} features that build on each other logically.`;
|
||||
Generate ${featureCount} NEW features that build on each other logically. Remember: ONLY generate features that DO NOT already exist.`;
|
||||
|
||||
logger.info('========== PROMPT BEING SENT ==========');
|
||||
logger.info(`Prompt length: ${prompt.length} chars`);
|
||||
|
||||
@@ -201,19 +201,33 @@ Your entire response should be valid JSON starting with { and ending with }. No
|
||||
xmlContent = responseText.substring(xmlStart, xmlEnd + '</project_specification>'.length);
|
||||
logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`);
|
||||
} else {
|
||||
// No valid XML structure found in the response text
|
||||
// This happens when structured output was expected but not received, and the agent
|
||||
// output conversational text instead of XML (e.g., "The project directory appears to be empty...")
|
||||
// We should NOT save this conversational text as it's not a valid spec
|
||||
logger.error('❌ Response does not contain valid <project_specification> XML structure');
|
||||
logger.error(
|
||||
'This typically happens when structured output failed and the agent produced conversational text instead of XML'
|
||||
);
|
||||
throw new Error(
|
||||
'Failed to generate spec: No valid XML structure found in response. ' +
|
||||
'The response contained conversational text but no <project_specification> tags. ' +
|
||||
'Please try again.'
|
||||
);
|
||||
// No XML found, try JSON extraction
|
||||
logger.warn('⚠️ No XML tags found, attempting JSON extraction...');
|
||||
const extractedJson = extractJson<SpecOutput>(responseText, { logger });
|
||||
|
||||
if (
|
||||
extractedJson &&
|
||||
typeof extractedJson.project_name === 'string' &&
|
||||
typeof extractedJson.overview === 'string' &&
|
||||
Array.isArray(extractedJson.technology_stack) &&
|
||||
Array.isArray(extractedJson.core_capabilities) &&
|
||||
Array.isArray(extractedJson.implemented_features)
|
||||
) {
|
||||
logger.info('✅ Successfully extracted JSON from response text');
|
||||
xmlContent = specToXml(extractedJson);
|
||||
logger.info(`✅ Converted extracted JSON to XML: ${xmlContent.length} chars`);
|
||||
} else {
|
||||
// Neither XML nor valid JSON found
|
||||
logger.error('❌ Response does not contain valid XML or JSON structure');
|
||||
logger.error(
|
||||
'This typically happens when structured output failed and the agent produced conversational text instead of structured output'
|
||||
);
|
||||
throw new Error(
|
||||
'Failed to generate spec: No valid XML or JSON structure found in response. ' +
|
||||
'The response contained conversational text but no <project_specification> tags or valid JSON. ' +
|
||||
'Please try again.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createCreateHandler } from './routes/create.js';
|
||||
import { createGenerateHandler } from './routes/generate.js';
|
||||
import { createGenerateFeaturesHandler } from './routes/generate-features.js';
|
||||
import { createSyncHandler } from './routes/sync.js';
|
||||
import { createStopHandler } from './routes/stop.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
@@ -20,6 +21,7 @@ export function createSpecRegenerationRoutes(
|
||||
router.post('/create', createCreateHandler(events));
|
||||
router.post('/generate', createGenerateHandler(events, settingsService));
|
||||
router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService));
|
||||
router.post('/sync', createSyncHandler(events, settingsService));
|
||||
router.post('/stop', createStopHandler());
|
||||
router.get('/status', createStatusHandler());
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/utils';
|
||||
import { getFeaturesDir } from '@automaker/platform';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
import { getNotificationService } from '../../services/notification-service.js';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
@@ -73,10 +74,10 @@ export async function parseAndCreateFeatures(
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await secureFs.writeFile(
|
||||
path.join(featureDir, 'feature.json'),
|
||||
JSON.stringify(featureData, null, 2)
|
||||
);
|
||||
// Use atomic write with backup support for crash protection
|
||||
await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, {
|
||||
backupCount: DEFAULT_BACKUP_COUNT,
|
||||
});
|
||||
|
||||
createdFeatures.push({ id: feature.id, title: feature.title });
|
||||
}
|
||||
@@ -88,6 +89,15 @@ export async function parseAndCreateFeatures(
|
||||
message: `Spec regeneration complete! Created ${createdFeatures.length} features.`,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
|
||||
// Create notification for spec generation completion
|
||||
const notificationService = getNotificationService();
|
||||
await notificationService.createNotification({
|
||||
type: 'spec_regeneration_complete',
|
||||
title: 'Spec Generation Complete',
|
||||
message: `Created ${createdFeatures.length} features from the project specification.`,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ parseAndCreateFeatures() failed:');
|
||||
logger.error('Error:', error);
|
||||
|
||||
@@ -50,7 +50,7 @@ export function createGenerateFeaturesHandler(
|
||||
logAuthStatus('Before starting feature generation');
|
||||
|
||||
const abortController = new AbortController();
|
||||
setRunningState(projectPath, true, abortController);
|
||||
setRunningState(projectPath, true, abortController, 'feature_generation');
|
||||
logger.info('Starting background feature generation task...');
|
||||
|
||||
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)
|
||||
|
||||
76
apps/server/src/routes/app-spec/routes/sync.ts
Normal file
76
apps/server/src/routes/app-spec/routes/sync.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* POST /sync endpoint - Sync spec with codebase and features
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import {
|
||||
getSpecRegenerationStatus,
|
||||
setRunningState,
|
||||
logAuthStatus,
|
||||
logError,
|
||||
getErrorMessage,
|
||||
} from '../common.js';
|
||||
import { syncSpec } from '../sync-spec.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
|
||||
const logger = createLogger('SpecSync');
|
||||
|
||||
export function createSyncHandler(events: EventEmitter, settingsService?: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
logger.info('========== /sync endpoint called ==========');
|
||||
logger.debug('Request body:', JSON.stringify(req.body, null, 2));
|
||||
|
||||
try {
|
||||
const { projectPath } = req.body as {
|
||||
projectPath: string;
|
||||
};
|
||||
|
||||
logger.debug('projectPath:', projectPath);
|
||||
|
||||
if (!projectPath) {
|
||||
logger.error('Missing projectPath parameter');
|
||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||
if (isRunning) {
|
||||
logger.warn('Generation/sync already running for project:', projectPath);
|
||||
res.json({ success: false, error: 'Operation already running for this project' });
|
||||
return;
|
||||
}
|
||||
|
||||
logAuthStatus('Before starting spec sync');
|
||||
|
||||
const abortController = new AbortController();
|
||||
setRunningState(projectPath, true, abortController, 'sync');
|
||||
logger.info('Starting background spec sync task...');
|
||||
|
||||
syncSpec(projectPath, events, abortController, settingsService)
|
||||
.then((result) => {
|
||||
logger.info('Spec sync completed successfully');
|
||||
logger.info('Result:', JSON.stringify(result, null, 2));
|
||||
})
|
||||
.catch((error) => {
|
||||
logError(error, 'Spec sync failed with error');
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_error',
|
||||
error: getErrorMessage(error),
|
||||
projectPath,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
logger.info('Spec sync task finished (success or error)');
|
||||
setRunningState(projectPath, false, null);
|
||||
});
|
||||
|
||||
logger.info('Returning success response (sync running in background)');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Sync route handler failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
307
apps/server/src/routes/app-spec/sync-spec.ts
Normal file
307
apps/server/src/routes/app-spec/sync-spec.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Sync spec with current codebase and feature state
|
||||
*
|
||||
* Updates the spec file based on:
|
||||
* - Completed Automaker features
|
||||
* - Code analysis for tech stack and implementations
|
||||
* - Roadmap phase status updates
|
||||
*/
|
||||
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import {
|
||||
extractImplementedFeatures,
|
||||
extractTechnologyStack,
|
||||
extractRoadmapPhases,
|
||||
updateImplementedFeaturesSection,
|
||||
updateTechnologyStack,
|
||||
updateRoadmapPhaseStatus,
|
||||
type ImplementedFeature,
|
||||
type RoadmapPhase,
|
||||
} from '../../lib/xml-extractor.js';
|
||||
import { getNotificationService } from '../../services/notification-service.js';
|
||||
|
||||
const logger = createLogger('SpecSync');
|
||||
|
||||
/**
|
||||
* Result of a sync operation
|
||||
*/
|
||||
export interface SyncResult {
|
||||
techStackUpdates: {
|
||||
added: string[];
|
||||
removed: string[];
|
||||
};
|
||||
implementedFeaturesUpdates: {
|
||||
addedFromFeatures: string[];
|
||||
removed: string[];
|
||||
};
|
||||
roadmapUpdates: Array<{ phaseName: string; newStatus: string }>;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the spec with current codebase and feature state
|
||||
*/
|
||||
export async function syncSpec(
|
||||
projectPath: string,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
settingsService?: SettingsService
|
||||
): Promise<SyncResult> {
|
||||
logger.info('========== syncSpec() started ==========');
|
||||
logger.info('projectPath:', projectPath);
|
||||
|
||||
const result: SyncResult = {
|
||||
techStackUpdates: { added: [], removed: [] },
|
||||
implementedFeaturesUpdates: { addedFromFeatures: [], removed: [] },
|
||||
roadmapUpdates: [],
|
||||
summary: '',
|
||||
};
|
||||
|
||||
// Read existing spec
|
||||
const specPath = getAppSpecPath(projectPath);
|
||||
let specContent: string;
|
||||
|
||||
try {
|
||||
specContent = (await secureFs.readFile(specPath, 'utf-8')) as string;
|
||||
logger.info(`Spec loaded successfully (${specContent.length} chars)`);
|
||||
} catch (readError) {
|
||||
logger.error('Failed to read spec file:', readError);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_error',
|
||||
error: 'No project spec found. Create or regenerate spec first.',
|
||||
projectPath,
|
||||
});
|
||||
throw new Error('No project spec found');
|
||||
}
|
||||
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: '[Phase: sync] Starting spec sync...\n',
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Extract current state from spec
|
||||
const currentImplementedFeatures = extractImplementedFeatures(specContent);
|
||||
const currentTechStack = extractTechnologyStack(specContent);
|
||||
const currentRoadmapPhases = extractRoadmapPhases(specContent);
|
||||
|
||||
logger.info(`Current spec has ${currentImplementedFeatures.length} implemented features`);
|
||||
logger.info(`Current spec has ${currentTechStack.length} technologies`);
|
||||
logger.info(`Current spec has ${currentRoadmapPhases.length} roadmap phases`);
|
||||
|
||||
// Load completed Automaker features
|
||||
const featureLoader = new FeatureLoader();
|
||||
const allFeatures = await featureLoader.getAll(projectPath);
|
||||
const completedFeatures = allFeatures.filter(
|
||||
(f) => f.status === 'completed' || f.status === 'verified'
|
||||
);
|
||||
|
||||
logger.info(`Found ${completedFeatures.length} completed/verified features in Automaker`);
|
||||
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: `Found ${completedFeatures.length} completed features to sync...\n`,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Build new implemented features list from completed Automaker features
|
||||
const newImplementedFeatures: ImplementedFeature[] = [];
|
||||
const existingNames = new Set(currentImplementedFeatures.map((f) => f.name.toLowerCase()));
|
||||
|
||||
for (const feature of completedFeatures) {
|
||||
const name = feature.title || `Feature: ${feature.id}`;
|
||||
if (!existingNames.has(name.toLowerCase())) {
|
||||
newImplementedFeatures.push({
|
||||
name,
|
||||
description: feature.description || '',
|
||||
});
|
||||
result.implementedFeaturesUpdates.addedFromFeatures.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge: keep existing + add new from completed features
|
||||
const mergedFeatures = [...currentImplementedFeatures, ...newImplementedFeatures];
|
||||
|
||||
// Update spec with merged features
|
||||
if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) {
|
||||
specContent = updateImplementedFeaturesSection(specContent, mergedFeatures);
|
||||
logger.info(
|
||||
`Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} features to spec`
|
||||
);
|
||||
}
|
||||
|
||||
// Analyze codebase for tech stack updates using AI
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: 'Analyzing codebase for technology updates...\n',
|
||||
projectPath,
|
||||
});
|
||||
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
projectPath,
|
||||
settingsService,
|
||||
'[SpecSync]'
|
||||
);
|
||||
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
// Use AI to analyze tech stack
|
||||
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
|
||||
|
||||
Current known technologies: ${currentTechStack.join(', ')}
|
||||
|
||||
Look at package.json, config files, and source code to identify:
|
||||
- Frameworks (React, Vue, Express, etc.)
|
||||
- Languages (TypeScript, JavaScript, Python, etc.)
|
||||
- Build tools (Vite, Webpack, etc.)
|
||||
- Databases (PostgreSQL, MongoDB, etc.)
|
||||
- Key libraries and tools
|
||||
|
||||
Return ONLY this JSON format, no other text:
|
||||
{
|
||||
"technologies": ["Technology 1", "Technology 2", ...]
|
||||
}`;
|
||||
|
||||
try {
|
||||
const techResult = await streamingQuery({
|
||||
prompt: techAnalysisPrompt,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
maxTurns: 10,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
thinkingLevel,
|
||||
readOnly: true,
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
onText: (text) => {
|
||||
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Parse tech stack from response
|
||||
const jsonMatch = techResult.text.match(/\{[\s\S]*"technologies"[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
if (Array.isArray(parsed.technologies)) {
|
||||
const newTechStack = parsed.technologies as string[];
|
||||
|
||||
// Calculate differences
|
||||
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
|
||||
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
|
||||
|
||||
for (const tech of newTechStack) {
|
||||
if (!currentSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.added.push(tech);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tech of currentTechStack) {
|
||||
if (!newSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.removed.push(tech);
|
||||
}
|
||||
}
|
||||
|
||||
// Update spec with new tech stack if there are changes
|
||||
if (
|
||||
result.techStackUpdates.added.length > 0 ||
|
||||
result.techStackUpdates.removed.length > 0
|
||||
) {
|
||||
specContent = updateTechnologyStack(specContent, newTechStack);
|
||||
logger.info(
|
||||
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to analyze tech stack:', error);
|
||||
// Continue with other sync operations
|
||||
}
|
||||
|
||||
// Update roadmap phase statuses based on completed features
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: 'Checking roadmap phase statuses...\n',
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// For each phase, check if all its features are completed
|
||||
// This is a heuristic - we check if the phase name appears in any feature titles/descriptions
|
||||
for (const phase of currentRoadmapPhases) {
|
||||
if (phase.status === 'completed') continue; // Already completed
|
||||
|
||||
// Check if this phase should be marked as completed
|
||||
// A phase is considered complete if we have completed features that mention it
|
||||
const phaseNameLower = phase.name.toLowerCase();
|
||||
const relatedCompletedFeatures = completedFeatures.filter(
|
||||
(f) =>
|
||||
f.title?.toLowerCase().includes(phaseNameLower) ||
|
||||
f.description?.toLowerCase().includes(phaseNameLower) ||
|
||||
f.category?.toLowerCase().includes(phaseNameLower)
|
||||
);
|
||||
|
||||
// If we have related completed features and the phase is still pending/in_progress,
|
||||
// update it to in_progress or completed based on feature count
|
||||
if (relatedCompletedFeatures.length > 0 && phase.status !== 'completed') {
|
||||
const newStatus = 'in_progress';
|
||||
specContent = updateRoadmapPhaseStatus(specContent, phase.name, newStatus);
|
||||
result.roadmapUpdates.push({ phaseName: phase.name, newStatus });
|
||||
logger.info(`Updated phase "${phase.name}" to ${newStatus}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save updated spec
|
||||
await secureFs.writeFile(specPath, specContent, 'utf-8');
|
||||
logger.info('Spec saved successfully');
|
||||
|
||||
// Build summary
|
||||
const summaryParts: string[] = [];
|
||||
if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) {
|
||||
summaryParts.push(
|
||||
`Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} implemented features`
|
||||
);
|
||||
}
|
||||
if (result.techStackUpdates.added.length > 0) {
|
||||
summaryParts.push(`Added ${result.techStackUpdates.added.length} technologies`);
|
||||
}
|
||||
if (result.techStackUpdates.removed.length > 0) {
|
||||
summaryParts.push(`Removed ${result.techStackUpdates.removed.length} technologies`);
|
||||
}
|
||||
if (result.roadmapUpdates.length > 0) {
|
||||
summaryParts.push(`Updated ${result.roadmapUpdates.length} roadmap phases`);
|
||||
}
|
||||
|
||||
result.summary = summaryParts.length > 0 ? summaryParts.join(', ') : 'Spec is already up to date';
|
||||
|
||||
// Create notification
|
||||
const notificationService = getNotificationService();
|
||||
await notificationService.createNotification({
|
||||
type: 'spec_regeneration_complete',
|
||||
title: 'Spec Sync Complete',
|
||||
message: result.summary,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_complete',
|
||||
message: `Spec sync complete! ${result.summary}`,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
logger.info('========== syncSpec() completed ==========');
|
||||
logger.info('Summary:', result.summary);
|
||||
|
||||
return result;
|
||||
}
|
||||
19
apps/server/src/routes/event-history/common.ts
Normal file
19
apps/server/src/routes/event-history/common.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Common utilities for event history routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
/** Logger instance for event history operations */
|
||||
export const logger = createLogger('EventHistory');
|
||||
|
||||
/**
|
||||
* Extract user-friendly error message from error objects
|
||||
*/
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
|
||||
/**
|
||||
* Log error with automatic logger binding
|
||||
*/
|
||||
export const logError = createLogError(logger);
|
||||
68
apps/server/src/routes/event-history/index.ts
Normal file
68
apps/server/src/routes/event-history/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Event History routes - HTTP API for event history management
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Listing events with filtering
|
||||
* - Getting individual event details
|
||||
* - Deleting events
|
||||
* - Clearing all events
|
||||
* - Replaying events to test hooks
|
||||
*
|
||||
* Mounted at /api/event-history in the main server.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { EventHistoryService } from '../../services/event-history-service.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createListHandler } from './routes/list.js';
|
||||
import { createGetHandler } from './routes/get.js';
|
||||
import { createDeleteHandler } from './routes/delete.js';
|
||||
import { createClearHandler } from './routes/clear.js';
|
||||
import { createReplayHandler } from './routes/replay.js';
|
||||
|
||||
/**
|
||||
* Create event history router with all endpoints
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /list - List events with optional filtering
|
||||
* - POST /get - Get a single event by ID
|
||||
* - POST /delete - Delete an event by ID
|
||||
* - POST /clear - Clear all events for a project
|
||||
* - POST /replay - Replay an event to trigger hooks
|
||||
*
|
||||
* @param eventHistoryService - Instance of EventHistoryService
|
||||
* @param settingsService - Instance of SettingsService (for replay)
|
||||
* @returns Express Router configured with all event history endpoints
|
||||
*/
|
||||
export function createEventHistoryRoutes(
|
||||
eventHistoryService: EventHistoryService,
|
||||
settingsService: SettingsService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// List events with filtering
|
||||
router.post('/list', validatePathParams('projectPath'), createListHandler(eventHistoryService));
|
||||
|
||||
// Get single event
|
||||
router.post('/get', validatePathParams('projectPath'), createGetHandler(eventHistoryService));
|
||||
|
||||
// Delete event
|
||||
router.post(
|
||||
'/delete',
|
||||
validatePathParams('projectPath'),
|
||||
createDeleteHandler(eventHistoryService)
|
||||
);
|
||||
|
||||
// Clear all events
|
||||
router.post('/clear', validatePathParams('projectPath'), createClearHandler(eventHistoryService));
|
||||
|
||||
// Replay event
|
||||
router.post(
|
||||
'/replay',
|
||||
validatePathParams('projectPath'),
|
||||
createReplayHandler(eventHistoryService, settingsService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
33
apps/server/src/routes/event-history/routes/clear.ts
Normal file
33
apps/server/src/routes/event-history/routes/clear.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* POST /api/event-history/clear - Clear all events for a project
|
||||
*
|
||||
* Request body: { projectPath: string }
|
||||
* Response: { success: true, cleared: number }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventHistoryService } from '../../../services/event-history-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createClearHandler(eventHistoryService: EventHistoryService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cleared = await eventHistoryService.clearEvents(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
cleared,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Clear events failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
43
apps/server/src/routes/event-history/routes/delete.ts
Normal file
43
apps/server/src/routes/event-history/routes/delete.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* POST /api/event-history/delete - Delete an event by ID
|
||||
*
|
||||
* Request body: { projectPath: string, eventId: string }
|
||||
* Response: { success: true } or { success: false, error: string }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventHistoryService } from '../../../services/event-history-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createDeleteHandler(eventHistoryService: EventHistoryService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, eventId } = req.body as {
|
||||
projectPath: string;
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventId || typeof eventId !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'eventId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await eventHistoryService.deleteEvent(projectPath, eventId);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({ success: false, error: 'Event not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Delete event failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
46
apps/server/src/routes/event-history/routes/get.ts
Normal file
46
apps/server/src/routes/event-history/routes/get.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* POST /api/event-history/get - Get a single event by ID
|
||||
*
|
||||
* Request body: { projectPath: string, eventId: string }
|
||||
* Response: { success: true, event: StoredEvent } or { success: false, error: string }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventHistoryService } from '../../../services/event-history-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createGetHandler(eventHistoryService: EventHistoryService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, eventId } = req.body as {
|
||||
projectPath: string;
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventId || typeof eventId !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'eventId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const event = await eventHistoryService.getEvent(projectPath, eventId);
|
||||
|
||||
if (!event) {
|
||||
res.status(404).json({ success: false, error: 'Event not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
event,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get event failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
53
apps/server/src/routes/event-history/routes/list.ts
Normal file
53
apps/server/src/routes/event-history/routes/list.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* POST /api/event-history/list - List events for a project
|
||||
*
|
||||
* Request body: {
|
||||
* projectPath: string,
|
||||
* filter?: {
|
||||
* trigger?: EventHookTrigger,
|
||||
* featureId?: string,
|
||||
* since?: string,
|
||||
* until?: string,
|
||||
* limit?: number,
|
||||
* offset?: number
|
||||
* }
|
||||
* }
|
||||
* Response: { success: true, events: StoredEventSummary[], total: number }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventHistoryService } from '../../../services/event-history-service.js';
|
||||
import type { EventHistoryFilter } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createListHandler(eventHistoryService: EventHistoryService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, filter } = req.body as {
|
||||
projectPath: string;
|
||||
filter?: EventHistoryFilter;
|
||||
};
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const events = await eventHistoryService.getEvents(projectPath, filter);
|
||||
const total = await eventHistoryService.getEventCount(projectPath, {
|
||||
...filter,
|
||||
limit: undefined,
|
||||
offset: undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
events,
|
||||
total,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'List events failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
234
apps/server/src/routes/event-history/routes/replay.ts
Normal file
234
apps/server/src/routes/event-history/routes/replay.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* POST /api/event-history/replay - Replay an event to trigger hooks
|
||||
*
|
||||
* Request body: {
|
||||
* projectPath: string,
|
||||
* eventId: string,
|
||||
* hookIds?: string[] // Optional: specific hooks to run (if not provided, runs all enabled matching hooks)
|
||||
* }
|
||||
* Response: { success: true, result: EventReplayResult }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventHistoryService } from '../../../services/event-history-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { EventReplayResult, EventReplayHookResult, EventHook } from '@automaker/types';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getErrorMessage, logError, logger } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/** Default timeout for shell commands (30 seconds) */
|
||||
const DEFAULT_SHELL_TIMEOUT = 30000;
|
||||
|
||||
/** Default timeout for HTTP requests (10 seconds) */
|
||||
const DEFAULT_HTTP_TIMEOUT = 10000;
|
||||
|
||||
interface HookContext {
|
||||
featureId?: string;
|
||||
featureName?: string;
|
||||
projectPath?: string;
|
||||
projectName?: string;
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
timestamp: string;
|
||||
eventType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute {{variable}} placeholders in a string
|
||||
*/
|
||||
function substituteVariables(template: string, context: HookContext): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
|
||||
const value = context[variable as keyof HookContext];
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single hook and return the result
|
||||
*/
|
||||
async function executeHook(hook: EventHook, context: HookContext): Promise<EventReplayHookResult> {
|
||||
const hookName = hook.name || hook.id;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
if (hook.action.type === 'shell') {
|
||||
const command = substituteVariables(hook.action.command, context);
|
||||
const timeout = hook.action.timeout || DEFAULT_SHELL_TIMEOUT;
|
||||
|
||||
logger.info(`Replaying shell hook "${hookName}": ${command}`);
|
||||
|
||||
await execAsync(command, {
|
||||
timeout,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
|
||||
return {
|
||||
hookId: hook.id,
|
||||
hookName: hook.name,
|
||||
success: true,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
} else if (hook.action.type === 'http') {
|
||||
const url = substituteVariables(hook.action.url, context);
|
||||
const method = hook.action.method || 'POST';
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (hook.action.headers) {
|
||||
for (const [key, value] of Object.entries(hook.action.headers)) {
|
||||
headers[key] = substituteVariables(value, context);
|
||||
}
|
||||
}
|
||||
|
||||
let body: string | undefined;
|
||||
if (hook.action.body) {
|
||||
body = substituteVariables(hook.action.body, context);
|
||||
} else if (method !== 'GET') {
|
||||
body = JSON.stringify({
|
||||
eventType: context.eventType,
|
||||
timestamp: context.timestamp,
|
||||
featureId: context.featureId,
|
||||
projectPath: context.projectPath,
|
||||
projectName: context.projectName,
|
||||
error: context.error,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Replaying HTTP hook "${hookName}": ${method} ${url}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_HTTP_TIMEOUT);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: method !== 'GET' ? body : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
hookId: hook.id,
|
||||
hookName: hook.name,
|
||||
success: false,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hookId: hook.id,
|
||||
hookName: hook.name,
|
||||
success: true,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hookId: hook.id,
|
||||
hookName: hook.name,
|
||||
success: false,
|
||||
error: 'Unknown hook action type',
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.name === 'AbortError'
|
||||
? 'Request timed out'
|
||||
: error.message
|
||||
: String(error);
|
||||
|
||||
return {
|
||||
hookId: hook.id,
|
||||
hookName: hook.name,
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createReplayHandler(
|
||||
eventHistoryService: EventHistoryService,
|
||||
settingsService: SettingsService
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, eventId, hookIds } = req.body as {
|
||||
projectPath: string;
|
||||
eventId: string;
|
||||
hookIds?: string[];
|
||||
};
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventId || typeof eventId !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'eventId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the event
|
||||
const event = await eventHistoryService.getEvent(projectPath, eventId);
|
||||
if (!event) {
|
||||
res.status(404).json({ success: false, error: 'Event not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get hooks from settings
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
let hooks = settings.eventHooks || [];
|
||||
|
||||
// Filter to matching trigger and enabled hooks
|
||||
hooks = hooks.filter((h) => h.enabled && h.trigger === event.trigger);
|
||||
|
||||
// If specific hook IDs requested, filter to those
|
||||
if (hookIds && hookIds.length > 0) {
|
||||
hooks = hooks.filter((h) => hookIds.includes(h.id));
|
||||
}
|
||||
|
||||
// Build context for variable substitution
|
||||
const context: HookContext = {
|
||||
featureId: event.featureId,
|
||||
featureName: event.featureName,
|
||||
projectPath: event.projectPath,
|
||||
projectName: event.projectName,
|
||||
error: event.error,
|
||||
errorType: event.errorType,
|
||||
timestamp: event.timestamp,
|
||||
eventType: event.trigger,
|
||||
};
|
||||
|
||||
// Execute all hooks in parallel
|
||||
const hookResults = await Promise.all(hooks.map((hook) => executeHook(hook, context)));
|
||||
|
||||
const result: EventReplayResult = {
|
||||
eventId,
|
||||
hooksTriggered: hooks.length,
|
||||
hookResults,
|
||||
};
|
||||
|
||||
logger.info(`Replayed event ${eventId}: ${hooks.length} hooks triggered`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Replay event failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Router } from 'express';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createListHandler } from './routes/list.js';
|
||||
import { createGetHandler } from './routes/get.js';
|
||||
@@ -18,13 +19,18 @@ import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||
|
||||
export function createFeaturesRoutes(
|
||||
featureLoader: FeatureLoader,
|
||||
settingsService?: SettingsService
|
||||
settingsService?: SettingsService,
|
||||
events?: EventEmitter
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader));
|
||||
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
|
||||
router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader));
|
||||
router.post(
|
||||
'/create',
|
||||
validatePathParams('projectPath'),
|
||||
createCreateHandler(featureLoader, events)
|
||||
);
|
||||
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
|
||||
router.post(
|
||||
'/bulk-update',
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createCreateHandler(featureLoader: FeatureLoader) {
|
||||
export function createCreateHandler(featureLoader: FeatureLoader, events?: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, feature } = req.body as {
|
||||
@@ -23,7 +24,30 @@ export function createCreateHandler(featureLoader: FeatureLoader) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate title if title is provided
|
||||
if (feature.title && feature.title.trim()) {
|
||||
const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title);
|
||||
if (duplicate) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: `A feature with title "${feature.title}" already exists`,
|
||||
duplicateFeatureId: duplicate.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const created = await featureLoader.create(projectPath, feature);
|
||||
|
||||
// Emit feature_created event for hooks
|
||||
if (events) {
|
||||
events.emit('feature:created', {
|
||||
featureId: created.id,
|
||||
featureName: created.name,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, feature: created });
|
||||
} catch (error) {
|
||||
logError(error, 'Create feature failed');
|
||||
|
||||
@@ -4,8 +4,14 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import type { Feature, FeatureStatus } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('features/update');
|
||||
|
||||
// Statuses that should trigger syncing to app_spec.txt
|
||||
const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed'];
|
||||
|
||||
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -34,6 +40,28 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate title if title is being updated
|
||||
if (updates.title && updates.title.trim()) {
|
||||
const duplicate = await featureLoader.findDuplicateTitle(
|
||||
projectPath,
|
||||
updates.title,
|
||||
featureId // Exclude the current feature from duplicate check
|
||||
);
|
||||
if (duplicate) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: `A feature with title "${updates.title}" already exists`,
|
||||
duplicateFeatureId: duplicate.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current feature to detect status changes
|
||||
const currentFeature = await featureLoader.get(projectPath, featureId);
|
||||
const previousStatus = currentFeature?.status as FeatureStatus | undefined;
|
||||
const newStatus = updates.status as FeatureStatus | undefined;
|
||||
|
||||
const updated = await featureLoader.update(
|
||||
projectPath,
|
||||
featureId,
|
||||
@@ -42,6 +70,22 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
enhancementMode,
|
||||
preEnhancementDescription
|
||||
);
|
||||
|
||||
// Trigger sync to app_spec.txt when status changes to verified or completed
|
||||
if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) {
|
||||
try {
|
||||
const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated);
|
||||
if (synced) {
|
||||
logger.info(
|
||||
`Synced feature "${updated.title || updated.id}" to app_spec.txt on status change to ${newStatus}`
|
||||
);
|
||||
}
|
||||
} catch (syncError) {
|
||||
// Log the sync error but don't fail the update operation
|
||||
logger.error(`Failed to sync feature to app_spec.txt:`, syncError);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, feature: updated });
|
||||
} catch (error) {
|
||||
logError(error, 'Update feature failed');
|
||||
|
||||
21
apps/server/src/routes/notifications/common.ts
Normal file
21
apps/server/src/routes/notifications/common.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Common utilities for notification routes
|
||||
*
|
||||
* Provides logger and error handling utilities shared across all notification endpoints.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
/** Logger instance for notification-related operations */
|
||||
export const logger = createLogger('Notifications');
|
||||
|
||||
/**
|
||||
* Extract user-friendly error message from error objects
|
||||
*/
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
|
||||
/**
|
||||
* Log error with automatic logger binding
|
||||
*/
|
||||
export const logError = createLogError(logger);
|
||||
62
apps/server/src/routes/notifications/index.ts
Normal file
62
apps/server/src/routes/notifications/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Notifications routes - HTTP API for project-level notifications
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Listing notifications
|
||||
* - Getting unread count
|
||||
* - Marking notifications as read
|
||||
* - Dismissing notifications
|
||||
*
|
||||
* All endpoints use handler factories that receive the NotificationService instance.
|
||||
* Mounted at /api/notifications in the main server.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { NotificationService } from '../../services/notification-service.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createListHandler } from './routes/list.js';
|
||||
import { createUnreadCountHandler } from './routes/unread-count.js';
|
||||
import { createMarkReadHandler } from './routes/mark-read.js';
|
||||
import { createDismissHandler } from './routes/dismiss.js';
|
||||
|
||||
/**
|
||||
* Create notifications router with all endpoints
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /list - List all notifications for a project
|
||||
* - POST /unread-count - Get unread notification count
|
||||
* - POST /mark-read - Mark notification(s) as read
|
||||
* - POST /dismiss - Dismiss notification(s)
|
||||
*
|
||||
* @param notificationService - Instance of NotificationService
|
||||
* @returns Express Router configured with all notification endpoints
|
||||
*/
|
||||
export function createNotificationsRoutes(notificationService: NotificationService): Router {
|
||||
const router = Router();
|
||||
|
||||
// List notifications
|
||||
router.post('/list', validatePathParams('projectPath'), createListHandler(notificationService));
|
||||
|
||||
// Get unread count
|
||||
router.post(
|
||||
'/unread-count',
|
||||
validatePathParams('projectPath'),
|
||||
createUnreadCountHandler(notificationService)
|
||||
);
|
||||
|
||||
// Mark as read (single or all)
|
||||
router.post(
|
||||
'/mark-read',
|
||||
validatePathParams('projectPath'),
|
||||
createMarkReadHandler(notificationService)
|
||||
);
|
||||
|
||||
// Dismiss (single or all)
|
||||
router.post(
|
||||
'/dismiss',
|
||||
validatePathParams('projectPath'),
|
||||
createDismissHandler(notificationService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
53
apps/server/src/routes/notifications/routes/dismiss.ts
Normal file
53
apps/server/src/routes/notifications/routes/dismiss.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* POST /api/notifications/dismiss - Dismiss notification(s)
|
||||
*
|
||||
* Request body: { projectPath: string, notificationId?: string }
|
||||
* - If notificationId provided: dismisses that notification
|
||||
* - If notificationId not provided: dismisses all notifications
|
||||
*
|
||||
* Response: { success: true, dismissed: boolean | count: number }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/notifications/dismiss
|
||||
*
|
||||
* @param notificationService - Instance of NotificationService
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createDismissHandler(notificationService: NotificationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, notificationId } = req.body;
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// If notificationId provided, dismiss single notification
|
||||
if (notificationId) {
|
||||
const dismissed = await notificationService.dismissNotification(
|
||||
projectPath,
|
||||
notificationId
|
||||
);
|
||||
if (!dismissed) {
|
||||
res.status(404).json({ success: false, error: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, dismissed: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise dismiss all
|
||||
const count = await notificationService.dismissAll(projectPath);
|
||||
res.json({ success: true, count });
|
||||
} catch (error) {
|
||||
logError(error, 'Dismiss failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
39
apps/server/src/routes/notifications/routes/list.ts
Normal file
39
apps/server/src/routes/notifications/routes/list.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* POST /api/notifications/list - List all notifications for a project
|
||||
*
|
||||
* Request body: { projectPath: string }
|
||||
* Response: { success: true, notifications: Notification[] }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/notifications/list
|
||||
*
|
||||
* @param notificationService - Instance of NotificationService
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createListHandler(notificationService: NotificationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body;
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const notifications = await notificationService.getNotifications(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
notifications,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'List notifications failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
50
apps/server/src/routes/notifications/routes/mark-read.ts
Normal file
50
apps/server/src/routes/notifications/routes/mark-read.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* POST /api/notifications/mark-read - Mark notification(s) as read
|
||||
*
|
||||
* Request body: { projectPath: string, notificationId?: string }
|
||||
* - If notificationId provided: marks that notification as read
|
||||
* - If notificationId not provided: marks all notifications as read
|
||||
*
|
||||
* Response: { success: true, count?: number, notification?: Notification }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/notifications/mark-read
|
||||
*
|
||||
* @param notificationService - Instance of NotificationService
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createMarkReadHandler(notificationService: NotificationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, notificationId } = req.body;
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// If notificationId provided, mark single notification
|
||||
if (notificationId) {
|
||||
const notification = await notificationService.markAsRead(projectPath, notificationId);
|
||||
if (!notification) {
|
||||
res.status(404).json({ success: false, error: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, notification });
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise mark all as read
|
||||
const count = await notificationService.markAllAsRead(projectPath);
|
||||
res.json({ success: true, count });
|
||||
} catch (error) {
|
||||
logError(error, 'Mark read failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
39
apps/server/src/routes/notifications/routes/unread-count.ts
Normal file
39
apps/server/src/routes/notifications/routes/unread-count.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* POST /api/notifications/unread-count - Get unread notification count
|
||||
*
|
||||
* Request body: { projectPath: string }
|
||||
* Response: { success: true, count: number }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/notifications/unread-count
|
||||
*
|
||||
* @param notificationService - Instance of NotificationService
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createUnreadCountHandler(notificationService: NotificationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body;
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await notificationService.getUnreadCount(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get unread count failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { getBacklogPlanStatus, getRunningDetails } from '../../backlog-plan/common.js';
|
||||
import { getAllRunningGenerations } from '../../app-spec/common.js';
|
||||
import path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
@@ -26,6 +27,36 @@ export function createIndexHandler(autoModeService: AutoModeService) {
|
||||
});
|
||||
}
|
||||
|
||||
// Add spec/feature generation tasks
|
||||
const specGenerations = getAllRunningGenerations();
|
||||
for (const generation of specGenerations) {
|
||||
let title: string;
|
||||
let description: string;
|
||||
|
||||
switch (generation.type) {
|
||||
case 'feature_generation':
|
||||
title = 'Generating features from spec';
|
||||
description = 'Creating features from the project specification';
|
||||
break;
|
||||
case 'sync':
|
||||
title = 'Syncing spec with code';
|
||||
description = 'Updating spec from codebase and completed features';
|
||||
break;
|
||||
default:
|
||||
title = 'Regenerating spec';
|
||||
description = 'Analyzing project and generating specification';
|
||||
}
|
||||
|
||||
runningAgents.push({
|
||||
featureId: `spec-generation:${generation.projectPath}`,
|
||||
projectPath: generation.projectPath,
|
||||
projectName: path.basename(generation.projectPath),
|
||||
isAutoMode: false,
|
||||
title,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
runningAgents,
|
||||
|
||||
@@ -34,6 +34,7 @@ export function createGetDevServerLogsHandler() {
|
||||
result: {
|
||||
worktreePath: result.result.worktreePath,
|
||||
port: result.result.port,
|
||||
url: result.result.url,
|
||||
logs: result.result.logs,
|
||||
startedAt: result.result.startedAt,
|
||||
},
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
appendLearning,
|
||||
recordMemoryUsage,
|
||||
createLogger,
|
||||
atomicWriteJson,
|
||||
readJsonWithRecovery,
|
||||
logRecoveryWarning,
|
||||
DEFAULT_BACKUP_COUNT,
|
||||
} from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
@@ -60,6 +64,7 @@ import {
|
||||
getMCPServersFromSettings,
|
||||
getPromptCustomization,
|
||||
} from '../lib/settings-helpers.js';
|
||||
import { getNotificationService } from './notification-service.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -386,6 +391,7 @@ export class AutoModeService {
|
||||
this.emitAutoModeEvent('auto_mode_error', {
|
||||
error: errorInfo.message,
|
||||
errorType: errorInfo.type,
|
||||
projectPath,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1414,13 +1420,13 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
allImagePaths.push(...allPaths);
|
||||
}
|
||||
|
||||
// Save updated feature.json with new images
|
||||
// Save updated feature.json with new images (atomic write with backup)
|
||||
if (copiedImagePaths.length > 0 && feature) {
|
||||
const featureDirForSave = getFeatureDir(projectPath, featureId);
|
||||
const featurePath = path.join(featureDirForSave, 'feature.json');
|
||||
|
||||
try {
|
||||
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2));
|
||||
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save feature.json:`, error);
|
||||
}
|
||||
@@ -1547,6 +1553,7 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
message: allPassed
|
||||
? 'All verification checks passed'
|
||||
: `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
return allPassed;
|
||||
@@ -1620,6 +1627,7 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
featureId,
|
||||
passes: true,
|
||||
message: `Changes committed: ${hash.trim().substring(0, 8)}`,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
return hash.trim();
|
||||
@@ -2088,8 +2096,20 @@ Format your response as a structured markdown document.`;
|
||||
const featurePath = path.join(featureDir, 'feature.json');
|
||||
|
||||
try {
|
||||
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string;
|
||||
const feature = JSON.parse(data);
|
||||
// Use recovery-enabled read for corrupted file handling
|
||||
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
||||
maxBackups: DEFAULT_BACKUP_COUNT,
|
||||
autoRestore: true,
|
||||
});
|
||||
|
||||
logRecoveryWarning(result, `Feature ${featureId}`, logger);
|
||||
|
||||
const feature = result.data;
|
||||
if (!feature) {
|
||||
logger.warn(`Feature ${featureId} not found or could not be recovered`);
|
||||
return;
|
||||
}
|
||||
|
||||
feature.status = status;
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed)
|
||||
@@ -2100,9 +2120,41 @@ Format your response as a structured markdown document.`;
|
||||
// Clear the timestamp when moving to other statuses
|
||||
feature.justFinishedAt = undefined;
|
||||
}
|
||||
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2));
|
||||
} catch {
|
||||
// Feature file may not exist
|
||||
|
||||
// Use atomic write with backup support
|
||||
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||
|
||||
// Create notifications for important status changes
|
||||
const notificationService = getNotificationService();
|
||||
if (status === 'waiting_approval') {
|
||||
await notificationService.createNotification({
|
||||
type: 'feature_waiting_approval',
|
||||
title: 'Feature Ready for Review',
|
||||
message: `"${feature.name || featureId}" is ready for your review and approval.`,
|
||||
featureId,
|
||||
projectPath,
|
||||
});
|
||||
} else if (status === 'verified') {
|
||||
await notificationService.createNotification({
|
||||
type: 'feature_verified',
|
||||
title: 'Feature Verified',
|
||||
message: `"${feature.name || featureId}" has been verified and is complete.`,
|
||||
featureId,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
// Sync completed/verified features to app_spec.txt
|
||||
if (status === 'verified' || status === 'completed') {
|
||||
try {
|
||||
await this.featureLoader.syncFeatureToAppSpec(projectPath, feature);
|
||||
} catch (syncError) {
|
||||
// Log but don't fail the status update if sync fails
|
||||
logger.warn(`Failed to sync feature ${featureId} to app_spec.txt:`, syncError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update feature status for ${featureId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2114,11 +2166,24 @@ Format your response as a structured markdown document.`;
|
||||
featureId: string,
|
||||
updates: Partial<PlanSpec>
|
||||
): Promise<void> {
|
||||
const featurePath = path.join(projectPath, '.automaker', 'features', featureId, 'feature.json');
|
||||
// Use getFeatureDir helper for consistent path resolution
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const featurePath = path.join(featureDir, 'feature.json');
|
||||
|
||||
try {
|
||||
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string;
|
||||
const feature = JSON.parse(data);
|
||||
// Use recovery-enabled read for corrupted file handling
|
||||
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
||||
maxBackups: DEFAULT_BACKUP_COUNT,
|
||||
autoRestore: true,
|
||||
});
|
||||
|
||||
logRecoveryWarning(result, `Feature ${featureId}`, logger);
|
||||
|
||||
const feature = result.data;
|
||||
if (!feature) {
|
||||
logger.warn(`Feature ${featureId} not found or could not be recovered`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize planSpec if it doesn't exist
|
||||
if (!feature.planSpec) {
|
||||
@@ -2138,7 +2203,9 @@ Format your response as a structured markdown document.`;
|
||||
}
|
||||
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2));
|
||||
|
||||
// Use atomic write with backup support
|
||||
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update planSpec for ${featureId}:`, error);
|
||||
}
|
||||
@@ -2155,25 +2222,34 @@ Format your response as a structured markdown document.`;
|
||||
const allFeatures: Feature[] = [];
|
||||
const pendingFeatures: Feature[] = [];
|
||||
|
||||
// Load all features (for dependency checking)
|
||||
// Load all features (for dependency checking) with recovery support
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const featurePath = path.join(featuresDir, entry.name, 'feature.json');
|
||||
try {
|
||||
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string;
|
||||
const feature = JSON.parse(data);
|
||||
allFeatures.push(feature);
|
||||
|
||||
// Track pending features separately
|
||||
if (
|
||||
feature.status === 'pending' ||
|
||||
feature.status === 'ready' ||
|
||||
feature.status === 'backlog'
|
||||
) {
|
||||
pendingFeatures.push(feature);
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid features
|
||||
// Use recovery-enabled read for corrupted file handling
|
||||
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
||||
maxBackups: DEFAULT_BACKUP_COUNT,
|
||||
autoRestore: true,
|
||||
});
|
||||
|
||||
logRecoveryWarning(result, `Feature ${entry.name}`, logger);
|
||||
|
||||
const feature = result.data;
|
||||
if (!feature) {
|
||||
// Skip features that couldn't be loaded or recovered
|
||||
continue;
|
||||
}
|
||||
|
||||
allFeatures.push(feature);
|
||||
|
||||
// Track pending features separately
|
||||
if (
|
||||
feature.status === 'pending' ||
|
||||
feature.status === 'ready' ||
|
||||
feature.status === 'backlog'
|
||||
) {
|
||||
pendingFeatures.push(feature);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3405,31 +3481,39 @@ After generating the revised spec, output:
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const featurePath = path.join(featuresDir, entry.name, 'feature.json');
|
||||
try {
|
||||
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string;
|
||||
const feature = JSON.parse(data) as Feature;
|
||||
|
||||
// Check if feature was interrupted (in_progress or pipeline_*)
|
||||
if (
|
||||
feature.status === 'in_progress' ||
|
||||
(feature.status && feature.status.startsWith('pipeline_'))
|
||||
) {
|
||||
// Verify it has existing context (agent-output.md)
|
||||
const featureDir = getFeatureDir(projectPath, feature.id);
|
||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||
try {
|
||||
await secureFs.access(contextPath);
|
||||
interruptedFeatures.push(feature);
|
||||
logger.info(
|
||||
`Found interrupted feature: ${feature.id} (${feature.title}) - status: ${feature.status}`
|
||||
);
|
||||
} catch {
|
||||
// No context file, skip this feature - it will be restarted fresh
|
||||
logger.info(`Interrupted feature ${feature.id} has no context, will restart fresh`);
|
||||
}
|
||||
// Use recovery-enabled read for corrupted file handling
|
||||
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
||||
maxBackups: DEFAULT_BACKUP_COUNT,
|
||||
autoRestore: true,
|
||||
});
|
||||
|
||||
logRecoveryWarning(result, `Feature ${entry.name}`, logger);
|
||||
|
||||
const feature = result.data;
|
||||
if (!feature) {
|
||||
// Skip features that couldn't be loaded or recovered
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if feature was interrupted (in_progress or pipeline_*)
|
||||
if (
|
||||
feature.status === 'in_progress' ||
|
||||
(feature.status && feature.status.startsWith('pipeline_'))
|
||||
) {
|
||||
// Verify it has existing context (agent-output.md)
|
||||
const featureDir = getFeatureDir(projectPath, feature.id);
|
||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||
try {
|
||||
await secureFs.access(contextPath);
|
||||
interruptedFeatures.push(feature);
|
||||
logger.info(
|
||||
`Found interrupted feature: ${feature.id} (${feature.title}) - status: ${feature.status}`
|
||||
);
|
||||
} catch {
|
||||
// No context file, skip this feature - it will be restarted fresh
|
||||
logger.info(`Interrupted feature ${feature.id} has no context, will restart fresh`);
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid features
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,29 @@ export class ClaudeUsageService {
|
||||
private timeout = 30000; // 30 second timeout
|
||||
private isWindows = os.platform() === 'win32';
|
||||
private isLinux = os.platform() === 'linux';
|
||||
// On Windows, ConPTY requires AttachConsole which fails in Electron/service mode
|
||||
// Detect Electron by checking for electron-specific env vars or process properties
|
||||
// When in Electron, always use winpty to avoid ConPTY's AttachConsole errors
|
||||
private isElectron =
|
||||
!!(process.versions && (process.versions as Record<string, string>).electron) ||
|
||||
!!process.env.ELECTRON_RUN_AS_NODE;
|
||||
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
|
||||
|
||||
/**
|
||||
* Kill a PTY process with platform-specific handling.
|
||||
* Windows doesn't support Unix signals like SIGTERM, so we call kill() without arguments.
|
||||
* On Unix-like systems (macOS, Linux), we can specify the signal.
|
||||
*
|
||||
* @param ptyProcess - The PTY process to kill
|
||||
* @param signal - The signal to send on Unix-like systems (default: 'SIGTERM')
|
||||
*/
|
||||
private killPtyProcess(ptyProcess: pty.IPty, signal: string = 'SIGTERM'): void {
|
||||
if (this.isWindows) {
|
||||
ptyProcess.kill();
|
||||
} else {
|
||||
ptyProcess.kill(signal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Claude CLI is available on the system
|
||||
@@ -181,37 +204,94 @@ export class ClaudeUsageService {
|
||||
? ['/c', 'claude', '--add-dir', workingDirectory]
|
||||
: ['-c', `claude --add-dir "${workingDirectory}"`];
|
||||
|
||||
// Using 'any' for ptyProcess because node-pty types don't include 'killed' property
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let ptyProcess: any = null;
|
||||
|
||||
// Build PTY spawn options
|
||||
const ptyOptions: pty.IPtyForkOptions = {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
} as Record<string, string>,
|
||||
};
|
||||
|
||||
// On Windows, always use winpty instead of ConPTY
|
||||
// ConPTY requires AttachConsole which fails in many contexts:
|
||||
// - Electron apps without a console
|
||||
// - VS Code integrated terminal
|
||||
// - Spawned from other applications
|
||||
// The error happens in a subprocess so we can't catch it - must proactively disable
|
||||
if (this.isWindows) {
|
||||
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
|
||||
logger.info(
|
||||
'[executeClaudeUsageCommandPty] Using winpty on Windows (ConPTY disabled for compatibility)'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
ptyProcess = pty.spawn(shell, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
} as Record<string, string>,
|
||||
});
|
||||
ptyProcess = pty.spawn(shell, args, ptyOptions);
|
||||
} catch (spawnError) {
|
||||
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
||||
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
||||
|
||||
// Return a user-friendly error instead of crashing
|
||||
reject(
|
||||
new Error(
|
||||
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
|
||||
)
|
||||
);
|
||||
return;
|
||||
// Check for Windows ConPTY-specific errors
|
||||
if (this.isWindows && errorMessage.includes('AttachConsole failed')) {
|
||||
// ConPTY failed - try winpty fallback
|
||||
if (!this.useConptyFallback) {
|
||||
logger.warn(
|
||||
'[executeClaudeUsageCommandPty] ConPTY AttachConsole failed, retrying with winpty fallback'
|
||||
);
|
||||
this.useConptyFallback = true;
|
||||
|
||||
try {
|
||||
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
|
||||
ptyProcess = pty.spawn(shell, args, ptyOptions);
|
||||
logger.info(
|
||||
'[executeClaudeUsageCommandPty] Successfully spawned with winpty fallback'
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
const fallbackMessage =
|
||||
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
||||
logger.error(
|
||||
'[executeClaudeUsageCommandPty] Winpty fallback also failed:',
|
||||
fallbackMessage
|
||||
);
|
||||
reject(
|
||||
new Error(
|
||||
`Windows PTY unavailable: Both ConPTY and winpty failed. This typically happens when running in Electron without a console. ConPTY error: ${errorMessage}. Winpty error: ${fallbackMessage}`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
logger.error('[executeClaudeUsageCommandPty] Winpty fallback failed:', errorMessage);
|
||||
reject(
|
||||
new Error(
|
||||
`Windows PTY unavailable: ${errorMessage}. The application is running without console access (common in Electron). Try running from a terminal window.`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
||||
reject(
|
||||
new Error(
|
||||
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
if (ptyProcess && !ptyProcess.killed) {
|
||||
ptyProcess.kill();
|
||||
this.killPtyProcess(ptyProcess);
|
||||
}
|
||||
// Don't fail if we have data - return it instead
|
||||
if (output.includes('Current session')) {
|
||||
@@ -244,16 +324,23 @@ export class ClaudeUsageService {
|
||||
const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
||||
|
||||
// Check for specific authentication/permission errors
|
||||
if (
|
||||
cleanOutput.includes('OAuth token does not meet scope requirement') ||
|
||||
cleanOutput.includes('permission_error') ||
|
||||
cleanOutput.includes('token_expired') ||
|
||||
cleanOutput.includes('authentication_error')
|
||||
) {
|
||||
// Must be very specific to avoid false positives from garbled terminal encoding
|
||||
// Removed permission_error check as it was causing false positives with winpty encoding
|
||||
const authChecks = {
|
||||
oauth: cleanOutput.includes('OAuth token does not meet scope requirement'),
|
||||
tokenExpired: cleanOutput.includes('token_expired'),
|
||||
// Only match if it looks like a JSON API error response
|
||||
authError:
|
||||
cleanOutput.includes('"type":"authentication_error"') ||
|
||||
cleanOutput.includes('"type": "authentication_error"'),
|
||||
};
|
||||
const hasAuthError = authChecks.oauth || authChecks.tokenExpired || authChecks.authError;
|
||||
|
||||
if (hasAuthError) {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
if (ptyProcess && !ptyProcess.killed) {
|
||||
ptyProcess.kill();
|
||||
this.killPtyProcess(ptyProcess);
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
@@ -265,11 +352,16 @@ export class ClaudeUsageService {
|
||||
}
|
||||
|
||||
// Check if we've seen the usage data (look for "Current session" or the TUI Usage header)
|
||||
if (
|
||||
!hasSeenUsageData &&
|
||||
(cleanOutput.includes('Current session') ||
|
||||
(cleanOutput.includes('Usage') && cleanOutput.includes('% left')))
|
||||
) {
|
||||
// Also check for percentage patterns that appear in usage output
|
||||
const hasUsageIndicators =
|
||||
cleanOutput.includes('Current session') ||
|
||||
(cleanOutput.includes('Usage') && cleanOutput.includes('% left')) ||
|
||||
// Additional patterns for winpty - look for percentage patterns
|
||||
/\d+%\s*(left|used|remaining)/i.test(cleanOutput) ||
|
||||
cleanOutput.includes('Resets in') ||
|
||||
cleanOutput.includes('Current week');
|
||||
|
||||
if (!hasSeenUsageData && hasUsageIndicators) {
|
||||
hasSeenUsageData = true;
|
||||
// Wait for full output, then send escape to exit
|
||||
setTimeout(() => {
|
||||
@@ -277,9 +369,10 @@ export class ClaudeUsageService {
|
||||
ptyProcess.write('\x1b'); // Send escape key
|
||||
|
||||
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
|
||||
// Windows doesn't support signals, so killPtyProcess handles platform differences
|
||||
setTimeout(() => {
|
||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||
ptyProcess.kill('SIGTERM');
|
||||
this.killPtyProcess(ptyProcess);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
@@ -307,10 +400,18 @@ export class ClaudeUsageService {
|
||||
}
|
||||
|
||||
// Detect REPL prompt and send /usage command
|
||||
if (
|
||||
!hasSentCommand &&
|
||||
(cleanOutput.includes('❯') || cleanOutput.includes('? for shortcuts'))
|
||||
) {
|
||||
// On Windows with winpty, Unicode prompt char ❯ gets garbled, so also check for ASCII indicators
|
||||
const isReplReady =
|
||||
cleanOutput.includes('❯') ||
|
||||
cleanOutput.includes('? for shortcuts') ||
|
||||
// Fallback for winpty garbled encoding - detect CLI welcome screen elements
|
||||
(cleanOutput.includes('Welcome back') && cleanOutput.includes('Claude')) ||
|
||||
(cleanOutput.includes('Tips for getting started') && cleanOutput.includes('Claude')) ||
|
||||
// Detect model indicator which appears when REPL is ready
|
||||
(cleanOutput.includes('Opus') && cleanOutput.includes('Claude API')) ||
|
||||
(cleanOutput.includes('Sonnet') && cleanOutput.includes('Claude API'));
|
||||
|
||||
if (!hasSentCommand && isReplReady) {
|
||||
hasSentCommand = true;
|
||||
// Wait for REPL to fully settle
|
||||
setTimeout(() => {
|
||||
@@ -347,11 +448,9 @@ export class ClaudeUsageService {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
|
||||
if (
|
||||
output.includes('token_expired') ||
|
||||
output.includes('authentication_error') ||
|
||||
output.includes('permission_error')
|
||||
) {
|
||||
// Check for auth errors - must be specific to avoid false positives
|
||||
// Removed permission_error check as it was causing false positives with winpty encoding
|
||||
if (output.includes('token_expired') || output.includes('"type":"authentication_error"')) {
|
||||
reject(new Error("Authentication required - please run 'claude login'"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -379,10 +379,11 @@ class DevServerService {
|
||||
|
||||
// Create server info early so we can reference it in handlers
|
||||
// We'll add it to runningServers after verifying the process started successfully
|
||||
const hostname = process.env.HOSTNAME || 'localhost';
|
||||
const serverInfo: DevServerInfo = {
|
||||
worktreePath,
|
||||
port,
|
||||
url: `http://localhost:${port}`,
|
||||
url: `http://${hostname}:${port}`,
|
||||
process: devProcess,
|
||||
startedAt: new Date(),
|
||||
scrollbackBuffer: '',
|
||||
@@ -474,7 +475,7 @@ class DevServerService {
|
||||
result: {
|
||||
worktreePath,
|
||||
port,
|
||||
url: `http://localhost:${port}`,
|
||||
url: `http://${hostname}:${port}`,
|
||||
message: `Dev server started on port ${port}`,
|
||||
},
|
||||
};
|
||||
@@ -594,6 +595,7 @@ class DevServerService {
|
||||
result?: {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
url: string;
|
||||
logs: string;
|
||||
startedAt: string;
|
||||
};
|
||||
@@ -613,6 +615,7 @@ class DevServerService {
|
||||
result: {
|
||||
worktreePath: server.worktreePath,
|
||||
port: server.port,
|
||||
url: server.url,
|
||||
logs: server.scrollbackBuffer,
|
||||
startedAt: server.startedAt.toISOString(),
|
||||
},
|
||||
|
||||
338
apps/server/src/services/event-history-service.ts
Normal file
338
apps/server/src/services/event-history-service.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Event History Service - Stores and retrieves event records for debugging and replay
|
||||
*
|
||||
* Provides persistent storage for events in {projectPath}/.automaker/events/
|
||||
* Each event is stored as a separate JSON file with an index for quick listing.
|
||||
*
|
||||
* Features:
|
||||
* - Store events when they occur
|
||||
* - List and filter historical events
|
||||
* - Replay events to test hook configurations
|
||||
* - Delete old events to manage disk space
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import {
|
||||
getEventHistoryDir,
|
||||
getEventHistoryIndexPath,
|
||||
getEventPath,
|
||||
ensureEventHistoryDir,
|
||||
} from '@automaker/platform';
|
||||
import type {
|
||||
StoredEvent,
|
||||
StoredEventIndex,
|
||||
StoredEventSummary,
|
||||
EventHistoryFilter,
|
||||
EventHookTrigger,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_EVENT_HISTORY_INDEX } from '@automaker/types';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const logger = createLogger('EventHistoryService');
|
||||
|
||||
/** Maximum events to keep in the index (oldest are pruned) */
|
||||
const MAX_EVENTS_IN_INDEX = 1000;
|
||||
|
||||
/**
|
||||
* Atomic file write - write to temp file then rename
|
||||
*/
|
||||
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`;
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
|
||||
try {
|
||||
await secureFs.writeFile(tempPath, content, 'utf-8');
|
||||
await secureFs.rename(tempPath, filePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
await secureFs.unlink(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read JSON file with fallback to default
|
||||
*/
|
||||
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
|
||||
try {
|
||||
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
|
||||
return JSON.parse(content) as T;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return defaultValue;
|
||||
}
|
||||
logger.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for storing a new event
|
||||
*/
|
||||
export interface StoreEventInput {
|
||||
trigger: EventHookTrigger;
|
||||
projectPath: string;
|
||||
featureId?: string;
|
||||
featureName?: string;
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
passes?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* EventHistoryService - Manages persistent storage of events
|
||||
*/
|
||||
export class EventHistoryService {
|
||||
/**
|
||||
* Store a new event to history
|
||||
*
|
||||
* @param input - Event data to store
|
||||
* @returns Promise resolving to the stored event
|
||||
*/
|
||||
async storeEvent(input: StoreEventInput): Promise<StoredEvent> {
|
||||
const { projectPath, trigger, featureId, featureName, error, errorType, passes, metadata } =
|
||||
input;
|
||||
|
||||
// Ensure events directory exists
|
||||
await ensureEventHistoryDir(projectPath);
|
||||
|
||||
const eventId = `evt-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const timestamp = new Date().toISOString();
|
||||
const projectName = this.extractProjectName(projectPath);
|
||||
|
||||
const event: StoredEvent = {
|
||||
id: eventId,
|
||||
trigger,
|
||||
timestamp,
|
||||
projectPath,
|
||||
projectName,
|
||||
featureId,
|
||||
featureName,
|
||||
error,
|
||||
errorType,
|
||||
passes,
|
||||
metadata,
|
||||
};
|
||||
|
||||
// Write the full event to its own file
|
||||
const eventPath = getEventPath(projectPath, eventId);
|
||||
await atomicWriteJson(eventPath, event);
|
||||
|
||||
// Update the index
|
||||
await this.addToIndex(projectPath, event);
|
||||
|
||||
logger.info(`Stored event ${eventId} (${trigger}) for project ${projectName}`);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all events for a project with optional filtering
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param filter - Optional filter criteria
|
||||
* @returns Promise resolving to array of event summaries
|
||||
*/
|
||||
async getEvents(projectPath: string, filter?: EventHistoryFilter): Promise<StoredEventSummary[]> {
|
||||
const indexPath = getEventHistoryIndexPath(projectPath);
|
||||
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
|
||||
|
||||
let events = [...index.events];
|
||||
|
||||
// Apply filters
|
||||
if (filter) {
|
||||
if (filter.trigger) {
|
||||
events = events.filter((e) => e.trigger === filter.trigger);
|
||||
}
|
||||
if (filter.featureId) {
|
||||
events = events.filter((e) => e.featureId === filter.featureId);
|
||||
}
|
||||
if (filter.since) {
|
||||
const sinceDate = new Date(filter.since).getTime();
|
||||
events = events.filter((e) => new Date(e.timestamp).getTime() >= sinceDate);
|
||||
}
|
||||
if (filter.until) {
|
||||
const untilDate = new Date(filter.until).getTime();
|
||||
events = events.filter((e) => new Date(e.timestamp).getTime() <= untilDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
|
||||
// Apply pagination
|
||||
if (filter?.offset) {
|
||||
events = events.slice(filter.offset);
|
||||
}
|
||||
if (filter?.limit) {
|
||||
events = events.slice(0, filter.limit);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single event by ID
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param eventId - Event identifier
|
||||
* @returns Promise resolving to the full event or null if not found
|
||||
*/
|
||||
async getEvent(projectPath: string, eventId: string): Promise<StoredEvent | null> {
|
||||
const eventPath = getEventPath(projectPath, eventId);
|
||||
try {
|
||||
const content = (await secureFs.readFile(eventPath, 'utf-8')) as string;
|
||||
return JSON.parse(content) as StoredEvent;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
logger.error(`Error reading event ${eventId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an event by ID
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param eventId - Event identifier
|
||||
* @returns Promise resolving to true if deleted
|
||||
*/
|
||||
async deleteEvent(projectPath: string, eventId: string): Promise<boolean> {
|
||||
// Remove from index
|
||||
const indexPath = getEventHistoryIndexPath(projectPath);
|
||||
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
|
||||
|
||||
const initialLength = index.events.length;
|
||||
index.events = index.events.filter((e) => e.id !== eventId);
|
||||
|
||||
if (index.events.length === initialLength) {
|
||||
return false; // Event not found in index
|
||||
}
|
||||
|
||||
await atomicWriteJson(indexPath, index);
|
||||
|
||||
// Delete the event file
|
||||
const eventPath = getEventPath(projectPath, eventId);
|
||||
try {
|
||||
await secureFs.unlink(eventPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.error(`Error deleting event file ${eventId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Deleted event ${eventId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all events for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Promise resolving to number of events cleared
|
||||
*/
|
||||
async clearEvents(projectPath: string): Promise<number> {
|
||||
const indexPath = getEventHistoryIndexPath(projectPath);
|
||||
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
|
||||
|
||||
const count = index.events.length;
|
||||
|
||||
// Delete all event files
|
||||
for (const event of index.events) {
|
||||
const eventPath = getEventPath(projectPath, event.id);
|
||||
try {
|
||||
await secureFs.unlink(eventPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.error(`Error deleting event file ${event.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the index
|
||||
await atomicWriteJson(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
|
||||
|
||||
logger.info(`Cleared ${count} events for project`);
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event count for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param filter - Optional filter criteria
|
||||
* @returns Promise resolving to event count
|
||||
*/
|
||||
async getEventCount(projectPath: string, filter?: EventHistoryFilter): Promise<number> {
|
||||
const events = await this.getEvents(projectPath, {
|
||||
...filter,
|
||||
limit: undefined,
|
||||
offset: undefined,
|
||||
});
|
||||
return events.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event to the index (internal)
|
||||
*/
|
||||
private async addToIndex(projectPath: string, event: StoredEvent): Promise<void> {
|
||||
const indexPath = getEventHistoryIndexPath(projectPath);
|
||||
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
|
||||
|
||||
const summary: StoredEventSummary = {
|
||||
id: event.id,
|
||||
trigger: event.trigger,
|
||||
timestamp: event.timestamp,
|
||||
featureName: event.featureName,
|
||||
featureId: event.featureId,
|
||||
};
|
||||
|
||||
// Add to beginning (newest first)
|
||||
index.events.unshift(summary);
|
||||
|
||||
// Prune old events if over limit
|
||||
if (index.events.length > MAX_EVENTS_IN_INDEX) {
|
||||
const removed = index.events.splice(MAX_EVENTS_IN_INDEX);
|
||||
// Delete the pruned event files
|
||||
for (const oldEvent of removed) {
|
||||
const eventPath = getEventPath(projectPath, oldEvent.id);
|
||||
try {
|
||||
await secureFs.unlink(eventPath);
|
||||
} catch {
|
||||
// Ignore deletion errors for pruned events
|
||||
}
|
||||
}
|
||||
logger.info(`Pruned ${removed.length} old events from history`);
|
||||
}
|
||||
|
||||
await atomicWriteJson(indexPath, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract project name from path
|
||||
*/
|
||||
private extractProjectName(projectPath: string): string {
|
||||
const parts = projectPath.split(/[/\\]/);
|
||||
return parts[parts.length - 1] || projectPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let eventHistoryServiceInstance: EventHistoryService | null = null;
|
||||
|
||||
/**
|
||||
* Get the singleton event history service instance
|
||||
*/
|
||||
export function getEventHistoryService(): EventHistoryService {
|
||||
if (!eventHistoryServiceInstance) {
|
||||
eventHistoryServiceInstance = new EventHistoryService();
|
||||
}
|
||||
return eventHistoryServiceInstance;
|
||||
}
|
||||
@@ -5,7 +5,10 @@
|
||||
* - Shell commands: Executed with configurable timeout
|
||||
* - HTTP webhooks: POST/GET/PUT/PATCH requests with variable substitution
|
||||
*
|
||||
* Also stores events to history for debugging and replay.
|
||||
*
|
||||
* Supported events:
|
||||
* - feature_created: A new feature was created
|
||||
* - feature_success: Feature completed successfully
|
||||
* - feature_error: Feature failed with an error
|
||||
* - auto_mode_complete: Auto mode finished all features (idle state)
|
||||
@@ -17,6 +20,7 @@ import { promisify } from 'util';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import type { EventHistoryService } from './event-history-service.js';
|
||||
import type {
|
||||
EventHook,
|
||||
EventHookTrigger,
|
||||
@@ -60,27 +64,45 @@ interface AutoModeEventPayload {
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature created event payload structure
|
||||
*/
|
||||
interface FeatureCreatedPayload {
|
||||
featureId: string;
|
||||
featureName?: string;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event Hook Service
|
||||
*
|
||||
* Manages execution of user-configured event hooks in response to system events.
|
||||
* Also stores events to history for debugging and replay.
|
||||
*/
|
||||
export class EventHookService {
|
||||
private emitter: EventEmitter | null = null;
|
||||
private settingsService: SettingsService | null = null;
|
||||
private eventHistoryService: EventHistoryService | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the service with event emitter and settings service
|
||||
* Initialize the service with event emitter, settings service, and event history service
|
||||
*/
|
||||
initialize(emitter: EventEmitter, settingsService: SettingsService): void {
|
||||
initialize(
|
||||
emitter: EventEmitter,
|
||||
settingsService: SettingsService,
|
||||
eventHistoryService?: EventHistoryService
|
||||
): void {
|
||||
this.emitter = emitter;
|
||||
this.settingsService = settingsService;
|
||||
this.eventHistoryService = eventHistoryService || null;
|
||||
|
||||
// Subscribe to auto-mode events
|
||||
// Subscribe to events
|
||||
this.unsubscribe = emitter.subscribe((type, payload) => {
|
||||
if (type === 'auto-mode:event') {
|
||||
this.handleAutoModeEvent(payload as AutoModeEventPayload);
|
||||
} else if (type === 'feature:created') {
|
||||
this.handleFeatureCreatedEvent(payload as FeatureCreatedPayload);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -97,6 +119,7 @@ export class EventHookService {
|
||||
}
|
||||
this.emitter = null;
|
||||
this.settingsService = null;
|
||||
this.eventHistoryService = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,17 +160,51 @@ export class EventHookService {
|
||||
eventType: trigger,
|
||||
};
|
||||
|
||||
// Execute matching hooks
|
||||
await this.executeHooksForTrigger(trigger, context);
|
||||
// Execute matching hooks (pass passes for feature completion events)
|
||||
await this.executeHooksForTrigger(trigger, context, { passes: payload.passes });
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all enabled hooks matching the given trigger
|
||||
* Handle feature:created events and trigger matching hooks
|
||||
*/
|
||||
private async handleFeatureCreatedEvent(payload: FeatureCreatedPayload): Promise<void> {
|
||||
const context: HookContext = {
|
||||
featureId: payload.featureId,
|
||||
featureName: payload.featureName,
|
||||
projectPath: payload.projectPath,
|
||||
projectName: this.extractProjectName(payload.projectPath),
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'feature_created',
|
||||
};
|
||||
|
||||
await this.executeHooksForTrigger('feature_created', context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all enabled hooks matching the given trigger and store event to history
|
||||
*/
|
||||
private async executeHooksForTrigger(
|
||||
trigger: EventHookTrigger,
|
||||
context: HookContext
|
||||
context: HookContext,
|
||||
additionalData?: { passes?: boolean }
|
||||
): Promise<void> {
|
||||
// Store event to history (even if no hooks match)
|
||||
if (this.eventHistoryService && context.projectPath) {
|
||||
try {
|
||||
await this.eventHistoryService.storeEvent({
|
||||
trigger,
|
||||
projectPath: context.projectPath,
|
||||
featureId: context.featureId,
|
||||
featureName: context.featureName,
|
||||
error: context.error,
|
||||
errorType: context.errorType,
|
||||
passes: additionalData?.passes,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to store event to history:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.settingsService) {
|
||||
logger.warn('Settings service not available');
|
||||
return;
|
||||
|
||||
@@ -5,14 +5,22 @@
|
||||
|
||||
import path from 'path';
|
||||
import type { Feature, DescriptionHistoryEntry } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import {
|
||||
createLogger,
|
||||
atomicWriteJson,
|
||||
readJsonWithRecovery,
|
||||
logRecoveryWarning,
|
||||
DEFAULT_BACKUP_COUNT,
|
||||
} from '@automaker/utils';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import {
|
||||
getFeaturesDir,
|
||||
getFeatureDir,
|
||||
getFeatureImagesDir,
|
||||
getAppSpecPath,
|
||||
ensureAutomakerDir,
|
||||
} from '@automaker/platform';
|
||||
import { addImplementedFeature, type ImplementedFeature } from '../lib/xml-extractor.js';
|
||||
|
||||
const logger = createLogger('FeatureLoader');
|
||||
|
||||
@@ -192,31 +200,31 @@ export class FeatureLoader {
|
||||
})) as any[];
|
||||
const featureDirs = entries.filter((entry) => entry.isDirectory());
|
||||
|
||||
// Load all features concurrently (secureFs has built-in concurrency limiting)
|
||||
// Load all features concurrently with automatic recovery from backups
|
||||
const featurePromises = featureDirs.map(async (dir) => {
|
||||
const featureId = dir.name;
|
||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
||||
|
||||
try {
|
||||
const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string;
|
||||
const feature = JSON.parse(content);
|
||||
// Use recovery-enabled read to handle corrupted files
|
||||
const result = await readJsonWithRecovery<Feature | null>(featureJsonPath, null, {
|
||||
maxBackups: DEFAULT_BACKUP_COUNT,
|
||||
autoRestore: true,
|
||||
});
|
||||
|
||||
if (!feature.id) {
|
||||
logger.warn(`Feature ${featureId} missing required 'id' field, skipping`);
|
||||
return null;
|
||||
}
|
||||
logRecoveryWarning(result, `Feature ${featureId}`, logger);
|
||||
|
||||
return feature as Feature;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
} else if (error instanceof SyntaxError) {
|
||||
logger.warn(`Failed to parse feature.json for ${featureId}: ${error.message}`);
|
||||
} else {
|
||||
logger.error(`Failed to load feature ${featureId}:`, (error as Error).message);
|
||||
}
|
||||
const feature = result.data;
|
||||
|
||||
if (!feature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!feature.id) {
|
||||
logger.warn(`Feature ${featureId} missing required 'id' field, skipping`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return feature;
|
||||
});
|
||||
|
||||
const results = await Promise.all(featurePromises);
|
||||
@@ -236,21 +244,85 @@ export class FeatureLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a title for comparison (case-insensitive, trimmed)
|
||||
*/
|
||||
private normalizeTitle(title: string): string {
|
||||
return title.toLowerCase().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a feature by its title (case-insensitive match)
|
||||
* @param projectPath - Path to the project
|
||||
* @param title - Title to search for
|
||||
* @returns The matching feature or null if not found
|
||||
*/
|
||||
async findByTitle(projectPath: string, title: string): Promise<Feature | null> {
|
||||
if (!title || !title.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedTitle = this.normalizeTitle(title);
|
||||
const features = await this.getAll(projectPath);
|
||||
|
||||
for (const feature of features) {
|
||||
if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) {
|
||||
return feature;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a title already exists on another feature (for duplicate detection)
|
||||
* @param projectPath - Path to the project
|
||||
* @param title - Title to check
|
||||
* @param excludeFeatureId - Optional feature ID to exclude from the check (for updates)
|
||||
* @returns The duplicate feature if found, null otherwise
|
||||
*/
|
||||
async findDuplicateTitle(
|
||||
projectPath: string,
|
||||
title: string,
|
||||
excludeFeatureId?: string
|
||||
): Promise<Feature | null> {
|
||||
if (!title || !title.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedTitle = this.normalizeTitle(title);
|
||||
const features = await this.getAll(projectPath);
|
||||
|
||||
for (const feature of features) {
|
||||
// Skip the feature being updated (if provided)
|
||||
if (excludeFeatureId && feature.id === excludeFeatureId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) {
|
||||
return feature;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single feature by ID
|
||||
* Uses automatic recovery from backups if the main file is corrupted
|
||||
*/
|
||||
async get(projectPath: string, featureId: string): Promise<Feature | null> {
|
||||
try {
|
||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
||||
const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string;
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
logger.error(`Failed to get feature ${featureId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
||||
|
||||
// Use recovery-enabled read to handle corrupted files
|
||||
const result = await readJsonWithRecovery<Feature | null>(featureJsonPath, null, {
|
||||
maxBackups: DEFAULT_BACKUP_COUNT,
|
||||
autoRestore: true,
|
||||
});
|
||||
|
||||
logRecoveryWarning(result, `Feature ${featureId}`, logger);
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,8 +366,8 @@ export class FeatureLoader {
|
||||
descriptionHistory: initialHistory,
|
||||
};
|
||||
|
||||
// Write feature.json
|
||||
await secureFs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2), 'utf-8');
|
||||
// Write feature.json atomically with backup support
|
||||
await atomicWriteJson(featureJsonPath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||
|
||||
logger.info(`Created feature ${featureId}`);
|
||||
return feature;
|
||||
@@ -379,9 +451,9 @@ export class FeatureLoader {
|
||||
descriptionHistory: updatedHistory,
|
||||
};
|
||||
|
||||
// Write back to file
|
||||
// Write back to file atomically with backup support
|
||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
||||
await secureFs.writeFile(featureJsonPath, JSON.stringify(updatedFeature, null, 2), 'utf-8');
|
||||
await atomicWriteJson(featureJsonPath, updatedFeature, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||
|
||||
logger.info(`Updated feature ${featureId}`);
|
||||
return updatedFeature;
|
||||
@@ -460,4 +532,64 @@ export class FeatureLoader {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a completed feature to the app_spec.txt implemented_features section
|
||||
*
|
||||
* When a feature is completed, this method adds it to the implemented_features
|
||||
* section of the project's app_spec.txt file. This keeps the spec in sync
|
||||
* with the actual state of the codebase.
|
||||
*
|
||||
* @param projectPath - Path to the project
|
||||
* @param feature - The feature to sync (must have title or description)
|
||||
* @param fileLocations - Optional array of file paths where the feature was implemented
|
||||
* @returns True if the spec was updated, false if no spec exists or feature was skipped
|
||||
*/
|
||||
async syncFeatureToAppSpec(
|
||||
projectPath: string,
|
||||
feature: Feature,
|
||||
fileLocations?: string[]
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const appSpecPath = getAppSpecPath(projectPath);
|
||||
|
||||
// Read the current app_spec.txt
|
||||
let specContent: string;
|
||||
try {
|
||||
specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.info(`No app_spec.txt found for project, skipping sync for feature ${feature.id}`);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Build the implemented feature entry
|
||||
const featureName = feature.title || `Feature: ${feature.id}`;
|
||||
const implementedFeature: ImplementedFeature = {
|
||||
name: featureName,
|
||||
description: feature.description,
|
||||
...(fileLocations && fileLocations.length > 0 ? { file_locations: fileLocations } : {}),
|
||||
};
|
||||
|
||||
// Add the feature to the implemented_features section
|
||||
const updatedSpecContent = addImplementedFeature(specContent, implementedFeature);
|
||||
|
||||
// Check if the content actually changed (feature might already exist)
|
||||
if (updatedSpecContent === specContent) {
|
||||
logger.info(`Feature "${featureName}" already exists in app_spec.txt, skipping`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write the updated spec back to the file
|
||||
await secureFs.writeFile(appSpecPath, updatedSpecContent, 'utf-8');
|
||||
|
||||
logger.info(`Synced feature "${featureName}" to app_spec.txt`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to sync feature ${feature.id} to app_spec.txt:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
280
apps/server/src/services/notification-service.ts
Normal file
280
apps/server/src/services/notification-service.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Notification Service - Handles reading/writing notifications to JSON files
|
||||
*
|
||||
* Provides persistent storage for project-level notifications in
|
||||
* {projectPath}/.automaker/notifications.json
|
||||
*
|
||||
* Notifications alert users when:
|
||||
* - Features reach specific statuses (waiting_approval, verified)
|
||||
* - Long-running operations complete (spec generation)
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import { getNotificationsPath, ensureAutomakerDir } from '@automaker/platform';
|
||||
import type { Notification, NotificationsFile, NotificationType } from '@automaker/types';
|
||||
import { DEFAULT_NOTIFICATIONS_FILE } from '@automaker/types';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const logger = createLogger('NotificationService');
|
||||
|
||||
/**
|
||||
* Atomic file write - write to temp file then rename
|
||||
*/
|
||||
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`;
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
|
||||
try {
|
||||
await secureFs.writeFile(tempPath, content, 'utf-8');
|
||||
await secureFs.rename(tempPath, filePath);
|
||||
} catch (error) {
|
||||
// Clean up temp file if it exists
|
||||
try {
|
||||
await secureFs.unlink(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read JSON file with fallback to default
|
||||
*/
|
||||
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
|
||||
try {
|
||||
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
|
||||
return JSON.parse(content) as T;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return defaultValue;
|
||||
}
|
||||
logger.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for creating a new notification
|
||||
*/
|
||||
export interface CreateNotificationInput {
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
featureId?: string;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* NotificationService - Manages persistent storage of notifications
|
||||
*
|
||||
* Handles reading and writing notifications to JSON files with atomic operations
|
||||
* for reliability. Each project has its own notifications.json file.
|
||||
*/
|
||||
export class NotificationService {
|
||||
private events: EventEmitter | null = null;
|
||||
|
||||
/**
|
||||
* Set the event emitter for broadcasting notification events
|
||||
*/
|
||||
setEventEmitter(events: EventEmitter): void {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all notifications for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Promise resolving to array of notifications
|
||||
*/
|
||||
async getNotifications(projectPath: string): Promise<Notification[]> {
|
||||
const notificationsPath = getNotificationsPath(projectPath);
|
||||
const file = await readJsonFile<NotificationsFile>(
|
||||
notificationsPath,
|
||||
DEFAULT_NOTIFICATIONS_FILE
|
||||
);
|
||||
// Filter out dismissed notifications and sort by date (newest first)
|
||||
return file.notifications
|
||||
.filter((n) => !n.dismissed)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread notification count for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Promise resolving to unread count
|
||||
*/
|
||||
async getUnreadCount(projectPath: string): Promise<number> {
|
||||
const notifications = await this.getNotifications(projectPath);
|
||||
return notifications.filter((n) => !n.read).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new notification
|
||||
*
|
||||
* @param input - Notification creation input
|
||||
* @returns Promise resolving to the created notification
|
||||
*/
|
||||
async createNotification(input: CreateNotificationInput): Promise<Notification> {
|
||||
const { projectPath, type, title, message, featureId } = input;
|
||||
|
||||
// Ensure automaker directory exists
|
||||
await ensureAutomakerDir(projectPath);
|
||||
|
||||
const notificationsPath = getNotificationsPath(projectPath);
|
||||
const file = await readJsonFile<NotificationsFile>(
|
||||
notificationsPath,
|
||||
DEFAULT_NOTIFICATIONS_FILE
|
||||
);
|
||||
|
||||
const notification: Notification = {
|
||||
id: randomUUID(),
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
createdAt: new Date().toISOString(),
|
||||
read: false,
|
||||
dismissed: false,
|
||||
featureId,
|
||||
projectPath,
|
||||
};
|
||||
|
||||
file.notifications.push(notification);
|
||||
await atomicWriteJson(notificationsPath, file);
|
||||
|
||||
logger.info(`Created notification: ${title} for project ${projectPath}`);
|
||||
|
||||
// Emit event for real-time updates
|
||||
if (this.events) {
|
||||
this.events.emit('notification:created', notification);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a notification as read
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param notificationId - ID of the notification to mark as read
|
||||
* @returns Promise resolving to the updated notification or null if not found
|
||||
*/
|
||||
async markAsRead(projectPath: string, notificationId: string): Promise<Notification | null> {
|
||||
const notificationsPath = getNotificationsPath(projectPath);
|
||||
const file = await readJsonFile<NotificationsFile>(
|
||||
notificationsPath,
|
||||
DEFAULT_NOTIFICATIONS_FILE
|
||||
);
|
||||
|
||||
const notification = file.notifications.find((n) => n.id === notificationId);
|
||||
if (!notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
notification.read = true;
|
||||
await atomicWriteJson(notificationsPath, file);
|
||||
|
||||
logger.info(`Marked notification ${notificationId} as read`);
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Promise resolving to number of notifications marked as read
|
||||
*/
|
||||
async markAllAsRead(projectPath: string): Promise<number> {
|
||||
const notificationsPath = getNotificationsPath(projectPath);
|
||||
const file = await readJsonFile<NotificationsFile>(
|
||||
notificationsPath,
|
||||
DEFAULT_NOTIFICATIONS_FILE
|
||||
);
|
||||
|
||||
let count = 0;
|
||||
for (const notification of file.notifications) {
|
||||
if (!notification.read && !notification.dismissed) {
|
||||
notification.read = true;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
await atomicWriteJson(notificationsPath, file);
|
||||
logger.info(`Marked ${count} notifications as read`);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a notification
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param notificationId - ID of the notification to dismiss
|
||||
* @returns Promise resolving to true if notification was dismissed
|
||||
*/
|
||||
async dismissNotification(projectPath: string, notificationId: string): Promise<boolean> {
|
||||
const notificationsPath = getNotificationsPath(projectPath);
|
||||
const file = await readJsonFile<NotificationsFile>(
|
||||
notificationsPath,
|
||||
DEFAULT_NOTIFICATIONS_FILE
|
||||
);
|
||||
|
||||
const notification = file.notifications.find((n) => n.id === notificationId);
|
||||
if (!notification) {
|
||||
return false;
|
||||
}
|
||||
|
||||
notification.dismissed = true;
|
||||
await atomicWriteJson(notificationsPath, file);
|
||||
|
||||
logger.info(`Dismissed notification ${notificationId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss all notifications for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Promise resolving to number of notifications dismissed
|
||||
*/
|
||||
async dismissAll(projectPath: string): Promise<number> {
|
||||
const notificationsPath = getNotificationsPath(projectPath);
|
||||
const file = await readJsonFile<NotificationsFile>(
|
||||
notificationsPath,
|
||||
DEFAULT_NOTIFICATIONS_FILE
|
||||
);
|
||||
|
||||
let count = 0;
|
||||
for (const notification of file.notifications) {
|
||||
if (!notification.dismissed) {
|
||||
notification.dismissed = true;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
await atomicWriteJson(notificationsPath, file);
|
||||
logger.info(`Dismissed ${count} notifications`);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let notificationServiceInstance: NotificationService | null = null;
|
||||
|
||||
/**
|
||||
* Get the singleton notification service instance
|
||||
*/
|
||||
export function getNotificationService(): NotificationService {
|
||||
if (!notificationServiceInstance) {
|
||||
notificationServiceInstance = new NotificationService();
|
||||
}
|
||||
return notificationServiceInstance;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Per-project settings ({projectPath}/.automaker/settings.json)
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/utils';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
|
||||
import {
|
||||
@@ -42,28 +42,8 @@ import {
|
||||
const logger = createLogger('SettingsService');
|
||||
|
||||
/**
|
||||
* Atomic file write - write to temp file then rename
|
||||
*/
|
||||
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`;
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
|
||||
try {
|
||||
await secureFs.writeFile(tempPath, content, 'utf-8');
|
||||
await secureFs.rename(tempPath, filePath);
|
||||
} catch (error) {
|
||||
// Clean up temp file if it exists
|
||||
try {
|
||||
await secureFs.unlink(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read JSON file with fallback to default
|
||||
* Wrapper for readJsonFile from utils that uses the local secureFs
|
||||
* to maintain compatibility with the server's secure file system
|
||||
*/
|
||||
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
|
||||
try {
|
||||
@@ -90,6 +70,13 @@ async function fileExists(filePath: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write settings atomically with backup support
|
||||
*/
|
||||
async function writeSettingsJson(filePath: string, data: unknown): Promise<void> {
|
||||
await atomicWriteJson(filePath, data, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||
}
|
||||
|
||||
/**
|
||||
* SettingsService - Manages persistent storage of user settings and credentials
|
||||
*
|
||||
@@ -180,7 +167,7 @@ export class SettingsService {
|
||||
if (needsSave) {
|
||||
try {
|
||||
await ensureDataDir(this.dataDir);
|
||||
await atomicWriteJson(settingsPath, result);
|
||||
await writeSettingsJson(settingsPath, result);
|
||||
logger.info('Settings migration complete');
|
||||
} catch (error) {
|
||||
logger.error('Failed to save migrated settings:', error);
|
||||
@@ -340,7 +327,7 @@ export class SettingsService {
|
||||
};
|
||||
}
|
||||
|
||||
await atomicWriteJson(settingsPath, updated);
|
||||
await writeSettingsJson(settingsPath, updated);
|
||||
logger.info('Global settings updated');
|
||||
|
||||
return updated;
|
||||
@@ -414,7 +401,7 @@ export class SettingsService {
|
||||
};
|
||||
}
|
||||
|
||||
await atomicWriteJson(credentialsPath, updated);
|
||||
await writeSettingsJson(credentialsPath, updated);
|
||||
logger.info('Credentials updated');
|
||||
|
||||
return updated;
|
||||
@@ -525,7 +512,7 @@ export class SettingsService {
|
||||
};
|
||||
}
|
||||
|
||||
await atomicWriteJson(settingsPath, updated);
|
||||
await writeSettingsJson(settingsPath, updated);
|
||||
logger.info(`Project settings updated for ${projectPath}`);
|
||||
|
||||
return updated;
|
||||
|
||||
@@ -70,6 +70,29 @@ export class TerminalService extends EventEmitter {
|
||||
private sessions: Map<string, TerminalSession> = new Map();
|
||||
private dataCallbacks: Set<DataCallback> = new Set();
|
||||
private exitCallbacks: Set<ExitCallback> = new Set();
|
||||
private isWindows = os.platform() === 'win32';
|
||||
// On Windows, ConPTY requires AttachConsole which fails in Electron/service mode
|
||||
// Detect Electron by checking for electron-specific env vars or process properties
|
||||
private isElectron =
|
||||
!!(process.versions && (process.versions as Record<string, string>).electron) ||
|
||||
!!process.env.ELECTRON_RUN_AS_NODE;
|
||||
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
|
||||
|
||||
/**
|
||||
* Kill a PTY process with platform-specific handling.
|
||||
* Windows doesn't support Unix signals like SIGTERM/SIGKILL, so we call kill() without arguments.
|
||||
* On Unix-like systems (macOS, Linux), we can specify the signal.
|
||||
*
|
||||
* @param ptyProcess - The PTY process to kill
|
||||
* @param signal - The signal to send on Unix-like systems (default: 'SIGTERM')
|
||||
*/
|
||||
private killPtyProcess(ptyProcess: pty.IPty, signal: string = 'SIGTERM'): void {
|
||||
if (this.isWindows) {
|
||||
ptyProcess.kill();
|
||||
} else {
|
||||
ptyProcess.kill(signal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the best shell for the current platform
|
||||
@@ -322,13 +345,60 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||
|
||||
const ptyProcess = pty.spawn(shell, shellArgs, {
|
||||
// Build PTY spawn options
|
||||
const ptyOptions: pty.IPtyForkOptions = {
|
||||
name: 'xterm-256color',
|
||||
cols: options.cols || 80,
|
||||
rows: options.rows || 24,
|
||||
cwd,
|
||||
env,
|
||||
});
|
||||
};
|
||||
|
||||
// On Windows, always use winpty instead of ConPTY
|
||||
// ConPTY requires AttachConsole which fails in many contexts:
|
||||
// - Electron apps without a console
|
||||
// - VS Code integrated terminal
|
||||
// - Spawned from other applications
|
||||
// The error happens in a subprocess so we can't catch it - must proactively disable
|
||||
if (this.isWindows) {
|
||||
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
|
||||
logger.info(
|
||||
`[createSession] Using winpty for session ${id} (ConPTY disabled for compatibility)`
|
||||
);
|
||||
}
|
||||
|
||||
let ptyProcess: pty.IPty;
|
||||
try {
|
||||
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
|
||||
} catch (spawnError) {
|
||||
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
||||
|
||||
// Check for Windows ConPTY-specific errors
|
||||
if (this.isWindows && errorMessage.includes('AttachConsole failed')) {
|
||||
// ConPTY failed - try winpty fallback
|
||||
if (!this.useConptyFallback) {
|
||||
logger.warn(`[createSession] ConPTY AttachConsole failed, retrying with winpty fallback`);
|
||||
this.useConptyFallback = true;
|
||||
|
||||
try {
|
||||
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
|
||||
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
|
||||
logger.info(`[createSession] Successfully spawned session ${id} with winpty fallback`);
|
||||
} catch (fallbackError) {
|
||||
const fallbackMessage =
|
||||
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
||||
logger.error(`[createSession] Winpty fallback also failed:`, fallbackMessage);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
logger.error(`[createSession] PTY spawn failed (winpty):`, errorMessage);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
logger.error(`[createSession] PTY spawn failed:`, errorMessage);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const session: TerminalSession = {
|
||||
id,
|
||||
@@ -392,7 +462,11 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
// Handle exit
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
logger.info(`Session ${id} exited with code ${exitCode}`);
|
||||
const exitMessage =
|
||||
exitCode === undefined || exitCode === null
|
||||
? 'Session terminated'
|
||||
: `Session exited with code ${exitCode}`;
|
||||
logger.info(`${exitMessage} (${id})`);
|
||||
this.sessions.delete(id);
|
||||
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
|
||||
this.emit('exit', id, exitCode);
|
||||
@@ -477,8 +551,9 @@ export class TerminalService extends EventEmitter {
|
||||
}
|
||||
|
||||
// First try graceful SIGTERM to allow process cleanup
|
||||
// On Windows, killPtyProcess calls kill() without signal since Windows doesn't support Unix signals
|
||||
logger.info(`Session ${sessionId} sending SIGTERM`);
|
||||
session.pty.kill('SIGTERM');
|
||||
this.killPtyProcess(session.pty, 'SIGTERM');
|
||||
|
||||
// Schedule SIGKILL fallback if process doesn't exit gracefully
|
||||
// The onExit handler will remove session from map when it actually exits
|
||||
@@ -486,7 +561,7 @@ export class TerminalService extends EventEmitter {
|
||||
if (this.sessions.has(sessionId)) {
|
||||
logger.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
|
||||
try {
|
||||
session.pty.kill('SIGKILL');
|
||||
this.killPtyProcess(session.pty, 'SIGKILL');
|
||||
} catch {
|
||||
// Process may have already exited
|
||||
}
|
||||
@@ -588,7 +663,8 @@ export class TerminalService extends EventEmitter {
|
||||
if (session.flushTimeout) {
|
||||
clearTimeout(session.flushTimeout);
|
||||
}
|
||||
session.pty.kill();
|
||||
// Use platform-specific kill to ensure proper termination on Windows
|
||||
this.killPtyProcess(session.pty);
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
|
||||
1027
apps/server/tests/unit/lib/xml-extractor.test.ts
Normal file
1027
apps/server/tests/unit/lib/xml-extractor.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,11 @@ import {
|
||||
getCodexConfigDir,
|
||||
getCodexAuthIndicators,
|
||||
} from '@automaker/platform';
|
||||
import {
|
||||
calculateReasoningTimeout,
|
||||
REASONING_TIMEOUT_MULTIPLIERS,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
} from '@automaker/types';
|
||||
|
||||
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
||||
const originalOpenAIKey = process.env[OPENAI_API_KEY_ENV];
|
||||
@@ -289,5 +294,121 @@ describe('codex-provider.ts', () => {
|
||||
expect(codexRunMock).not.toHaveBeenCalled();
|
||||
expect(spawnJSONLProcess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes extended timeout for high reasoning effort', async () => {
|
||||
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
||||
|
||||
await collectAsyncGenerator(
|
||||
provider.executeQuery({
|
||||
prompt: 'Complex reasoning task',
|
||||
model: 'gpt-5.1-codex-max',
|
||||
cwd: '/tmp',
|
||||
reasoningEffort: 'high',
|
||||
})
|
||||
);
|
||||
|
||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||
// High reasoning effort should have 3x the default timeout (90000ms)
|
||||
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high);
|
||||
});
|
||||
|
||||
it('passes extended timeout for xhigh reasoning effort', async () => {
|
||||
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
||||
|
||||
await collectAsyncGenerator(
|
||||
provider.executeQuery({
|
||||
prompt: 'Very complex reasoning task',
|
||||
model: 'gpt-5.1-codex-max',
|
||||
cwd: '/tmp',
|
||||
reasoningEffort: 'xhigh',
|
||||
})
|
||||
);
|
||||
|
||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||
// xhigh reasoning effort should have 4x the default timeout (120000ms)
|
||||
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh);
|
||||
});
|
||||
|
||||
it('uses default timeout when no reasoning effort is specified', async () => {
|
||||
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
||||
|
||||
await collectAsyncGenerator(
|
||||
provider.executeQuery({
|
||||
prompt: 'Simple task',
|
||||
model: 'gpt-5.2',
|
||||
cwd: '/tmp',
|
||||
})
|
||||
);
|
||||
|
||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||
// No reasoning effort should use the default timeout
|
||||
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateReasoningTimeout', () => {
|
||||
it('returns default timeout when no reasoning effort is specified', () => {
|
||||
expect(calculateReasoningTimeout()).toBe(DEFAULT_TIMEOUT_MS);
|
||||
expect(calculateReasoningTimeout(undefined)).toBe(DEFAULT_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it('returns default timeout for none reasoning effort', () => {
|
||||
expect(calculateReasoningTimeout('none')).toBe(DEFAULT_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it('applies correct multiplier for minimal reasoning effort', () => {
|
||||
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.minimal);
|
||||
expect(calculateReasoningTimeout('minimal')).toBe(expected);
|
||||
});
|
||||
|
||||
it('applies correct multiplier for low reasoning effort', () => {
|
||||
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.low);
|
||||
expect(calculateReasoningTimeout('low')).toBe(expected);
|
||||
});
|
||||
|
||||
it('applies correct multiplier for medium reasoning effort', () => {
|
||||
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.medium);
|
||||
expect(calculateReasoningTimeout('medium')).toBe(expected);
|
||||
});
|
||||
|
||||
it('applies correct multiplier for high reasoning effort', () => {
|
||||
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high);
|
||||
expect(calculateReasoningTimeout('high')).toBe(expected);
|
||||
});
|
||||
|
||||
it('applies correct multiplier for xhigh reasoning effort', () => {
|
||||
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh);
|
||||
expect(calculateReasoningTimeout('xhigh')).toBe(expected);
|
||||
});
|
||||
|
||||
it('uses custom base timeout when provided', () => {
|
||||
const customBase = 60000;
|
||||
expect(calculateReasoningTimeout('high', customBase)).toBe(
|
||||
Math.round(customBase * REASONING_TIMEOUT_MULTIPLIERS.high)
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to 1.0 multiplier for invalid reasoning effort', () => {
|
||||
// Test that invalid values fallback gracefully to default multiplier
|
||||
// This tests the defensive ?? 1.0 in calculateReasoningTimeout
|
||||
const invalidEffort = 'invalid_effort' as never;
|
||||
expect(calculateReasoningTimeout(invalidEffort)).toBe(DEFAULT_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it('produces expected absolute timeout values', () => {
|
||||
// Verify the actual timeout values that will be used:
|
||||
// none: 30000ms (30s)
|
||||
// minimal: 36000ms (36s)
|
||||
// low: 45000ms (45s)
|
||||
// medium: 60000ms (1m)
|
||||
// high: 90000ms (1m 30s)
|
||||
// xhigh: 120000ms (2m)
|
||||
expect(calculateReasoningTimeout('none')).toBe(30000);
|
||||
expect(calculateReasoningTimeout('minimal')).toBe(36000);
|
||||
expect(calculateReasoningTimeout('low')).toBe(45000);
|
||||
expect(calculateReasoningTimeout('medium')).toBe(60000);
|
||||
expect(calculateReasoningTimeout('high')).toBe(90000);
|
||||
expect(calculateReasoningTimeout('xhigh')).toBe(120000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -518,7 +518,11 @@ Resets in 2h
|
||||
|
||||
const promise = ptyService.fetchUsageData();
|
||||
|
||||
dataCallback!('authentication_error');
|
||||
// Send data containing the authentication error pattern the service looks for
|
||||
dataCallback!('"type":"authentication_error"');
|
||||
|
||||
// Trigger the exit handler which checks for auth errors
|
||||
exitCallback!({ exitCode: 1 });
|
||||
|
||||
await expect(promise).rejects.toThrow(
|
||||
"Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions."
|
||||
@@ -586,6 +590,8 @@ Resets in 2h
|
||||
|
||||
it('should send SIGTERM after ESC if process does not exit', async () => {
|
||||
vi.useFakeTimers();
|
||||
// Mock Unix platform to test SIGTERM behavior (Windows calls kill() without signal)
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
const ptyService = new ClaudeUsageService();
|
||||
|
||||
let dataCallback: Function | undefined;
|
||||
|
||||
@@ -190,9 +190,10 @@ describe('feature-loader.ts', () => {
|
||||
const result = await loader.getAll(testProjectPath);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
// With recovery-enabled reads, warnings come from AtomicWriter and FeatureLoader
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/WARN.*\[FeatureLoader\]/),
|
||||
expect.stringContaining('Failed to parse feature.json')
|
||||
expect.stringMatching(/WARN.*\[AtomicWriter\]/),
|
||||
expect.stringContaining('unavailable')
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
@@ -260,10 +261,13 @@ describe('feature-loader.ts', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw on other errors', async () => {
|
||||
it('should return null on other errors (with recovery attempt)', async () => {
|
||||
// With recovery-enabled reads, get() returns null instead of throwing
|
||||
// because it attempts to recover from backups before giving up
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
await expect(loader.get(testProjectPath, 'feature-123')).rejects.toThrow('Permission denied');
|
||||
const result = await loader.get(testProjectPath, 'feature-123');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -442,4 +446,471 @@ describe('feature-loader.ts', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByTitle', () => {
|
||||
it('should find feature by exact title match (case-insensitive)', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
{ name: 'feature-2', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'Login Feature',
|
||||
category: 'auth',
|
||||
description: 'Login implementation',
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-2000-def',
|
||||
title: 'Logout Feature',
|
||||
category: 'auth',
|
||||
description: 'Logout implementation',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.findByTitle(testProjectPath, 'LOGIN FEATURE');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe('feature-1000-abc');
|
||||
expect(result?.title).toBe('Login Feature');
|
||||
});
|
||||
|
||||
it('should return null when title is not found', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'Login Feature',
|
||||
category: 'auth',
|
||||
description: 'Login implementation',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.findByTitle(testProjectPath, 'Nonexistent Feature');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty or whitespace title', async () => {
|
||||
const result1 = await loader.findByTitle(testProjectPath, '');
|
||||
const result2 = await loader.findByTitle(testProjectPath, ' ');
|
||||
|
||||
expect(result1).toBeNull();
|
||||
expect(result2).toBeNull();
|
||||
});
|
||||
|
||||
it('should skip features without titles', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
{ name: 'feature-2', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
// no title
|
||||
category: 'auth',
|
||||
description: 'Login implementation',
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-2000-def',
|
||||
title: 'Login Feature',
|
||||
category: 'auth',
|
||||
description: 'Another login',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.findByTitle(testProjectPath, 'Login Feature');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe('feature-2000-def');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDuplicateTitle', () => {
|
||||
it('should find duplicate title', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'My Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature description',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.findDuplicateTitle(testProjectPath, 'my feature');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe('feature-1000-abc');
|
||||
});
|
||||
|
||||
it('should exclude specified feature ID from duplicate check', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
{ name: 'feature-2', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'My Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature 1',
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-2000-def',
|
||||
title: 'Other Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature 2',
|
||||
})
|
||||
);
|
||||
|
||||
// Should not find duplicate when excluding the feature that has the title
|
||||
const result = await loader.findDuplicateTitle(
|
||||
testProjectPath,
|
||||
'My Feature',
|
||||
'feature-1000-abc'
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should find duplicate when title exists on different feature', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
{ name: 'feature-2', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'My Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature 1',
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-2000-def',
|
||||
title: 'Other Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature 2',
|
||||
})
|
||||
);
|
||||
|
||||
// Should find duplicate because feature-1000-abc has the title and we're excluding feature-2000-def
|
||||
const result = await loader.findDuplicateTitle(
|
||||
testProjectPath,
|
||||
'My Feature',
|
||||
'feature-2000-def'
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe('feature-1000-abc');
|
||||
});
|
||||
|
||||
it('should return null for empty or whitespace title', async () => {
|
||||
const result1 = await loader.findDuplicateTitle(testProjectPath, '');
|
||||
const result2 = await loader.findDuplicateTitle(testProjectPath, ' ');
|
||||
|
||||
expect(result1).toBeNull();
|
||||
expect(result2).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle titles with leading/trailing whitespace', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'My Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature description',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.findDuplicateTitle(testProjectPath, ' My Feature ');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe('feature-1000-abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncFeatureToAppSpec', () => {
|
||||
const sampleAppSpec = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project_specification>
|
||||
<project_name>Test Project</project_name>
|
||||
<core_capabilities>
|
||||
<capability>Testing</capability>
|
||||
</core_capabilities>
|
||||
<implemented_features>
|
||||
<feature>
|
||||
<name>Existing Feature</name>
|
||||
<description>Already implemented</description>
|
||||
</feature>
|
||||
</implemented_features>
|
||||
</project_specification>`;
|
||||
|
||||
const appSpecWithoutFeatures = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project_specification>
|
||||
<project_name>Test Project</project_name>
|
||||
<core_capabilities>
|
||||
<capability>Testing</capability>
|
||||
</core_capabilities>
|
||||
</project_specification>`;
|
||||
|
||||
it('should add feature to app_spec.txt', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'New Feature',
|
||||
category: 'ui',
|
||||
description: 'A new feature description',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('app_spec.txt'),
|
||||
expect.stringContaining('New Feature'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('A new feature description'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should add feature with file locations', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'Feature With Locations',
|
||||
category: 'backend',
|
||||
description: 'Feature with file locations',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature, [
|
||||
'src/feature.ts',
|
||||
'src/utils/helper.ts',
|
||||
]);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('src/feature.ts'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('src/utils/helper.ts'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when app_spec.txt does not exist', async () => {
|
||||
const error: any = new Error('File not found');
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValueOnce(error);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'New Feature',
|
||||
category: 'ui',
|
||||
description: 'A new feature description',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when feature already exists (duplicate)', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-5678-xyz',
|
||||
title: 'Existing Feature', // Same name as existing feature
|
||||
category: 'ui',
|
||||
description: 'Different description',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use feature ID as fallback name when title is missing', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
category: 'ui',
|
||||
description: 'Feature without title',
|
||||
// No title property
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('Feature: feature-1234-abc'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle app_spec without implemented_features section', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(appSpecWithoutFeatures);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'First Feature',
|
||||
category: 'ui',
|
||||
description: 'First implemented feature',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('<implemented_features>'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('First Feature'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on non-ENOENT file read errors', async () => {
|
||||
const error = new Error('Permission denied');
|
||||
vi.mocked(fs.readFile).mockRejectedValueOnce(error);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'New Feature',
|
||||
category: 'ui',
|
||||
description: 'A new feature description',
|
||||
};
|
||||
|
||||
await expect(loader.syncFeatureToAppSpec(testProjectPath, feature)).rejects.toThrow(
|
||||
'Permission denied'
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing features when adding a new one', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'New Feature',
|
||||
category: 'ui',
|
||||
description: 'A new feature',
|
||||
};
|
||||
|
||||
await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
// Verify both old and new features are in the output
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('Existing Feature'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('New Feature'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should escape special characters in feature name and description', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'Feature with <special> & "chars"',
|
||||
category: 'ui',
|
||||
description: 'Description with <tags> & "quotes"',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(true);
|
||||
// The XML should have escaped characters
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('<special>'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('&'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not add empty file_locations array', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'Feature Without Locations',
|
||||
category: 'ui',
|
||||
description: 'No file locations',
|
||||
};
|
||||
|
||||
await loader.syncFeatureToAppSpec(testProjectPath, feature, []);
|
||||
|
||||
// File locations should not be included when array is empty
|
||||
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
|
||||
const writtenContent = writeCall[1] as string;
|
||||
|
||||
// Count occurrences of file_locations - should only have the one from Existing Feature if any
|
||||
// The new feature should not add file_locations
|
||||
expect(writtenContent).toContain('Feature Without Locations');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/ui",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||
"repository": {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Notification Bell - Bell icon with unread count and popover
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Bell, Check, Trash2, ExternalLink } from 'lucide-react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useNotificationsStore } from '@/store/notifications-store';
|
||||
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import type { Notification } from '@automaker/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
|
||||
*/
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
|
||||
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
|
||||
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
interface NotificationBellProps {
|
||||
projectPath: string | null;
|
||||
}
|
||||
|
||||
export function NotificationBell({ projectPath }: NotificationBellProps) {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isPopoverOpen,
|
||||
setPopoverOpen,
|
||||
markAsRead,
|
||||
dismissNotification,
|
||||
} = useNotificationsStore();
|
||||
|
||||
// Load notifications and subscribe to events
|
||||
useLoadNotifications(projectPath);
|
||||
useNotificationEvents(projectPath);
|
||||
|
||||
const handleMarkAsRead = useCallback(
|
||||
async (notificationId: string) => {
|
||||
if (!projectPath) return;
|
||||
|
||||
// Optimistic update
|
||||
markAsRead(notificationId);
|
||||
|
||||
// Sync with server
|
||||
const api = getHttpApiClient();
|
||||
await api.notifications.markAsRead(projectPath, notificationId);
|
||||
},
|
||||
[projectPath, markAsRead]
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
async (notificationId: string) => {
|
||||
if (!projectPath) return;
|
||||
|
||||
// Optimistic update
|
||||
dismissNotification(notificationId);
|
||||
|
||||
// Sync with server
|
||||
const api = getHttpApiClient();
|
||||
await api.notifications.dismiss(projectPath, notificationId);
|
||||
},
|
||||
[projectPath, dismissNotification]
|
||||
);
|
||||
|
||||
const handleNotificationClick = useCallback(
|
||||
(notification: Notification) => {
|
||||
// Mark as read
|
||||
handleMarkAsRead(notification.id);
|
||||
setPopoverOpen(false);
|
||||
|
||||
// Navigate to the relevant view based on notification type
|
||||
if (notification.featureId) {
|
||||
navigate({ to: '/board' });
|
||||
}
|
||||
},
|
||||
[handleMarkAsRead, setPopoverOpen, navigate]
|
||||
);
|
||||
|
||||
const handleViewAll = useCallback(() => {
|
||||
setPopoverOpen(false);
|
||||
navigate({ to: '/notifications' });
|
||||
}, [setPopoverOpen, navigate]);
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'feature_waiting_approval':
|
||||
return <Bell className="h-4 w-4 text-yellow-500" />;
|
||||
case 'feature_verified':
|
||||
return <Check className="h-4 w-4 text-green-500" />;
|
||||
case 'spec_regeneration_complete':
|
||||
return <Check className="h-4 w-4 text-blue-500" />;
|
||||
default:
|
||||
return <Bell className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Show recent 3 notifications in popover
|
||||
const recentNotifications = notifications.slice(0, 3);
|
||||
|
||||
if (!projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isPopoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'relative flex items-center justify-center w-8 h-8 rounded-md',
|
||||
'hover:bg-accent transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2'
|
||||
)}
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="start" side="right">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<h4 className="font-medium text-sm">Notifications</h4>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">{unreadCount} unread</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{recentNotifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 px-4">
|
||||
<Bell className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{recentNotifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-4 py-3 cursor-pointer hover:bg-accent/50 border-b last:border-b-0',
|
||||
!notification.read && 'bg-primary/5'
|
||||
)}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">{getNotificationIcon(notification.type)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm font-medium truncate">{notification.title}</p>
|
||||
{!notification.read && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
{formatRelativeTime(new Date(notification.createdAt))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex flex-col gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDismiss(notification.id);
|
||||
}}
|
||||
title="Dismiss"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<div className="border-t px-4 py-2">
|
||||
<Button variant="ghost" size="sm" className="w-full text-xs" onClick={handleViewAll}>
|
||||
View all notifications
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useOSDetection } from '@/hooks/use-os-detection';
|
||||
import { ProjectSwitcherItem } from './components/project-switcher-item';
|
||||
import { ProjectContextMenu } from './components/project-context-menu';
|
||||
import { EditProjectDialog } from './components/edit-project-dialog';
|
||||
import { NotificationBell } from './components/notification-bell';
|
||||
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
||||
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
|
||||
import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks';
|
||||
@@ -327,6 +328,11 @@ export function ProjectSwitcher() {
|
||||
v{appVersion} {versionSuffix}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Notification Bell */}
|
||||
<div className="flex justify-center mt-2">
|
||||
<NotificationBell projectPath={currentProject?.path ?? null} />
|
||||
</div>
|
||||
<div className="w-full h-px bg-border mt-3" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||
const logger = createLogger('Sidebar');
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore, type ThemeMode } from '@/store/app-store';
|
||||
import { useNotificationsStore } from '@/store/notifications-store';
|
||||
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
|
||||
@@ -19,7 +20,10 @@ import {
|
||||
SidebarHeader,
|
||||
SidebarNavigation,
|
||||
SidebarFooter,
|
||||
MobileSidebarToggle,
|
||||
} from './sidebar/components';
|
||||
import { useIsCompact } from '@/hooks/use-media-query';
|
||||
import { PanelLeftClose } from 'lucide-react';
|
||||
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
|
||||
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
|
||||
import {
|
||||
@@ -43,9 +47,11 @@ export function Sidebar() {
|
||||
trashedProjects,
|
||||
currentProject,
|
||||
sidebarOpen,
|
||||
mobileSidebarHidden,
|
||||
projectHistory,
|
||||
upsertAndSetCurrentProject,
|
||||
toggleSidebar,
|
||||
toggleMobileSidebarHidden,
|
||||
restoreTrashedProject,
|
||||
deleteTrashedProject,
|
||||
emptyTrash,
|
||||
@@ -56,12 +62,17 @@ export function Sidebar() {
|
||||
setSpecCreatingForProject,
|
||||
} = useAppStore();
|
||||
|
||||
const isCompact = useIsCompact();
|
||||
|
||||
// Environment variable flags for hiding sidebar items
|
||||
const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor } = SIDEBAR_FEATURE_FLAGS;
|
||||
|
||||
// Get customizable keyboard shortcuts
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
|
||||
// Get unread notifications count
|
||||
const unreadNotificationsCount = useNotificationsStore((s) => s.unreadCount);
|
||||
|
||||
// State for delete project confirmation dialog
|
||||
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
|
||||
|
||||
@@ -238,6 +249,7 @@ export function Sidebar() {
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
unviewedValidationsCount,
|
||||
unreadNotificationsCount,
|
||||
isSpecGenerating: isCurrentProjectGeneratingSpec,
|
||||
});
|
||||
|
||||
@@ -250,10 +262,16 @@ export function Sidebar() {
|
||||
return location.pathname === routePath;
|
||||
};
|
||||
|
||||
// Check if sidebar should be completely hidden on mobile
|
||||
const shouldHideSidebar = isCompact && mobileSidebarHidden;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating toggle to show sidebar on mobile when hidden */}
|
||||
<MobileSidebarToggle />
|
||||
|
||||
{/* Mobile backdrop overlay */}
|
||||
{sidebarOpen && (
|
||||
{sidebarOpen && !shouldHideSidebar && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
|
||||
onClick={toggleSidebar}
|
||||
@@ -269,8 +287,11 @@ export function Sidebar() {
|
||||
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
|
||||
// Smooth width transition
|
||||
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||
// Mobile: completely hidden when mobileSidebarHidden is true
|
||||
shouldHideSidebar && 'hidden',
|
||||
// Mobile: overlay when open, collapsed when closed
|
||||
sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16'
|
||||
!shouldHideSidebar &&
|
||||
(sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16')
|
||||
)}
|
||||
data-testid="sidebar"
|
||||
>
|
||||
@@ -280,8 +301,33 @@ export function Sidebar() {
|
||||
shortcut={shortcuts.toggleSidebar}
|
||||
/>
|
||||
|
||||
{/* Floating hide button on right edge - only visible on compact screens when sidebar is collapsed */}
|
||||
{!sidebarOpen && isCompact && (
|
||||
<button
|
||||
onClick={toggleMobileSidebarHidden}
|
||||
className={cn(
|
||||
'absolute -right-6 top-1/2 -translate-y-1/2 z-40',
|
||||
'flex items-center justify-center w-6 h-10 rounded-r-lg',
|
||||
'bg-card/95 backdrop-blur-sm border border-l-0 border-border/80',
|
||||
'text-muted-foreground hover:text-brand-500 hover:bg-accent/80',
|
||||
'shadow-lg hover:shadow-xl hover:shadow-brand-500/10',
|
||||
'transition-all duration-200',
|
||||
'hover:w-8 active:scale-95'
|
||||
)}
|
||||
aria-label="Hide sidebar"
|
||||
data-testid="sidebar-mobile-hide"
|
||||
>
|
||||
<PanelLeftClose className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<SidebarHeader sidebarOpen={sidebarOpen} currentProject={currentProject} />
|
||||
<SidebarHeader
|
||||
sidebarOpen={sidebarOpen}
|
||||
currentProject={currentProject}
|
||||
onClose={toggleSidebar}
|
||||
onExpand={toggleSidebar}
|
||||
/>
|
||||
|
||||
<SidebarNavigation
|
||||
currentProject={currentProject}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PanelLeft, PanelLeftClose } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import { useIsCompact } from '@/hooks/use-media-query';
|
||||
|
||||
interface CollapseToggleButtonProps {
|
||||
sidebarOpen: boolean;
|
||||
@@ -13,6 +14,13 @@ export function CollapseToggleButton({
|
||||
toggleSidebar,
|
||||
shortcut,
|
||||
}: CollapseToggleButtonProps) {
|
||||
const isCompact = useIsCompact();
|
||||
|
||||
// Hide when in compact mode (mobile menu is shown in board header)
|
||||
if (isCompact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
|
||||
@@ -8,3 +8,4 @@ export { ProjectActions } from './project-actions';
|
||||
export { SidebarNavigation } from './sidebar-navigation';
|
||||
export { ProjectSelectorWithOptions } from './project-selector-with-options';
|
||||
export { SidebarFooter } from './sidebar-footer';
|
||||
export { MobileSidebarToggle } from './mobile-sidebar-toggle';
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { PanelLeft } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useIsCompact } from '@/hooks/use-media-query';
|
||||
|
||||
/**
|
||||
* Floating toggle button for mobile that completely hides/shows the sidebar.
|
||||
* Positioned at the left-center of the screen.
|
||||
* Only visible on compact/mobile screens when the sidebar is hidden.
|
||||
*/
|
||||
export function MobileSidebarToggle() {
|
||||
const isCompact = useIsCompact();
|
||||
const { mobileSidebarHidden, toggleMobileSidebarHidden } = useAppStore();
|
||||
|
||||
// Only show on compact screens when sidebar is hidden
|
||||
if (!isCompact || !mobileSidebarHidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleMobileSidebarHidden}
|
||||
className={cn(
|
||||
'fixed left-0 top-1/2 -translate-y-1/2 z-50',
|
||||
'flex items-center justify-center',
|
||||
'w-8 h-12 rounded-r-lg',
|
||||
// Glass morphism background
|
||||
'bg-card/95 backdrop-blur-sm border border-l-0 border-border/80',
|
||||
// Shadow and hover effects
|
||||
'shadow-lg shadow-black/10 hover:shadow-xl hover:shadow-brand-500/10',
|
||||
'text-muted-foreground hover:text-brand-500 hover:bg-accent/80',
|
||||
'hover:border-brand-500/30',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:w-10 active:scale-95'
|
||||
)}
|
||||
aria-label="Show sidebar"
|
||||
data-testid="mobile-sidebar-toggle"
|
||||
>
|
||||
<PanelLeft className="w-4 h-4 pointer-events-none" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -151,7 +151,7 @@ export function SidebarFooter({
|
||||
sidebarOpen ? 'justify-start' : 'justify-center',
|
||||
'hover:scale-[1.02] active:scale-[0.97]'
|
||||
)}
|
||||
title={!sidebarOpen ? 'Settings' : undefined}
|
||||
title={!sidebarOpen ? 'Global Settings' : undefined}
|
||||
data-testid="settings-button"
|
||||
>
|
||||
<Settings
|
||||
@@ -168,7 +168,7 @@ export function SidebarFooter({
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
Settings
|
||||
Global Settings
|
||||
</span>
|
||||
{sidebarOpen && (
|
||||
<span
|
||||
@@ -194,7 +194,7 @@ export function SidebarFooter({
|
||||
'translate-x-1 group-hover:translate-x-0'
|
||||
)}
|
||||
>
|
||||
Settings
|
||||
Global Settings
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
||||
{formatShortcut(shortcuts.settings, true)}
|
||||
</span>
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { Folder, LucideIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Folder, LucideIcon, X, Menu, Check } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { cn, isMac } from '@/lib/utils';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import { isElectron, type Project } from '@/lib/electron';
|
||||
import { useIsCompact } from '@/hooks/use-media-query';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
sidebarOpen: boolean;
|
||||
currentProject: Project | null;
|
||||
onClose?: () => void;
|
||||
onExpand?: () => void;
|
||||
}
|
||||
|
||||
export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProps) {
|
||||
export function SidebarHeader({
|
||||
sidebarOpen,
|
||||
currentProject,
|
||||
onClose,
|
||||
onExpand,
|
||||
}: SidebarHeaderProps) {
|
||||
const isCompact = useIsCompact();
|
||||
const [projectListOpen, setProjectListOpen] = useState(false);
|
||||
const { projects, setCurrentProject } = useAppStore();
|
||||
// Get the icon component from lucide-react
|
||||
const getIconComponent = (): LucideIcon => {
|
||||
if (currentProject?.icon && currentProject.icon in LucideIcons) {
|
||||
@@ -24,43 +38,141 @@ export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProp
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 flex flex-col',
|
||||
'shrink-0 flex flex-col relative',
|
||||
// Add padding on macOS Electron for traffic light buttons
|
||||
isMac && isElectron() && 'pt-[10px]'
|
||||
)}
|
||||
>
|
||||
{/* Project name and icon display */}
|
||||
{currentProject && (
|
||||
<div
|
||||
{/* Mobile close button - only visible on mobile when sidebar is open */}
|
||||
{sidebarOpen && onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 pt-3 pb-1',
|
||||
!sidebarOpen && 'justify-center px-2'
|
||||
'lg:hidden absolute top-3 right-3 z-10',
|
||||
'flex items-center justify-center w-8 h-8 rounded-lg',
|
||||
'bg-muted/50 hover:bg-muted',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'transition-colors duration-200'
|
||||
)}
|
||||
aria-label="Close navigation"
|
||||
data-testid="sidebar-mobile-close"
|
||||
>
|
||||
{/* Project Icon */}
|
||||
<div className="shrink-0">
|
||||
{hasCustomIcon ? (
|
||||
<img
|
||||
src={getAuthenticatedImageUrl(currentProject.customIconPath!, currentProject.path)}
|
||||
alt={currentProject.name}
|
||||
className="w-8 h-8 rounded-lg object-cover ring-1 ring-border/50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center">
|
||||
<IconComponent className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project Name - only show when sidebar is open */}
|
||||
{sidebarOpen && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold text-foreground truncate">
|
||||
{currentProject.name}
|
||||
</h2>
|
||||
</div>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{/* Mobile expand button - hamburger menu to expand sidebar when collapsed on mobile */}
|
||||
{!sidebarOpen && isCompact && onExpand && (
|
||||
<button
|
||||
onClick={onExpand}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-10 h-10 mx-auto mt-2 rounded-lg',
|
||||
'bg-muted/50 hover:bg-muted',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'transition-colors duration-200'
|
||||
)}
|
||||
</div>
|
||||
aria-label="Expand navigation"
|
||||
data-testid="sidebar-mobile-expand"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{/* Project name and icon display - entire element clickable on mobile */}
|
||||
{currentProject && (
|
||||
<Popover open={projectListOpen} onOpenChange={setProjectListOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 pt-3 pb-1 w-full text-left',
|
||||
'rounded-lg transition-colors duration-150',
|
||||
!sidebarOpen && 'justify-center px-2',
|
||||
// Only enable click behavior on compact screens
|
||||
isCompact && 'hover:bg-accent/50 cursor-pointer',
|
||||
!isCompact && 'pointer-events-none'
|
||||
)}
|
||||
title={isCompact ? 'Switch project' : undefined}
|
||||
>
|
||||
{/* Project Icon */}
|
||||
<div className="shrink-0">
|
||||
{hasCustomIcon ? (
|
||||
<img
|
||||
src={getAuthenticatedImageUrl(
|
||||
currentProject.customIconPath!,
|
||||
currentProject.path
|
||||
)}
|
||||
alt={currentProject.name}
|
||||
className="w-8 h-8 rounded-lg object-cover ring-1 ring-border/50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center">
|
||||
<IconComponent className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project Name - only show when sidebar is open */}
|
||||
{sidebarOpen && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold text-foreground truncate">
|
||||
{currentProject.name}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-2" align="start" side="bottom" sideOffset={8}>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground px-2 py-1">Switch Project</p>
|
||||
{projects.map((project) => {
|
||||
const ProjectIcon =
|
||||
project.icon && project.icon in LucideIcons
|
||||
? (LucideIcons as Record<string, LucideIcon>)[project.icon]
|
||||
: Folder;
|
||||
const isActive = currentProject?.id === project.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() => {
|
||||
setCurrentProject(project);
|
||||
setProjectListOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-2 py-2 rounded-lg text-left',
|
||||
'transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-brand-500/10 text-brand-500'
|
||||
: 'hover:bg-accent text-foreground'
|
||||
)}
|
||||
>
|
||||
{project.customIconPath ? (
|
||||
<img
|
||||
src={getAuthenticatedImageUrl(project.customIconPath, project.path)}
|
||||
alt={project.name}
|
||||
className="w-6 h-6 rounded object-cover ring-1 ring-border/50"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'w-6 h-6 rounded flex items-center justify-center',
|
||||
isActive ? 'bg-brand-500/20' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<ProjectIcon
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isActive ? 'text-brand-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className="flex-1 text-sm truncate">{project.name}</span>
|
||||
{isActive && <Check className="w-4 h-4 text-brand-500" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,12 @@ export function SidebarNavigation({
|
||||
navigate,
|
||||
}: SidebarNavigationProps) {
|
||||
return (
|
||||
<nav className={cn('flex-1 overflow-y-auto px-3 pb-2', sidebarOpen ? 'mt-1' : 'mt-1')}>
|
||||
<nav
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
|
||||
sidebarOpen ? 'mt-1' : 'mt-1'
|
||||
)}
|
||||
>
|
||||
{!currentProject && sidebarOpen ? (
|
||||
// Placeholder when no project is selected (only in expanded state)
|
||||
<div className="flex items-center justify-center h-full px-4">
|
||||
@@ -41,7 +46,13 @@ export function SidebarNavigation({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{section.label && !sidebarOpen && <div className="h-px bg-border/30 mx-2 my-1.5"></div>}
|
||||
{/* Separator for sections without label (visual separation) */}
|
||||
{!section.label && sectionIdx > 0 && sidebarOpen && (
|
||||
<div className="h-px bg-border/40 mx-3 mb-4"></div>
|
||||
)}
|
||||
{(section.label || sectionIdx > 0) && !sidebarOpen && (
|
||||
<div className="h-px bg-border/30 mx-2 my-1.5"></div>
|
||||
)}
|
||||
|
||||
{/* Nav Items */}
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
Lightbulb,
|
||||
Brain,
|
||||
Network,
|
||||
Bell,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import type { NavSection, NavItem } from '../types';
|
||||
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||
@@ -32,9 +34,11 @@ interface UseNavigationProps {
|
||||
agent: string;
|
||||
terminal: string;
|
||||
settings: string;
|
||||
projectSettings: string;
|
||||
ideation: string;
|
||||
githubIssues: string;
|
||||
githubPrs: string;
|
||||
notifications: string;
|
||||
};
|
||||
hideSpecEditor: boolean;
|
||||
hideContext: boolean;
|
||||
@@ -49,6 +53,8 @@ interface UseNavigationProps {
|
||||
cycleNextProject: () => void;
|
||||
/** Count of unviewed validations to show on GitHub Issues nav item */
|
||||
unviewedValidationsCount?: number;
|
||||
/** Count of unread notifications to show on Notifications nav item */
|
||||
unreadNotificationsCount?: number;
|
||||
/** Whether spec generation is currently running for the current project */
|
||||
isSpecGenerating?: boolean;
|
||||
}
|
||||
@@ -67,6 +73,7 @@ export function useNavigation({
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
unviewedValidationsCount,
|
||||
unreadNotificationsCount,
|
||||
isSpecGenerating,
|
||||
}: UseNavigationProps) {
|
||||
// Track if current project has a GitHub remote
|
||||
@@ -199,6 +206,26 @@ export function useNavigation({
|
||||
});
|
||||
}
|
||||
|
||||
// Add Notifications and Project Settings as a standalone section (no label for visual separation)
|
||||
sections.push({
|
||||
label: '',
|
||||
items: [
|
||||
{
|
||||
id: 'notifications',
|
||||
label: 'Notifications',
|
||||
icon: Bell,
|
||||
shortcut: shortcuts.notifications,
|
||||
count: unreadNotificationsCount,
|
||||
},
|
||||
{
|
||||
id: 'project-settings',
|
||||
label: 'Project Settings',
|
||||
icon: Settings,
|
||||
shortcut: shortcuts.projectSettings,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}, [
|
||||
shortcuts,
|
||||
@@ -207,6 +234,7 @@ export function useNavigation({
|
||||
hideTerminal,
|
||||
hasGitHubRemote,
|
||||
unviewedValidationsCount,
|
||||
unreadNotificationsCount,
|
||||
isSpecGenerating,
|
||||
]);
|
||||
|
||||
@@ -257,11 +285,11 @@ export function useNavigation({
|
||||
});
|
||||
});
|
||||
|
||||
// Add settings shortcut
|
||||
// Add global settings shortcut
|
||||
shortcutsList.push({
|
||||
key: shortcuts.settings,
|
||||
action: () => navigate({ to: '/settings' }),
|
||||
description: 'Navigate to Settings',
|
||||
description: 'Navigate to Global Settings',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
@@ -6,6 +6,7 @@ const logger = createLogger('RunningAgents');
|
||||
|
||||
export function useRunningAgents() {
|
||||
const [runningAgentsCount, setRunningAgentsCount] = useState(0);
|
||||
const fetchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Fetch running agents count function - used for initial load and event-driven updates
|
||||
const fetchRunningAgentsCount = useCallback(async () => {
|
||||
@@ -32,6 +33,16 @@ export function useRunningAgents() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Debounced fetch to avoid excessive API calls from frequent events
|
||||
const debouncedFetchRunningAgentsCount = useCallback(() => {
|
||||
if (fetchTimeoutRef.current) {
|
||||
clearTimeout(fetchTimeoutRef.current);
|
||||
}
|
||||
fetchTimeoutRef.current = setTimeout(() => {
|
||||
fetchRunningAgentsCount();
|
||||
}, 300);
|
||||
}, [fetchRunningAgentsCount]);
|
||||
|
||||
// Subscribe to auto-mode events to update running agents count in real-time
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
@@ -80,6 +91,41 @@ export function useRunningAgents() {
|
||||
};
|
||||
}, [fetchRunningAgentsCount]);
|
||||
|
||||
// Subscribe to spec regeneration events to update running agents count
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) return;
|
||||
|
||||
fetchRunningAgentsCount();
|
||||
|
||||
const unsubscribe = api.specRegeneration.onEvent((event) => {
|
||||
logger.debug('Spec regeneration event for running agents hook', {
|
||||
type: event.type,
|
||||
});
|
||||
// When spec regeneration completes or errors, refresh immediately
|
||||
if (event.type === 'spec_regeneration_complete' || event.type === 'spec_regeneration_error') {
|
||||
fetchRunningAgentsCount();
|
||||
}
|
||||
// For progress events, use debounced fetch to avoid excessive calls
|
||||
else if (event.type === 'spec_regeneration_progress') {
|
||||
debouncedFetchRunningAgentsCount();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [fetchRunningAgentsCount, debouncedFetchRunningAgentsCount]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (fetchTimeoutRef.current) {
|
||||
clearTimeout(fetchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
runningAgentsCount,
|
||||
};
|
||||
|
||||
105
apps/ui/src/components/ui/header-actions-panel.tsx
Normal file
105
apps/ui/src/components/ui/header-actions-panel.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Menu } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface HeaderActionsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A slide-out panel for header actions on tablet and below.
|
||||
* Shows as a right-side panel that slides in from the right edge.
|
||||
* On desktop (lg+), this component is hidden and children should be rendered inline.
|
||||
*/
|
||||
export function HeaderActionsPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
title = 'Actions',
|
||||
children,
|
||||
}: HeaderActionsPanelProps) {
|
||||
// Use portal to render outside parent stacking contexts (backdrop-blur creates stacking context)
|
||||
const panelContent = (
|
||||
<>
|
||||
{/* Mobile backdrop overlay - only shown when isOpen is true on tablet/mobile */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-[60] lg:hidden"
|
||||
onClick={onClose}
|
||||
data-testid="header-actions-backdrop"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actions panel */}
|
||||
<div
|
||||
className={cn(
|
||||
// Mobile: fixed position overlay with slide transition from right
|
||||
'fixed inset-y-0 right-0 w-72 z-[70]',
|
||||
'transition-transform duration-200 ease-out',
|
||||
// Hide on mobile when closed, show when open
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full',
|
||||
// Desktop: hidden entirely (actions shown inline in header)
|
||||
'lg:hidden',
|
||||
'flex flex-col',
|
||||
'border-l border-border/50',
|
||||
'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl'
|
||||
)}
|
||||
>
|
||||
{/* Panel header with close button */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50">
|
||||
<span className="text-sm font-semibold text-foreground">{title}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close actions panel"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Panel content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">{children}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// Render to document.body to escape stacking context
|
||||
if (typeof document !== 'undefined') {
|
||||
return createPortal(panelContent, document.body);
|
||||
}
|
||||
|
||||
return panelContent;
|
||||
}
|
||||
|
||||
interface HeaderActionsPanelTriggerProps {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle button for the HeaderActionsPanel.
|
||||
* Only visible on tablet and below (lg:hidden).
|
||||
*/
|
||||
export function HeaderActionsPanelTrigger({
|
||||
isOpen,
|
||||
onToggle,
|
||||
className,
|
||||
}: HeaderActionsPanelTriggerProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggle}
|
||||
className={cn('h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden', className)}
|
||||
aria-label={isOpen ? 'Close actions menu' : 'Open actions menu'}
|
||||
>
|
||||
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -70,8 +70,7 @@ const editorTheme = EditorView.theme({
|
||||
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'var(--accent)',
|
||||
opacity: '0.3',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 0.25rem',
|
||||
@@ -114,7 +113,7 @@ export function ShellSyntaxEditor({
|
||||
}: ShellSyntaxEditorProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('w-full rounded-lg border border-border bg-muted/30', className)}
|
||||
className={cn('w-full rounded-lg border border-border bg-background', className)}
|
||||
style={{ minHeight }}
|
||||
data-testid={testId}
|
||||
>
|
||||
|
||||
@@ -27,18 +27,6 @@ export function AgentHeader({
|
||||
return (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleSessionManager}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showSessionManager ? (
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
) : (
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<Bot className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
@@ -71,6 +59,19 @@ export function AgentHeader({
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleSessionManager}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showSessionManager ? 'Hide sessions panel' : 'Show sessions panel'}
|
||||
>
|
||||
{showSessionManager ? (
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
) : (
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -521,9 +521,9 @@ export function BoardView() {
|
||||
// Empty string clears the branch assignment, moving features to main/current branch
|
||||
finalBranchName = '';
|
||||
} else if (workMode === 'auto') {
|
||||
// Auto-generate a branch name based on current branch and timestamp
|
||||
const baseBranch =
|
||||
currentWorktreeBranch || getPrimaryWorktreeBranch(currentProject.path) || 'main';
|
||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
||||
// Always use primary branch to avoid nested feature/feature/... paths
|
||||
const baseBranch = getPrimaryWorktreeBranch(currentProject.path) || 'main';
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 6);
|
||||
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
|
||||
@@ -603,7 +603,6 @@ export function BoardView() {
|
||||
selectedFeatureIds,
|
||||
updateFeature,
|
||||
exitSelectionMode,
|
||||
currentWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
addAndSelectWorktree,
|
||||
setWorktreeRefreshKey,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
|
||||
import { UsagePopover } from '@/components/usage-popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { useIsTablet } from '@/hooks/use-media-query';
|
||||
import { AutoModeSettingsPopover } from './dialogs/auto-mode-settings-popover';
|
||||
import { WorktreeSettingsPopover } from './dialogs/worktree-settings-popover';
|
||||
import { PlanSettingsPopover } from './dialogs/plan-settings-popover';
|
||||
@@ -108,7 +108,10 @@ export function BoardHeader({
|
||||
// Show if Codex is authenticated (CLI or API key)
|
||||
const showCodexUsage = !!codexAuthStatus?.authenticated;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
// State for mobile actions panel
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
|
||||
const isTablet = useIsTablet();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
@@ -125,11 +128,13 @@ export function BoardHeader({
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
||||
{isMounted && !isMobile && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
|
||||
{/* Mobile view: show hamburger menu with all controls */}
|
||||
{isMounted && isMobile && (
|
||||
{/* Tablet/Mobile view: show hamburger menu with all controls */}
|
||||
{isMounted && isTablet && (
|
||||
<HeaderMobileMenu
|
||||
isOpen={showActionsPanel}
|
||||
onToggle={() => setShowActionsPanel(!showActionsPanel)}
|
||||
isWorktreePanelVisible={isWorktreePanelVisible}
|
||||
onWorktreePanelToggle={handleWorktreePanelToggle}
|
||||
maxConcurrency={maxConcurrency}
|
||||
@@ -146,7 +151,7 @@ export function BoardHeader({
|
||||
|
||||
{/* Desktop view: show full controls */}
|
||||
{/* Worktrees Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && !isMobile && (
|
||||
{isMounted && !isTablet && (
|
||||
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<Label
|
||||
@@ -169,7 +174,7 @@ export function BoardHeader({
|
||||
)}
|
||||
|
||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && !isMobile && (
|
||||
{isMounted && !isTablet && (
|
||||
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
|
||||
<Label
|
||||
htmlFor="auto-mode-toggle"
|
||||
@@ -193,8 +198,8 @@ export function BoardHeader({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan Button with Settings - only show on desktop, mobile has it in the menu */}
|
||||
{isMounted && !isMobile && (
|
||||
{/* Plan Button with Settings - only show on desktop, tablet/mobile has it in the panel */}
|
||||
{isMounted && !isTablet && (
|
||||
<div className={controlContainerClass} data-testid="plan-button-container">
|
||||
{hasPendingPlan && (
|
||||
<button
|
||||
|
||||
@@ -2,18 +2,17 @@ import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Menu, Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
|
||||
HeaderActionsPanel,
|
||||
HeaderActionsPanelTrigger,
|
||||
} from '@/components/ui/header-actions-panel';
|
||||
import { Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MobileUsageBar } from './mobile-usage-bar';
|
||||
|
||||
interface HeaderMobileMenuProps {
|
||||
// Panel visibility
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
// Worktree panel visibility
|
||||
isWorktreePanelVisible: boolean;
|
||||
onWorktreePanelToggle: (visible: boolean) => void;
|
||||
@@ -33,6 +32,8 @@ interface HeaderMobileMenuProps {
|
||||
}
|
||||
|
||||
export function HeaderMobileMenu({
|
||||
isOpen,
|
||||
onToggle,
|
||||
isWorktreePanelVisible,
|
||||
onWorktreePanelToggle,
|
||||
maxConcurrency,
|
||||
@@ -46,129 +47,122 @@ export function HeaderMobileMenu({
|
||||
showCodexUsage,
|
||||
}: HeaderMobileMenuProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
data-testid="header-mobile-menu-trigger"
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<>
|
||||
<HeaderActionsPanelTrigger isOpen={isOpen} onToggle={onToggle} />
|
||||
<HeaderActionsPanel isOpen={isOpen} onClose={onToggle} title="Board Controls">
|
||||
{/* Usage Bar - show if either provider is authenticated */}
|
||||
{(showClaudeUsage || showCodexUsage) && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Usage
|
||||
</DropdownMenuLabel>
|
||||
</span>
|
||||
<MobileUsageBar showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} />
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
Controls
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Controls Section */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Controls
|
||||
</span>
|
||||
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm"
|
||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||
data-testid="mobile-auto-mode-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Auto Mode</span>
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
|
||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||
data-testid="mobile-auto-mode-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Auto Mode</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="mobile-auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
onCheckedChange={onAutoModeToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-auto-mode-toggle"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenAutoModeSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="mobile-auto-mode-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
{/* Worktrees Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
|
||||
onClick={() => onWorktreePanelToggle(!isWorktreePanelVisible)}
|
||||
data-testid="mobile-worktrees-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Worktree Bar</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="mobile-auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
onCheckedChange={onAutoModeToggle}
|
||||
id="mobile-worktrees-toggle"
|
||||
checked={isWorktreePanelVisible}
|
||||
onCheckedChange={onWorktreePanelToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-auto-mode-toggle"
|
||||
data-testid="mobile-worktrees-toggle"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenAutoModeSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="mobile-auto-mode-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Worktrees Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm"
|
||||
onClick={() => onWorktreePanelToggle(!isWorktreePanelVisible)}
|
||||
data-testid="mobile-worktrees-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Worktrees</span>
|
||||
{/* Concurrency Control */}
|
||||
<div
|
||||
className="p-3 rounded-lg border border-border/50"
|
||||
data-testid="mobile-concurrency-control"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
id="mobile-worktrees-toggle"
|
||||
checked={isWorktreePanelVisible}
|
||||
onCheckedChange={onWorktreePanelToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-worktrees-toggle"
|
||||
/>
|
||||
|
||||
{/* Plan Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
onOpenPlanDialog();
|
||||
onToggle();
|
||||
}}
|
||||
data-testid="mobile-plan-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Concurrency Control */}
|
||||
<div className="px-2 py-2" data-testid="mobile-concurrency-control">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Plan Button */}
|
||||
<DropdownMenuItem
|
||||
onClick={onOpenPlanDialog}
|
||||
className="flex items-center gap-2"
|
||||
data-testid="mobile-plan-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4" />
|
||||
<span>Plan</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</HeaderActionsPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,8 +127,10 @@ export function useBoardActions({
|
||||
// No worktree isolation - work directly on current branch
|
||||
finalBranchName = undefined;
|
||||
} else if (workMode === 'auto') {
|
||||
// Auto-generate a branch name based on current branch and timestamp
|
||||
const baseBranch = currentWorktreeBranch || 'main';
|
||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
||||
// Always use primary branch to avoid nested feature/feature/... paths
|
||||
const baseBranch =
|
||||
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 6);
|
||||
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
|
||||
@@ -245,7 +247,7 @@ export function useBoardActions({
|
||||
currentProject,
|
||||
onWorktreeCreated,
|
||||
onWorktreeAutoSelect,
|
||||
currentWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
features,
|
||||
]
|
||||
);
|
||||
@@ -282,7 +284,10 @@ export function useBoardActions({
|
||||
if (workMode === 'current') {
|
||||
finalBranchName = undefined;
|
||||
} else if (workMode === 'auto') {
|
||||
const baseBranch = currentWorktreeBranch || 'main';
|
||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
||||
// Always use primary branch to avoid nested feature/feature/... paths
|
||||
const baseBranch =
|
||||
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 6);
|
||||
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
|
||||
@@ -390,7 +395,7 @@ export function useBoardActions({
|
||||
setEditingFeature,
|
||||
currentProject,
|
||||
onWorktreeCreated,
|
||||
currentWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
features,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -93,7 +93,7 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
|
||||
isRunning: true,
|
||||
isLoading: false,
|
||||
port: result.result!.port,
|
||||
url: `http://localhost:${result.result!.port}`,
|
||||
url: result.result!.url,
|
||||
startedAt: result.result!.startedAt,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
@@ -7,6 +7,10 @@ import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
HeaderActionsPanel,
|
||||
HeaderActionsPanelTrigger,
|
||||
} from '@/components/ui/header-actions-panel';
|
||||
import {
|
||||
RefreshCw,
|
||||
FileText,
|
||||
@@ -94,6 +98,9 @@ export function ContextView() {
|
||||
const [editDescriptionValue, setEditDescriptionValue] = useState('');
|
||||
const [editDescriptionFileName, setEditDescriptionFileName] = useState('');
|
||||
|
||||
// Actions panel state (for tablet/mobile)
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
|
||||
// File input ref for import
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -691,30 +698,70 @@ export function ContextView() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleImportClick}
|
||||
disabled={isUploading}
|
||||
data-testid="import-file-button"
|
||||
>
|
||||
<FileUp className="w-4 h-4 mr-2" />
|
||||
Import File
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
size="sm"
|
||||
onClick={() => setIsCreateMarkdownOpen(true)}
|
||||
hotkey={shortcuts.addContextFile}
|
||||
hotkeyActive={false}
|
||||
data-testid="create-markdown-button"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
Create Markdown
|
||||
</HotkeyButton>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Desktop: show actions inline */}
|
||||
<div className="hidden lg:flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleImportClick}
|
||||
disabled={isUploading}
|
||||
data-testid="import-file-button"
|
||||
>
|
||||
<FileUp className="w-4 h-4 mr-2" />
|
||||
Import File
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
size="sm"
|
||||
onClick={() => setIsCreateMarkdownOpen(true)}
|
||||
hotkey={shortcuts.addContextFile}
|
||||
hotkeyActive={false}
|
||||
data-testid="create-markdown-button"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
Create Markdown
|
||||
</HotkeyButton>
|
||||
</div>
|
||||
{/* Tablet/Mobile: show trigger for actions panel */}
|
||||
<HeaderActionsPanelTrigger
|
||||
isOpen={showActionsPanel}
|
||||
onToggle={() => setShowActionsPanel(!showActionsPanel)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Panel (tablet/mobile) */}
|
||||
<HeaderActionsPanel
|
||||
isOpen={showActionsPanel}
|
||||
onClose={() => setShowActionsPanel(false)}
|
||||
title="Context Actions"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
handleImportClick();
|
||||
setShowActionsPanel(false);
|
||||
}}
|
||||
disabled={isUploading}
|
||||
data-testid="import-file-button-mobile"
|
||||
>
|
||||
<FileUp className="w-4 h-4 mr-2" />
|
||||
Import File
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
setIsCreateMarkdownOpen(true);
|
||||
setShowActionsPanel(false);
|
||||
}}
|
||||
data-testid="create-markdown-button-mobile"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
Create Markdown
|
||||
</Button>
|
||||
</HeaderActionsPanel>
|
||||
|
||||
{/* Main content area with file list and editor */}
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -21,10 +21,15 @@ import {
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
Search,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -55,6 +60,13 @@ function getOSAbbreviation(os: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getIconComponent(iconName?: string): LucideIcon {
|
||||
if (iconName && iconName in LucideIcons) {
|
||||
return (LucideIcons as unknown as Record<string, LucideIcon>)[iconName];
|
||||
}
|
||||
return Folder;
|
||||
}
|
||||
|
||||
export function DashboardView() {
|
||||
const navigate = useNavigate();
|
||||
const { os } = useOSDetection();
|
||||
@@ -79,6 +91,7 @@ export function DashboardView() {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isOpening, setIsOpening] = useState(false);
|
||||
const [projectToRemove, setProjectToRemove] = useState<{ id: string; name: string } | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Sort projects: favorites first, then by last opened
|
||||
const sortedProjects = [...projects].sort((a, b) => {
|
||||
@@ -91,8 +104,15 @@ export function DashboardView() {
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
const favoriteProjects = sortedProjects.filter((p) => p.isFavorite);
|
||||
const recentProjects = sortedProjects.filter((p) => !p.isFavorite);
|
||||
// Filter projects based on search query
|
||||
const filteredProjects = sortedProjects.filter((project) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return project.name.toLowerCase().includes(query) || project.path.toLowerCase().includes(query);
|
||||
});
|
||||
|
||||
const favoriteProjects = filteredProjects.filter((p) => p.isFavorite);
|
||||
const recentProjects = filteredProjects.filter((p) => !p.isFavorite);
|
||||
|
||||
/**
|
||||
* Initialize project and navigate to board
|
||||
@@ -529,14 +549,35 @@ export function DashboardView() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate({ to: '/settings' })}
|
||||
className="titlebar-no-drag"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
{/* Mobile action buttons in header */}
|
||||
{hasProjects && (
|
||||
<div className="flex sm:hidden gap-2 titlebar-no-drag">
|
||||
<Button variant="outline" size="icon" onClick={handleOpenProject}>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
className="bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={handleNewProject}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Quick Setup
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleInteractiveMode}>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Interactive Mode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -646,25 +687,42 @@ export function DashboardView() {
|
||||
{/* Has projects - show project list */}
|
||||
{hasProjects && (
|
||||
<div className="space-y-6 sm:space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
{/* Quick actions header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h2 className="text-2xl font-bold text-foreground">Your Projects</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleOpenProject}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Open Folder</span>
|
||||
{/* Search and actions header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-foreground">Your Projects</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search input */}
|
||||
<div className="relative flex-1 sm:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search projects..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-8 w-full sm:w-64"
|
||||
data-testid="project-search-input"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-muted transition-colors"
|
||||
title="Clear search"
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Desktop only buttons */}
|
||||
<Button variant="outline" onClick={handleOpenProject} className="hidden sm:flex">
|
||||
<FolderOpen className="w-4 h-4 mr-2" />
|
||||
Open Folder
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="flex-1 sm:flex-none bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white">
|
||||
<Plus className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">New Project</span>
|
||||
<span className="sm:hidden">New</span>
|
||||
<ChevronDown className="w-4 h-4 ml-1 sm:ml-2" />
|
||||
<Button className="hidden sm:flex bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Project
|
||||
<ChevronDown className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
@@ -703,8 +761,24 @@ export function DashboardView() {
|
||||
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-yellow-500/5 to-amber-600/5 opacity-0 group-hover:opacity-100 transition-all duration-300" />
|
||||
<div className="relative p-3 sm:p-4">
|
||||
<div className="flex items-start gap-2.5 sm:gap-3">
|
||||
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-yellow-500/10 border border-yellow-500/30 flex items-center justify-center group-hover:bg-yellow-500/20 transition-all duration-300 shrink-0">
|
||||
<Folder className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-500" />
|
||||
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-yellow-500/10 border border-yellow-500/30 flex items-center justify-center group-hover:bg-yellow-500/20 transition-all duration-300 shrink-0 overflow-hidden">
|
||||
{project.customIconPath ? (
|
||||
<img
|
||||
src={getAuthenticatedImageUrl(
|
||||
project.customIconPath,
|
||||
project.path
|
||||
)}
|
||||
alt={project.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const IconComponent = getIconComponent(project.icon);
|
||||
return (
|
||||
<IconComponent className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-500" />
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-yellow-500 transition-colors duration-300">
|
||||
@@ -778,8 +852,24 @@ export function DashboardView() {
|
||||
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all duration-300" />
|
||||
<div className="relative p-3 sm:p-4">
|
||||
<div className="flex items-start gap-2.5 sm:gap-3">
|
||||
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0">
|
||||
<Folder className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground group-hover:text-brand-500 transition-colors duration-300" />
|
||||
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0 overflow-hidden">
|
||||
{project.customIconPath ? (
|
||||
<img
|
||||
src={getAuthenticatedImageUrl(
|
||||
project.customIconPath,
|
||||
project.path
|
||||
)}
|
||||
alt={project.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const IconComponent = getIconComponent(project.icon);
|
||||
return (
|
||||
<IconComponent className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground group-hover:text-brand-500 transition-colors duration-300" />
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-brand-500 transition-colors duration-300">
|
||||
@@ -797,10 +887,10 @@ export function DashboardView() {
|
||||
<div className="flex items-center gap-0.5 sm:gap-1">
|
||||
<button
|
||||
onClick={(e) => handleToggleFavorite(e, project.id)}
|
||||
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
|
||||
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors"
|
||||
title="Add to favorites"
|
||||
>
|
||||
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground hover:text-yellow-500" />
|
||||
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground/50 hover:text-yellow-500 transition-colors" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -830,6 +920,22 @@ export function DashboardView() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No search results */}
|
||||
{searchQuery && favoriteProjects.length === 0 && recentProjects.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
|
||||
<Search className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">No projects found</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
No projects match "{searchQuery}"
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearchQuery('')}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
// @ts-nocheck
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { CircleDot, RefreshCw } from 'lucide-react';
|
||||
import { CircleDot, RefreshCw, SearchX } from 'lucide-react';
|
||||
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { LoadingState } from '@/components/ui/loading-state';
|
||||
import { ErrorState } from '@/components/ui/error-state';
|
||||
import { cn, pathsEqual } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks';
|
||||
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
|
||||
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
||||
import { ValidationDialog } from './github-issues-view/dialogs';
|
||||
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
|
||||
import { useModelOverride } from '@/components/shared';
|
||||
import type { ValidateIssueOptions } from './github-issues-view/types';
|
||||
import type {
|
||||
ValidateIssueOptions,
|
||||
IssuesFilterState,
|
||||
IssuesStateFilter,
|
||||
} from './github-issues-view/types';
|
||||
import { DEFAULT_ISSUES_FILTER_STATE } from './github-issues-view/types';
|
||||
|
||||
const logger = createLogger('GitHubIssuesView');
|
||||
|
||||
@@ -26,6 +32,9 @@ export function GitHubIssuesView() {
|
||||
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
|
||||
useState<ValidateIssueOptions | null>(null);
|
||||
|
||||
// Filter state
|
||||
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
|
||||
|
||||
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
|
||||
|
||||
// Model override for validation
|
||||
@@ -44,6 +53,41 @@ export function GitHubIssuesView() {
|
||||
onShowValidationDialogChange: setShowValidationDialog,
|
||||
});
|
||||
|
||||
// Combine all issues for filtering
|
||||
const allIssues = useMemo(() => [...openIssues, ...closedIssues], [openIssues, closedIssues]);
|
||||
|
||||
// Apply filter to issues - now returns matched issues directly for better performance
|
||||
const filterResult = useIssuesFilter(allIssues, filterState, cachedValidations);
|
||||
|
||||
// Separate filtered issues by state - this is O(n) but now only done once
|
||||
// since filterResult.matchedIssues already contains the filtered issues
|
||||
const { filteredOpenIssues, filteredClosedIssues } = useMemo(() => {
|
||||
const open: typeof openIssues = [];
|
||||
const closed: typeof closedIssues = [];
|
||||
for (const issue of filterResult.matchedIssues) {
|
||||
if (issue.state.toLowerCase() === 'open') {
|
||||
open.push(issue);
|
||||
} else {
|
||||
closed.push(issue);
|
||||
}
|
||||
}
|
||||
return { filteredOpenIssues: open, filteredClosedIssues: closed };
|
||||
}, [filterResult.matchedIssues]);
|
||||
|
||||
// Filter state change handlers
|
||||
const handleStateFilterChange = useCallback((stateFilter: IssuesStateFilter) => {
|
||||
setFilterState((prev) => ({ ...prev, stateFilter }));
|
||||
}, []);
|
||||
|
||||
const handleLabelsChange = useCallback((selectedLabels: string[]) => {
|
||||
setFilterState((prev) => ({ ...prev, selectedLabels }));
|
||||
}, []);
|
||||
|
||||
// Clear all filters to default state
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setFilterState(DEFAULT_ISSUES_FILTER_STATE);
|
||||
}, []);
|
||||
|
||||
// Get current branch from selected worktree
|
||||
const currentBranch = useMemo(() => {
|
||||
if (!currentProject?.path) return '';
|
||||
@@ -130,7 +174,10 @@ export function GitHubIssuesView() {
|
||||
return <ErrorState error={error} title="Failed to Load Issues" onRetry={refresh} />;
|
||||
}
|
||||
|
||||
const totalIssues = openIssues.length + closedIssues.length;
|
||||
const totalIssues = filteredOpenIssues.length + filteredClosedIssues.length;
|
||||
const totalUnfilteredIssues = openIssues.length + closedIssues.length;
|
||||
const isFilteredEmpty =
|
||||
totalIssues === 0 && totalUnfilteredIssues > 0 && filterResult.hasActiveFilter;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
@@ -143,10 +190,21 @@ export function GitHubIssuesView() {
|
||||
>
|
||||
{/* Header */}
|
||||
<IssuesListHeader
|
||||
openCount={openIssues.length}
|
||||
closedCount={closedIssues.length}
|
||||
openCount={filteredOpenIssues.length}
|
||||
closedCount={filteredClosedIssues.length}
|
||||
totalOpenCount={openIssues.length}
|
||||
totalClosedCount={closedIssues.length}
|
||||
hasActiveFilter={filterResult.hasActiveFilter}
|
||||
refreshing={refreshing}
|
||||
onRefresh={refresh}
|
||||
compact={!!selectedIssue}
|
||||
filterProps={{
|
||||
stateFilter: filterState.stateFilter,
|
||||
selectedLabels: filterState.selectedLabels,
|
||||
availableLabels: filterResult.availableLabels,
|
||||
onStateFilterChange: handleStateFilterChange,
|
||||
onLabelsChange: handleLabelsChange,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Issues List */}
|
||||
@@ -154,15 +212,35 @@ export function GitHubIssuesView() {
|
||||
{totalIssues === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-6">
|
||||
<div className="p-4 rounded-full bg-muted/50 mb-4">
|
||||
<CircleDot className="h-8 w-8 text-muted-foreground" />
|
||||
{isFilteredEmpty ? (
|
||||
<SearchX className="h-8 w-8 text-muted-foreground" />
|
||||
) : (
|
||||
<CircleDot className="h-8 w-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-base font-medium mb-2">No Issues</h2>
|
||||
<p className="text-sm text-muted-foreground">This repository has no issues yet.</p>
|
||||
<h2 className="text-base font-medium mb-2">
|
||||
{isFilteredEmpty ? 'No Matching Issues' : 'No Issues'}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{isFilteredEmpty
|
||||
? 'No issues match your current filters.'
|
||||
: 'This repository has no issues yet.'}
|
||||
</p>
|
||||
{isFilteredEmpty && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearFilters}
|
||||
className="text-xs"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{/* Open Issues */}
|
||||
{openIssues.map((issue) => (
|
||||
{filteredOpenIssues.map((issue) => (
|
||||
<IssueRow
|
||||
key={issue.number}
|
||||
issue={issue}
|
||||
@@ -176,12 +254,12 @@ export function GitHubIssuesView() {
|
||||
))}
|
||||
|
||||
{/* Closed Issues Section */}
|
||||
{closedIssues.length > 0 && (
|
||||
{filteredClosedIssues.length > 0 && (
|
||||
<>
|
||||
<div className="px-4 py-2 bg-muted/30 text-xs font-medium text-muted-foreground">
|
||||
Closed Issues ({closedIssues.length})
|
||||
Closed Issues ({filteredClosedIssues.length})
|
||||
</div>
|
||||
{closedIssues.map((issue) => (
|
||||
{filteredClosedIssues.map((issue) => (
|
||||
<IssueRow
|
||||
key={issue.number}
|
||||
issue={issue}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { IssueRow } from './issue-row';
|
||||
export { IssueDetailPanel } from './issue-detail-panel';
|
||||
export { IssuesListHeader } from './issues-list-header';
|
||||
export { IssuesFilterControls } from './issues-filter-controls';
|
||||
export { CommentItem } from './comment-item';
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { ChevronDown, Tag, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IssuesStateFilter } from '../types';
|
||||
import { ISSUES_STATE_FILTER_OPTIONS } from '../types';
|
||||
|
||||
/** Maximum number of labels to display before showing "+N more" in normal layout */
|
||||
const VISIBLE_LABELS_LIMIT = 3;
|
||||
/** Maximum number of labels to display before showing "+N more" in compact layout */
|
||||
const VISIBLE_LABELS_LIMIT_COMPACT = 2;
|
||||
|
||||
interface IssuesFilterControlsProps {
|
||||
/** Current state filter value */
|
||||
stateFilter: IssuesStateFilter;
|
||||
/** Currently selected labels */
|
||||
selectedLabels: string[];
|
||||
/** Available labels to choose from (typically from useIssuesFilter result) */
|
||||
availableLabels: string[];
|
||||
/** Callback when state filter changes */
|
||||
onStateFilterChange: (filter: IssuesStateFilter) => void;
|
||||
/** Callback when labels selection changes */
|
||||
onLabelsChange: (labels: string[]) => void;
|
||||
/** Whether the controls are disabled (e.g., during loading) */
|
||||
disabled?: boolean;
|
||||
/** Whether to use compact layout (stacked vertically) */
|
||||
compact?: boolean;
|
||||
/** Additional class name for the container */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Human-readable labels for state filter options */
|
||||
const STATE_FILTER_LABELS: Record<IssuesStateFilter, string> = {
|
||||
open: 'Open',
|
||||
closed: 'Closed',
|
||||
all: 'All',
|
||||
};
|
||||
|
||||
export function IssuesFilterControls({
|
||||
stateFilter,
|
||||
selectedLabels,
|
||||
availableLabels,
|
||||
onStateFilterChange,
|
||||
onLabelsChange,
|
||||
disabled = false,
|
||||
compact = false,
|
||||
className,
|
||||
}: IssuesFilterControlsProps) {
|
||||
/**
|
||||
* Handles toggling a label in the selection.
|
||||
* If the label is already selected, it removes it; otherwise, it adds it.
|
||||
*/
|
||||
const handleLabelToggle = (label: string) => {
|
||||
const isSelected = selectedLabels.includes(label);
|
||||
if (isSelected) {
|
||||
onLabelsChange(selectedLabels.filter((l) => l !== label));
|
||||
} else {
|
||||
onLabelsChange([...selectedLabels, label]);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears all selected labels.
|
||||
*/
|
||||
const handleClearLabels = () => {
|
||||
onLabelsChange([]);
|
||||
};
|
||||
|
||||
const hasSelectedLabels = selectedLabels.length > 0;
|
||||
const hasAvailableLabels = availableLabels.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2', className)}>
|
||||
{/* Filter Controls Row */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* State Filter Select */}
|
||||
<Select
|
||||
value={stateFilter}
|
||||
onValueChange={(value) => onStateFilterChange(value as IssuesStateFilter)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={cn('h-8 text-sm', compact ? 'w-[90px]' : 'w-[110px]')}>
|
||||
<SelectValue placeholder="State" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ISSUES_STATE_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{STATE_FILTER_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Labels Filter Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild disabled={disabled || !hasAvailableLabels}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('h-8 gap-1.5', hasSelectedLabels && 'border-primary/50 bg-primary/5')}
|
||||
disabled={disabled || !hasAvailableLabels}
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5" />
|
||||
<span>Labels</span>
|
||||
{hasSelectedLabels && (
|
||||
<Badge variant="secondary" size="sm" className="ml-1 px-1.5 py-0">
|
||||
{selectedLabels.length}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56 max-h-64 overflow-y-auto">
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
<span>Filter by label</span>
|
||||
{hasSelectedLabels && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClearLabels}
|
||||
>
|
||||
<X className="h-3 w-3 mr-0.5" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{availableLabels.map((label) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={label}
|
||||
checked={selectedLabels.includes(label)}
|
||||
onCheckedChange={() => handleLabelToggle(label)}
|
||||
onSelect={(e) => e.preventDefault()} // Prevent dropdown from closing
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
{!hasAvailableLabels && (
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">No labels available</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Selected Labels Display - shown on separate row */}
|
||||
{hasSelectedLabels && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{selectedLabels
|
||||
.slice(0, compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT)
|
||||
.map((label) => (
|
||||
<Badge
|
||||
key={label}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1 cursor-pointer hover:bg-destructive/10 hover:border-destructive/50"
|
||||
onClick={() => handleLabelToggle(label)}
|
||||
>
|
||||
{label}
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</Badge>
|
||||
))}
|
||||
{selectedLabels.length >
|
||||
(compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT) && (
|
||||
<Badge variant="muted" size="sm">
|
||||
+
|
||||
{selectedLabels.length -
|
||||
(compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT)}{' '}
|
||||
more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,100 @@
|
||||
import { CircleDot, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IssuesStateFilter } from '../types';
|
||||
import { IssuesFilterControls } from './issues-filter-controls';
|
||||
|
||||
interface IssuesListHeaderProps {
|
||||
openCount: number;
|
||||
closedCount: number;
|
||||
/** Total open issues count (unfiltered) - used to show "X of Y" when filtered */
|
||||
totalOpenCount?: number;
|
||||
/** Total closed issues count (unfiltered) - used to show "X of Y" when filtered */
|
||||
totalClosedCount?: number;
|
||||
/** Whether any filter is currently active */
|
||||
hasActiveFilter?: boolean;
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
/** Whether the list is in compact mode (e.g., when detail panel is open) */
|
||||
compact?: boolean;
|
||||
/** Optional filter state and handlers - when provided, filter controls are rendered */
|
||||
filterProps?: {
|
||||
stateFilter: IssuesStateFilter;
|
||||
selectedLabels: string[];
|
||||
availableLabels: string[];
|
||||
onStateFilterChange: (filter: IssuesStateFilter) => void;
|
||||
onLabelsChange: (labels: string[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function IssuesListHeader({
|
||||
openCount,
|
||||
closedCount,
|
||||
totalOpenCount,
|
||||
totalClosedCount,
|
||||
hasActiveFilter = false,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
compact = false,
|
||||
filterProps,
|
||||
}: IssuesListHeaderProps) {
|
||||
const totalIssues = openCount + closedCount;
|
||||
|
||||
// Format the counts subtitle based on filter state
|
||||
const getCountsSubtitle = () => {
|
||||
if (totalIssues === 0) {
|
||||
return hasActiveFilter ? 'No matching issues' : 'No issues found';
|
||||
}
|
||||
|
||||
// When filters are active and we have total counts, show "X of Y" format
|
||||
if (hasActiveFilter && totalOpenCount !== undefined && totalClosedCount !== undefined) {
|
||||
const openText =
|
||||
openCount === totalOpenCount
|
||||
? `${openCount} open`
|
||||
: `${openCount} of ${totalOpenCount} open`;
|
||||
const closedText =
|
||||
closedCount === totalClosedCount
|
||||
? `${closedCount} closed`
|
||||
: `${closedCount} of ${totalClosedCount} closed`;
|
||||
return `${openText}, ${closedText}`;
|
||||
}
|
||||
|
||||
// Default format when no filters active
|
||||
return `${openCount} open, ${closedCount} closed`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-green-500/10">
|
||||
<CircleDot className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">Issues</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totalIssues === 0 ? 'No issues found' : `${openCount} open, ${closedCount} closed`}
|
||||
</p>
|
||||
<div className="border-b border-border">
|
||||
{/* Top row: Title and refresh button */}
|
||||
<div className="flex items-center justify-between p-4 pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-green-500/10">
|
||||
<CircleDot className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">Issues</h1>
|
||||
<p className="text-xs text-muted-foreground">{getCountsSubtitle()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
|
||||
{/* Filter controls row (optional) */}
|
||||
{filterProps && (
|
||||
<div className="px-4 pb-3 pt-1">
|
||||
<IssuesFilterControls
|
||||
stateFilter={filterProps.stateFilter}
|
||||
selectedLabels={filterProps.selectedLabels}
|
||||
availableLabels={filterProps.availableLabels}
|
||||
onStateFilterChange={filterProps.onStateFilterChange}
|
||||
onLabelsChange={filterProps.onLabelsChange}
|
||||
disabled={refreshing}
|
||||
compact={compact}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useGithubIssues } from './use-github-issues';
|
||||
export { useIssueValidation } from './use-issue-validation';
|
||||
export { useIssueComments } from './use-issue-comments';
|
||||
export { useIssuesFilter } from './use-issues-filter';
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { GitHubIssue, StoredValidation } from '@/lib/electron';
|
||||
import type { IssuesFilterState, IssuesFilterResult, IssuesValidationStatus } from '../types';
|
||||
import { isValidationStale } from '../utils';
|
||||
|
||||
/**
|
||||
* Determines the validation status of an issue based on its cached validation.
|
||||
*/
|
||||
function getValidationStatus(
|
||||
issueNumber: number,
|
||||
cachedValidations: Map<number, StoredValidation>
|
||||
): IssuesValidationStatus | null {
|
||||
const validation = cachedValidations.get(issueNumber);
|
||||
if (!validation) {
|
||||
return 'not_validated';
|
||||
}
|
||||
if (isValidationStale(validation.validatedAt)) {
|
||||
return 'stale';
|
||||
}
|
||||
return 'validated';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a search query matches an issue's searchable content.
|
||||
* Searches through title and body (case-insensitive).
|
||||
*/
|
||||
function matchesSearchQuery(issue: GitHubIssue, normalizedQuery: string): boolean {
|
||||
if (!normalizedQuery) return true;
|
||||
|
||||
const titleMatch = issue.title?.toLowerCase().includes(normalizedQuery);
|
||||
const bodyMatch = issue.body?.toLowerCase().includes(normalizedQuery);
|
||||
|
||||
return titleMatch || bodyMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an issue matches the state filter (open/closed/all).
|
||||
* Note: GitHub CLI returns state in uppercase (OPEN/CLOSED), so we compare case-insensitively.
|
||||
*/
|
||||
function matchesStateFilter(
|
||||
issue: GitHubIssue,
|
||||
stateFilter: IssuesFilterState['stateFilter']
|
||||
): boolean {
|
||||
if (stateFilter === 'all') return true;
|
||||
return issue.state.toLowerCase() === stateFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an issue matches any of the selected labels.
|
||||
* Returns true if no labels are selected (no filter) or if any selected label matches.
|
||||
*/
|
||||
function matchesLabels(issue: GitHubIssue, selectedLabels: string[]): boolean {
|
||||
if (selectedLabels.length === 0) return true;
|
||||
|
||||
const issueLabels = issue.labels.map((l) => l.name);
|
||||
return selectedLabels.some((label) => issueLabels.includes(label));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an issue matches any of the selected assignees.
|
||||
* Returns true if no assignees are selected (no filter) or if any selected assignee matches.
|
||||
*/
|
||||
function matchesAssignees(issue: GitHubIssue, selectedAssignees: string[]): boolean {
|
||||
if (selectedAssignees.length === 0) return true;
|
||||
|
||||
const issueAssignees = issue.assignees?.map((a) => a.login) ?? [];
|
||||
return selectedAssignees.some((assignee) => issueAssignees.includes(assignee));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an issue matches any of the selected milestones.
|
||||
* Returns true if no milestones are selected (no filter) or if any selected milestone matches.
|
||||
* Note: GitHub issues may not have milestone data in the current schema, this is a placeholder.
|
||||
*/
|
||||
function matchesMilestones(issue: GitHubIssue, selectedMilestones: string[]): boolean {
|
||||
if (selectedMilestones.length === 0) return true;
|
||||
|
||||
// GitHub issues in the current schema don't have milestone field
|
||||
// This is a placeholder for future milestone support
|
||||
// For now, issues with no milestone won't match if a milestone filter is active
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an issue matches the validation status filter.
|
||||
*/
|
||||
function matchesValidationStatus(
|
||||
issue: GitHubIssue,
|
||||
validationStatusFilter: IssuesValidationStatus | null,
|
||||
cachedValidations: Map<number, StoredValidation>
|
||||
): boolean {
|
||||
if (!validationStatusFilter) return true;
|
||||
|
||||
const status = getValidationStatus(issue.number, cachedValidations);
|
||||
return status === validationStatusFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all unique labels from a list of issues.
|
||||
*/
|
||||
function extractAvailableLabels(issues: GitHubIssue[]): string[] {
|
||||
const labelsSet = new Set<string>();
|
||||
for (const issue of issues) {
|
||||
for (const label of issue.labels) {
|
||||
labelsSet.add(label.name);
|
||||
}
|
||||
}
|
||||
return Array.from(labelsSet).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all unique assignees from a list of issues.
|
||||
*/
|
||||
function extractAvailableAssignees(issues: GitHubIssue[]): string[] {
|
||||
const assigneesSet = new Set<string>();
|
||||
for (const issue of issues) {
|
||||
for (const assignee of issue.assignees ?? []) {
|
||||
assigneesSet.add(assignee.login);
|
||||
}
|
||||
}
|
||||
return Array.from(assigneesSet).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all unique milestones from a list of issues.
|
||||
* Note: Currently returns empty array as milestone is not in the GitHubIssue schema.
|
||||
*/
|
||||
function extractAvailableMilestones(_issues: GitHubIssue[]): string[] {
|
||||
// GitHub issues in the current schema don't have milestone field
|
||||
// This is a placeholder for future milestone support
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if any filter is currently active.
|
||||
*/
|
||||
function hasActiveFilterCheck(filterState: IssuesFilterState): boolean {
|
||||
const {
|
||||
searchQuery,
|
||||
stateFilter,
|
||||
selectedLabels,
|
||||
selectedAssignees,
|
||||
selectedMilestones,
|
||||
validationStatusFilter,
|
||||
} = filterState;
|
||||
|
||||
// Note: stateFilter 'open' is the default, so we consider it "not active" for UI purposes
|
||||
// Only 'closed' or 'all' are considered active filters
|
||||
const hasStateFilter = stateFilter !== 'open';
|
||||
const hasSearchQuery = searchQuery.trim().length > 0;
|
||||
const hasLabelFilter = selectedLabels.length > 0;
|
||||
const hasAssigneeFilter = selectedAssignees.length > 0;
|
||||
const hasMilestoneFilter = selectedMilestones.length > 0;
|
||||
const hasValidationFilter = validationStatusFilter !== null;
|
||||
|
||||
return (
|
||||
hasSearchQuery ||
|
||||
hasStateFilter ||
|
||||
hasLabelFilter ||
|
||||
hasAssigneeFilter ||
|
||||
hasMilestoneFilter ||
|
||||
hasValidationFilter
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to filter GitHub issues based on the current filter state.
|
||||
*
|
||||
* This hook follows the same pattern as useGraphFilter but is tailored for GitHub issues.
|
||||
* It computes matched issues and extracts available filter options from all issues.
|
||||
*
|
||||
* @param issues - Combined array of all issues (open + closed) to filter
|
||||
* @param filterState - Current filter state including search, labels, assignees, etc.
|
||||
* @param cachedValidations - Map of issue numbers to their cached validation results
|
||||
* @returns Filter result containing matched issue numbers and available filter options
|
||||
*/
|
||||
export function useIssuesFilter(
|
||||
issues: GitHubIssue[],
|
||||
filterState: IssuesFilterState,
|
||||
cachedValidations: Map<number, StoredValidation> = new Map()
|
||||
): IssuesFilterResult {
|
||||
const {
|
||||
searchQuery,
|
||||
stateFilter,
|
||||
selectedLabels,
|
||||
selectedAssignees,
|
||||
selectedMilestones,
|
||||
validationStatusFilter,
|
||||
} = filterState;
|
||||
|
||||
return useMemo(() => {
|
||||
// Extract available options from all issues (for filter dropdown population)
|
||||
const availableLabels = extractAvailableLabels(issues);
|
||||
const availableAssignees = extractAvailableAssignees(issues);
|
||||
const availableMilestones = extractAvailableMilestones(issues);
|
||||
|
||||
// Check if any filter is active
|
||||
const hasActiveFilter = hasActiveFilterCheck(filterState);
|
||||
|
||||
// Normalize search query for case-insensitive matching
|
||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||
|
||||
// Filter issues based on all criteria - return matched issues directly
|
||||
// This eliminates the redundant O(n) filtering operation in the consuming component
|
||||
const matchedIssues: GitHubIssue[] = [];
|
||||
|
||||
for (const issue of issues) {
|
||||
// All conditions must be true for a match
|
||||
const matchesAllFilters =
|
||||
matchesSearchQuery(issue, normalizedQuery) &&
|
||||
matchesStateFilter(issue, stateFilter) &&
|
||||
matchesLabels(issue, selectedLabels) &&
|
||||
matchesAssignees(issue, selectedAssignees) &&
|
||||
matchesMilestones(issue, selectedMilestones) &&
|
||||
matchesValidationStatus(issue, validationStatusFilter, cachedValidations);
|
||||
|
||||
if (matchesAllFilters) {
|
||||
matchedIssues.push(issue);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matchedIssues,
|
||||
availableLabels,
|
||||
availableAssignees,
|
||||
availableMilestones,
|
||||
hasActiveFilter,
|
||||
matchedCount: matchedIssues.length,
|
||||
};
|
||||
}, [
|
||||
issues,
|
||||
searchQuery,
|
||||
stateFilter,
|
||||
selectedLabels,
|
||||
selectedAssignees,
|
||||
selectedMilestones,
|
||||
validationStatusFilter,
|
||||
cachedValidations,
|
||||
]);
|
||||
}
|
||||
@@ -1,6 +1,111 @@
|
||||
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
|
||||
import type { ModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types';
|
||||
|
||||
// ============================================================================
|
||||
// Issues Filter State Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Available sort columns for issues list
|
||||
*/
|
||||
export const ISSUES_SORT_COLUMNS = [
|
||||
'title',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'comments',
|
||||
'number',
|
||||
] as const;
|
||||
|
||||
export type IssuesSortColumn = (typeof ISSUES_SORT_COLUMNS)[number];
|
||||
|
||||
/**
|
||||
* Sort direction options
|
||||
*/
|
||||
export type IssuesSortDirection = 'asc' | 'desc';
|
||||
|
||||
/**
|
||||
* Available issue state filter values
|
||||
*/
|
||||
export const ISSUES_STATE_FILTER_OPTIONS = ['open', 'closed', 'all'] as const;
|
||||
|
||||
export type IssuesStateFilter = (typeof ISSUES_STATE_FILTER_OPTIONS)[number];
|
||||
|
||||
/**
|
||||
* Validation status filter values for filtering issues by validation state
|
||||
*/
|
||||
export const ISSUES_VALIDATION_STATUS_OPTIONS = ['validated', 'not_validated', 'stale'] as const;
|
||||
|
||||
export type IssuesValidationStatus = (typeof ISSUES_VALIDATION_STATUS_OPTIONS)[number];
|
||||
|
||||
/**
|
||||
* Sort configuration for issues list
|
||||
*/
|
||||
export interface IssuesSortConfig {
|
||||
column: IssuesSortColumn;
|
||||
direction: IssuesSortDirection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main filter state interface for the GitHub Issues view
|
||||
*
|
||||
* This interface defines all filterable/sortable state for the issues list.
|
||||
* It follows the same pattern as GraphFilterState but is tailored for GitHub issues.
|
||||
*/
|
||||
export interface IssuesFilterState {
|
||||
/** Search query for filtering by issue title or body */
|
||||
searchQuery: string;
|
||||
/** Filter by issue state (open/closed/all) */
|
||||
stateFilter: IssuesStateFilter;
|
||||
/** Filter by selected labels (matches any) */
|
||||
selectedLabels: string[];
|
||||
/** Filter by selected assignees (matches any) */
|
||||
selectedAssignees: string[];
|
||||
/** Filter by selected milestones (matches any) */
|
||||
selectedMilestones: string[];
|
||||
/** Filter by validation status */
|
||||
validationStatusFilter: IssuesValidationStatus | null;
|
||||
/** Current sort configuration */
|
||||
sortConfig: IssuesSortConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of applying filters to the issues list
|
||||
*/
|
||||
export interface IssuesFilterResult {
|
||||
/** Array of GitHubIssue objects that match the current filters */
|
||||
matchedIssues: GitHubIssue[];
|
||||
/** Available labels from all issues (for filter dropdown population) */
|
||||
availableLabels: string[];
|
||||
/** Available assignees from all issues (for filter dropdown population) */
|
||||
availableAssignees: string[];
|
||||
/** Available milestones from all issues (for filter dropdown population) */
|
||||
availableMilestones: string[];
|
||||
/** Whether any filter is currently active */
|
||||
hasActiveFilter: boolean;
|
||||
/** Total count of matched issues */
|
||||
matchedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default values for IssuesFilterState
|
||||
*/
|
||||
export const DEFAULT_ISSUES_FILTER_STATE: IssuesFilterState = {
|
||||
searchQuery: '',
|
||||
stateFilter: 'open',
|
||||
selectedLabels: [],
|
||||
selectedAssignees: [],
|
||||
selectedMilestones: [],
|
||||
validationStatusFilter: null,
|
||||
sortConfig: {
|
||||
column: 'updated_at',
|
||||
direction: 'desc',
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Component Props Types
|
||||
// ============================================================================
|
||||
|
||||
export interface IssueRowProps {
|
||||
issue: GitHubIssue;
|
||||
isSelected: boolean;
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useAppStore } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
HeaderActionsPanel,
|
||||
HeaderActionsPanelTrigger,
|
||||
} from '@/components/ui/header-actions-panel';
|
||||
import {
|
||||
RefreshCw,
|
||||
FileText,
|
||||
@@ -60,6 +64,9 @@ export function MemoryView() {
|
||||
const [newMemoryName, setNewMemoryName] = useState('');
|
||||
const [newMemoryContent, setNewMemoryContent] = useState('');
|
||||
|
||||
// Actions panel state (for tablet/mobile)
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
|
||||
// Get memory directory path
|
||||
const getMemoryPath = useCallback(() => {
|
||||
if (!currentProject) return null;
|
||||
@@ -310,27 +317,66 @@ export function MemoryView() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadMemoryFiles}
|
||||
data-testid="refresh-memory-button"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setIsCreateMemoryOpen(true)}
|
||||
data-testid="create-memory-button"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
Create Memory File
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Desktop: show actions inline */}
|
||||
<div className="hidden lg:flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadMemoryFiles}
|
||||
data-testid="refresh-memory-button"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setIsCreateMemoryOpen(true)}
|
||||
data-testid="create-memory-button"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
Create Memory File
|
||||
</Button>
|
||||
</div>
|
||||
{/* Tablet/Mobile: show trigger for actions panel */}
|
||||
<HeaderActionsPanelTrigger
|
||||
isOpen={showActionsPanel}
|
||||
onToggle={() => setShowActionsPanel(!showActionsPanel)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Panel (tablet/mobile) */}
|
||||
<HeaderActionsPanel
|
||||
isOpen={showActionsPanel}
|
||||
onClose={() => setShowActionsPanel(false)}
|
||||
title="Memory Actions"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
loadMemoryFiles();
|
||||
setShowActionsPanel(false);
|
||||
}}
|
||||
data-testid="refresh-memory-button-mobile"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
setIsCreateMemoryOpen(true);
|
||||
setShowActionsPanel(false);
|
||||
}}
|
||||
data-testid="create-memory-button-mobile"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
Create Memory File
|
||||
</Button>
|
||||
</HeaderActionsPanel>
|
||||
|
||||
{/* Main content area with file list and editor */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Panel - File List */}
|
||||
|
||||
272
apps/ui/src/components/views/notifications-view.tsx
Normal file
272
apps/ui/src/components/views/notifications-view.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Notifications View - Full page view for all notifications
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useNotificationsStore } from '@/store/notifications-store';
|
||||
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Bell, Check, CheckCheck, Trash2, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import type { Notification } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
|
||||
*/
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
|
||||
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
|
||||
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function NotificationsView() {
|
||||
const { currentProject } = useAppStore();
|
||||
const projectPath = currentProject?.path ?? null;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isLoading,
|
||||
error,
|
||||
setNotifications,
|
||||
setUnreadCount,
|
||||
markAsRead,
|
||||
dismissNotification,
|
||||
markAllAsRead,
|
||||
dismissAll,
|
||||
} = useNotificationsStore();
|
||||
|
||||
// Load notifications when project changes
|
||||
useLoadNotifications(projectPath);
|
||||
|
||||
// Subscribe to real-time notification events
|
||||
useNotificationEvents(projectPath);
|
||||
|
||||
const handleMarkAsRead = useCallback(
|
||||
async (notificationId: string) => {
|
||||
if (!projectPath) return;
|
||||
|
||||
// Optimistic update
|
||||
markAsRead(notificationId);
|
||||
|
||||
// Sync with server
|
||||
const api = getHttpApiClient();
|
||||
await api.notifications.markAsRead(projectPath, notificationId);
|
||||
},
|
||||
[projectPath, markAsRead]
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
async (notificationId: string) => {
|
||||
if (!projectPath) return;
|
||||
|
||||
// Optimistic update
|
||||
dismissNotification(notificationId);
|
||||
|
||||
// Sync with server
|
||||
const api = getHttpApiClient();
|
||||
await api.notifications.dismiss(projectPath, notificationId);
|
||||
},
|
||||
[projectPath, dismissNotification]
|
||||
);
|
||||
|
||||
const handleMarkAllAsRead = useCallback(async () => {
|
||||
if (!projectPath) return;
|
||||
|
||||
// Optimistic update
|
||||
markAllAsRead();
|
||||
|
||||
// Sync with server
|
||||
const api = getHttpApiClient();
|
||||
await api.notifications.markAsRead(projectPath);
|
||||
}, [projectPath, markAllAsRead]);
|
||||
|
||||
const handleDismissAll = useCallback(async () => {
|
||||
if (!projectPath) return;
|
||||
|
||||
// Optimistic update
|
||||
dismissAll();
|
||||
|
||||
// Sync with server
|
||||
const api = getHttpApiClient();
|
||||
await api.notifications.dismiss(projectPath);
|
||||
}, [projectPath, dismissAll]);
|
||||
|
||||
const handleNotificationClick = useCallback(
|
||||
(notification: Notification) => {
|
||||
// Mark as read
|
||||
handleMarkAsRead(notification.id);
|
||||
|
||||
// Navigate to the relevant view based on notification type
|
||||
if (notification.featureId) {
|
||||
// Navigate to board view - feature will be selected
|
||||
navigate({ to: '/board' });
|
||||
}
|
||||
},
|
||||
[handleMarkAsRead, navigate]
|
||||
);
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'feature_waiting_approval':
|
||||
return <Bell className="h-5 w-5 text-yellow-500" />;
|
||||
case 'feature_verified':
|
||||
return <Check className="h-5 w-5 text-green-500" />;
|
||||
case 'spec_regeneration_complete':
|
||||
return <Check className="h-5 w-5 text-blue-500" />;
|
||||
case 'agent_complete':
|
||||
return <Check className="h-5 w-5 text-purple-500" />;
|
||||
default:
|
||||
return <Bell className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<Bell className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">Select a project to view notifications</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-muted-foreground mt-4">Loading notifications...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<p className="text-destructive">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col p-6 overflow-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Notifications</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{unreadCount > 0 ? `${unreadCount} unread` : 'All caught up!'}
|
||||
</p>
|
||||
</div>
|
||||
{notifications.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={unreadCount === 0}
|
||||
>
|
||||
<CheckCheck className="h-4 w-4 mr-2" />
|
||||
Mark all as read
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDismissAll}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Dismiss all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<Card className="flex-1">
|
||||
<CardContent className="flex flex-col items-center justify-center h-full min-h-[300px]">
|
||||
<Bell className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground text-lg">No notifications</p>
|
||||
<p className="text-muted-foreground text-sm mt-2">
|
||||
Notifications will appear here when features are ready for review or operations
|
||||
complete.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{notifications.map((notification) => (
|
||||
<Card
|
||||
key={notification.id}
|
||||
className={`transition-colors cursor-pointer hover:bg-accent/50 ${
|
||||
!notification.read ? 'border-primary/50 bg-primary/5' : ''
|
||||
}`}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
>
|
||||
<CardContent className="flex items-start gap-4 p-4">
|
||||
<div className="flex-shrink-0 mt-1">{getNotificationIcon(notification.type)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">{notification.title}</CardTitle>
|
||||
{!notification.read && (
|
||||
<span className="h-2 w-2 rounded-full bg-primary flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="mt-1">{notification.message}</CardDescription>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{formatRelativeTime(new Date(notification.createdAt))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{!notification.read && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkAsRead(notification.id);
|
||||
}}
|
||||
title="Mark as read"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDismiss(notification.id);
|
||||
}}
|
||||
title="Dismiss"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{notification.featureId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleNotificationClick(notification);
|
||||
}}
|
||||
title="Go to feature"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PROJECT_SETTINGS_NAV_ITEMS } from '../config/navigation';
|
||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||
|
||||
interface ProjectSettingsNavigationProps {
|
||||
activeSection: ProjectSettingsViewId;
|
||||
onNavigate: (sectionId: ProjectSettingsViewId) => void;
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function ProjectSettingsNavigation({
|
||||
activeSection,
|
||||
onNavigate,
|
||||
isOpen = true,
|
||||
onClose,
|
||||
}: ProjectSettingsNavigationProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Mobile backdrop overlay - only shown when isOpen is true on mobile */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
|
||||
onClick={onClose}
|
||||
data-testid="project-settings-nav-backdrop"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Navigation sidebar */}
|
||||
<nav
|
||||
className={cn(
|
||||
// Mobile: fixed position overlay with slide transition from right
|
||||
'fixed inset-y-0 right-0 w-72 z-30',
|
||||
'transition-transform duration-200 ease-out',
|
||||
// Hide on mobile when closed, show when open
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full',
|
||||
// Desktop: relative position in layout, always visible
|
||||
'lg:relative lg:w-64 lg:z-auto lg:translate-x-0',
|
||||
'shrink-0 overflow-y-auto',
|
||||
'border-l border-border/50 lg:border-l-0 lg:border-r',
|
||||
'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl',
|
||||
// Desktop background
|
||||
'lg:from-card/80 lg:via-card/60 lg:to-card/40'
|
||||
)}
|
||||
>
|
||||
{/* Mobile close button */}
|
||||
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-border/50">
|
||||
<span className="text-sm font-semibold text-foreground">Navigation</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close navigation menu"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="sticky top-0 p-4 space-y-1">
|
||||
{PROJECT_SETTINGS_NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeSection === item.id;
|
||||
const isDanger = item.id === 'danger';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
className={cn(
|
||||
'group w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ease-out text-left relative overflow-hidden',
|
||||
isActive
|
||||
? [
|
||||
isDanger
|
||||
? 'bg-gradient-to-r from-red-500/15 via-red-500/10 to-red-600/5'
|
||||
: 'bg-gradient-to-r from-brand-500/15 via-brand-500/10 to-brand-600/5',
|
||||
'text-foreground',
|
||||
isDanger ? 'border border-red-500/25' : 'border border-brand-500/25',
|
||||
isDanger ? 'shadow-sm shadow-red-500/5' : 'shadow-sm shadow-brand-500/5',
|
||||
]
|
||||
: [
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50',
|
||||
'border border-transparent hover:border-border/40',
|
||||
],
|
||||
'hover:scale-[1.01] active:scale-[0.98]'
|
||||
)}
|
||||
>
|
||||
{/* Active indicator bar */}
|
||||
{isActive && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-y-0 left-0 w-0.5 rounded-r-full',
|
||||
isDanger
|
||||
? 'bg-gradient-to-b from-red-400 via-red-500 to-red-600'
|
||||
: 'bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-4 h-4 shrink-0 transition-all duration-200',
|
||||
isActive
|
||||
? isDanger
|
||||
? 'text-red-500'
|
||||
: 'text-brand-500'
|
||||
: isDanger
|
||||
? 'group-hover:text-red-400 group-hover:scale-110'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||
)}
|
||||
/>
|
||||
<span className={cn(isDanger && !isActive && 'text-red-400/70')}>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { User, GitBranch, Palette, AlertTriangle } from 'lucide-react';
|
||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||
|
||||
export interface ProjectNavigationItem {
|
||||
id: ProjectSettingsViewId;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
||||
{ id: 'identity', label: 'Identity', icon: User },
|
||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
export { useProjectSettingsView, type ProjectSettingsViewId } from './use-project-settings-view';
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'danger';
|
||||
|
||||
interface UseProjectSettingsViewOptions {
|
||||
initialView?: ProjectSettingsViewId;
|
||||
}
|
||||
|
||||
export function useProjectSettingsView({
|
||||
initialView = 'identity',
|
||||
}: UseProjectSettingsViewOptions = {}) {
|
||||
const [activeView, setActiveView] = useState<ProjectSettingsViewId>(initialView);
|
||||
|
||||
const navigateTo = useCallback((viewId: ProjectSettingsViewId) => {
|
||||
setActiveView(viewId);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeView,
|
||||
navigateTo,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { ProjectSettingsView } from './project-settings-view';
|
||||
export { ProjectIdentitySection } from './project-identity-section';
|
||||
export { ProjectThemeSection } from './project-theme-section';
|
||||
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
||||
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Palette, Upload, X, ImageIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { IconPicker } from '@/components/layout/project-switcher/components/icon-picker';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface ProjectIdentitySectionProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export function ProjectIdentitySection({ project }: ProjectIdentitySectionProps) {
|
||||
const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore();
|
||||
const [projectName, setProjectNameLocal] = useState(project.name || '');
|
||||
const [projectIcon, setProjectIconLocal] = useState<string | null>(project.icon || null);
|
||||
const [customIconPath, setCustomIconPathLocal] = useState<string | null>(
|
||||
project.customIconPath || null
|
||||
);
|
||||
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sync local state when project changes
|
||||
useEffect(() => {
|
||||
setProjectNameLocal(project.name || '');
|
||||
setProjectIconLocal(project.icon || null);
|
||||
setCustomIconPathLocal(project.customIconPath || null);
|
||||
}, [project]);
|
||||
|
||||
// Auto-save when values change
|
||||
const handleNameChange = (name: string) => {
|
||||
setProjectNameLocal(name);
|
||||
if (name.trim() && name.trim() !== project.name) {
|
||||
setProjectName(project.id, name.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleIconChange = (icon: string | null) => {
|
||||
setProjectIconLocal(icon);
|
||||
setProjectIcon(project.id, icon);
|
||||
};
|
||||
|
||||
const handleCustomIconChange = (path: string | null) => {
|
||||
setCustomIconPathLocal(path);
|
||||
setProjectCustomIcon(project.id, path);
|
||||
// Clear Lucide icon when custom icon is set
|
||||
if (path) {
|
||||
setProjectIconLocal(null);
|
||||
setProjectIcon(project.id, null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomIconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
toast.error('Invalid file type', {
|
||||
description: 'Please upload a PNG, JPG, GIF, or WebP image.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 2MB for icons)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('File too large', {
|
||||
description: 'Please upload an image smaller than 2MB.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploadingIcon(true);
|
||||
try {
|
||||
// Convert to base64
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const base64Data = reader.result as string;
|
||||
const result = await getHttpApiClient().saveImageToTemp(
|
||||
base64Data,
|
||||
`project-icon-${file.name}`,
|
||||
file.type,
|
||||
project.path
|
||||
);
|
||||
if (result.success && result.path) {
|
||||
handleCustomIconChange(result.path);
|
||||
toast.success('Icon uploaded successfully');
|
||||
} else {
|
||||
toast.error('Failed to upload icon', {
|
||||
description: result.error || 'Please try again.',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload icon', {
|
||||
description: 'Network error. Please try again.',
|
||||
});
|
||||
} finally {
|
||||
setIsUploadingIcon(false);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error('Failed to read file', {
|
||||
description: 'Please try again with a different file.',
|
||||
});
|
||||
setIsUploadingIcon(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch {
|
||||
toast.error('Failed to upload icon');
|
||||
setIsUploadingIcon(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCustomIcon = () => {
|
||||
handleCustomIconChange(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Palette className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Project Identity</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Customize how your project appears in the sidebar and project switcher.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Project Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-name-settings">Project Name</Label>
|
||||
<Input
|
||||
id="project-name-settings"
|
||||
value={projectName}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Enter project name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project Icon */}
|
||||
<div className="space-y-2">
|
||||
<Label>Project Icon</Label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Choose a preset icon or upload a custom image
|
||||
</p>
|
||||
|
||||
{/* Custom Icon Upload */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{customIconPath ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={getAuthenticatedImageUrl(customIconPath, project.path)}
|
||||
alt="Custom project icon"
|
||||
className="w-12 h-12 rounded-lg object-cover border border-border"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveCustomIcon}
|
||||
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg border border-dashed border-border flex items-center justify-center bg-accent/30">
|
||||
<ImageIcon className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
onChange={handleCustomIconUpload}
|
||||
className="hidden"
|
||||
id="custom-icon-upload"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploadingIcon}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
PNG, JPG, GIF or WebP. Max 2MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preset Icon Picker - only show if no custom icon */}
|
||||
{!customIconPath && (
|
||||
<IconPicker selectedIcon={projectIcon} onSelectIcon={handleIconChange} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Settings, FolderOpen, Menu, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ProjectIdentitySection } from './project-identity-section';
|
||||
import { ProjectThemeSection } from './project-theme-section';
|
||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
||||
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||
import { useProjectSettingsView } from './hooks/use-project-settings-view';
|
||||
import type { Project as ElectronProject } from '@/lib/electron';
|
||||
|
||||
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
|
||||
const LG_BREAKPOINT = 1024;
|
||||
|
||||
// Convert to the shared types used by components
|
||||
interface SettingsProject {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
theme?: string;
|
||||
icon?: string | null;
|
||||
customIconPath?: string | null;
|
||||
}
|
||||
|
||||
export function ProjectSettingsView() {
|
||||
const { currentProject, moveProjectToTrash } = useAppStore();
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// Use project settings view navigation hook
|
||||
const { activeView, navigateTo } = useProjectSettingsView();
|
||||
|
||||
// Mobile navigation state - default to showing on desktop, hidden on mobile
|
||||
const [showNavigation, setShowNavigation] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.innerWidth >= LG_BREAKPOINT;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Auto-close navigation on mobile when a section is selected
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && window.innerWidth < LG_BREAKPOINT) {
|
||||
setShowNavigation(false);
|
||||
}
|
||||
}, [activeView]);
|
||||
|
||||
// Handle window resize to show/hide navigation appropriately
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth >= LG_BREAKPOINT) {
|
||||
setShowNavigation(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Convert electron Project to settings-view Project type
|
||||
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
|
||||
if (!project) return null;
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
path: project.path,
|
||||
theme: project.theme,
|
||||
icon: project.icon,
|
||||
customIconPath: project.customIconPath,
|
||||
};
|
||||
};
|
||||
|
||||
const settingsProject = convertProject(currentProject);
|
||||
|
||||
// Render the active section based on current view
|
||||
const renderActiveSection = () => {
|
||||
if (!currentProject) return null;
|
||||
|
||||
switch (activeView) {
|
||||
case 'identity':
|
||||
return <ProjectIdentitySection project={currentProject} />;
|
||||
case 'theme':
|
||||
return <ProjectThemeSection project={currentProject} />;
|
||||
case 'worktrees':
|
||||
return <WorktreePreferencesSection project={currentProject} />;
|
||||
case 'danger':
|
||||
return (
|
||||
<DangerZoneSection
|
||||
project={settingsProject}
|
||||
onDeleteClick={() => setShowDeleteDialog(true)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <ProjectIdentitySection project={currentProject} />;
|
||||
}
|
||||
};
|
||||
|
||||
// Show message if no project is selected
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||
data-testid="project-settings-view"
|
||||
>
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-muted/50 flex items-center justify-center">
|
||||
<FolderOpen className="w-8 h-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">No Project Selected</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a project from the sidebar to configure project-specific settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||
data-testid="project-settings-view"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Project Settings</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure settings for {currentProject.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile menu button - far right */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowNavigation(!showNavigation)}
|
||||
className="lg:hidden h-8 w-8 p-0"
|
||||
aria-label={showNavigation ? 'Close navigation menu' : 'Open navigation menu'}
|
||||
>
|
||||
{showNavigation ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content Area with Sidebar */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Side Navigation */}
|
||||
<ProjectSettingsNavigation
|
||||
activeSection={activeView}
|
||||
onNavigate={navigateTo}
|
||||
isOpen={showNavigation}
|
||||
onClose={() => setShowNavigation(false)}
|
||||
/>
|
||||
|
||||
{/* Content Panel - Shows only the active section */}
|
||||
<div className="flex-1 overflow-y-auto p-4 lg:p-8">
|
||||
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Project Confirmation Dialog */}
|
||||
<DeleteProjectDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
project={currentProject}
|
||||
onConfirm={moveProjectToTrash}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Palette, Moon, Sun } from 'lucide-react';
|
||||
import { darkThemes, lightThemes, type Theme } from '@/config/theme-options';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface ProjectThemeSectionProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export function ProjectThemeSection({ project }: ProjectThemeSectionProps) {
|
||||
const { theme: globalTheme, setProjectTheme } = useAppStore();
|
||||
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
|
||||
|
||||
const projectTheme = project.theme as Theme | undefined;
|
||||
const hasCustomTheme = projectTheme !== undefined;
|
||||
const effectiveTheme = projectTheme || globalTheme;
|
||||
|
||||
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
|
||||
|
||||
const handleThemeChange = (theme: Theme) => {
|
||||
setProjectTheme(project.id, theme);
|
||||
};
|
||||
|
||||
const handleUseGlobalTheme = (checked: boolean) => {
|
||||
if (checked) {
|
||||
// Clear project theme to use global
|
||||
setProjectTheme(project.id, null);
|
||||
} else {
|
||||
// Set project theme to current global theme
|
||||
setProjectTheme(project.id, globalTheme);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Palette className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Theme</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Customize the theme for this project.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Use Global Theme Toggle */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="use-global-theme"
|
||||
checked={!hasCustomTheme}
|
||||
onCheckedChange={handleUseGlobalTheme}
|
||||
className="mt-1"
|
||||
data-testid="use-global-theme-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="use-global-theme"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<Palette className="w-4 h-4 text-brand-500" />
|
||||
Use Global Theme
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
When enabled, this project will use the global theme setting. Disable to set a
|
||||
project-specific theme.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Selection - only show if not using global theme */}
|
||||
{hasCustomTheme && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Project Theme</Label>
|
||||
{/* Dark/Light Tabs */}
|
||||
<div className="flex gap-1 p-1 rounded-lg bg-accent/30">
|
||||
<button
|
||||
onClick={() => setActiveTab('dark')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200',
|
||||
activeTab === 'dark'
|
||||
? 'bg-brand-500 text-white shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Moon className="w-3.5 h-3.5" />
|
||||
Dark
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('light')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200',
|
||||
activeTab === 'light'
|
||||
? 'bg-brand-500 text-white shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Sun className="w-3.5 h-3.5" />
|
||||
Light
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{themesToShow.map(({ value, label, Icon, testId, color }) => {
|
||||
const isActive = effectiveTheme === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => handleThemeChange(value)}
|
||||
className={cn(
|
||||
'group flex items-center justify-center gap-2.5 px-4 py-3.5 rounded-xl',
|
||||
'text-sm font-medium transition-all duration-200 ease-out',
|
||||
isActive
|
||||
? [
|
||||
'bg-gradient-to-br from-brand-500/15 to-brand-600/10',
|
||||
'border-2 border-brand-500/40',
|
||||
'text-foreground',
|
||||
'shadow-md shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
'bg-accent/30 hover:bg-accent/50',
|
||||
'border border-border/50 hover:border-border',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:shadow-sm',
|
||||
],
|
||||
'hover:scale-[1.02] active:scale-[0.98]'
|
||||
)}
|
||||
data-testid={`project-${testId}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 transition-all duration-200" style={{ color }} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info when using global theme */}
|
||||
{!hasCustomTheme && (
|
||||
<div className="rounded-xl border border-border/30 bg-muted/30 p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This project is using the global theme:{' '}
|
||||
<span className="font-medium text-foreground">{globalTheme}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
|
||||
import {
|
||||
GitBranch,
|
||||
Terminal,
|
||||
FileCode,
|
||||
Save,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
PanelBottomClose,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface WorktreePreferencesSectionProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
interface InitScriptResponse {
|
||||
success: boolean;
|
||||
exists: boolean;
|
||||
content: string;
|
||||
path: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function WorktreePreferencesSection({ project }: WorktreePreferencesSectionProps) {
|
||||
const globalUseWorktrees = useAppStore((s) => s.useWorktrees);
|
||||
const getProjectUseWorktrees = useAppStore((s) => s.getProjectUseWorktrees);
|
||||
const setProjectUseWorktrees = useAppStore((s) => s.setProjectUseWorktrees);
|
||||
const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
|
||||
const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
|
||||
const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch);
|
||||
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
|
||||
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
|
||||
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
|
||||
|
||||
// Get effective worktrees setting (project override or global fallback)
|
||||
const projectUseWorktrees = getProjectUseWorktrees(project.path);
|
||||
const effectiveUseWorktrees = projectUseWorktrees ?? globalUseWorktrees;
|
||||
|
||||
const [scriptContent, setScriptContent] = useState('');
|
||||
const [originalContent, setOriginalContent] = useState('');
|
||||
const [scriptExists, setScriptExists] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Get the current settings for this project
|
||||
const showIndicator = getShowInitScriptIndicator(project.path);
|
||||
const defaultDeleteBranch = getDefaultDeleteBranch(project.path);
|
||||
const autoDismiss = getAutoDismissInitScriptIndicator(project.path);
|
||||
|
||||
// Check if there are unsaved changes
|
||||
const hasChanges = scriptContent !== originalContent;
|
||||
|
||||
// Load project settings (including useWorktrees) when project changes
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
const currentPath = project.path;
|
||||
|
||||
const loadProjectSettings = async () => {
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
const response = await httpClient.settings.getProject(currentPath);
|
||||
|
||||
// Avoid updating state if component unmounted or project changed
|
||||
if (isCancelled) return;
|
||||
|
||||
if (response.success && response.settings) {
|
||||
// Sync useWorktrees to store if it has a value
|
||||
if (response.settings.useWorktrees !== undefined) {
|
||||
setProjectUseWorktrees(currentPath, response.settings.useWorktrees);
|
||||
}
|
||||
// Also sync other settings to store
|
||||
if (response.settings.showInitScriptIndicator !== undefined) {
|
||||
setShowInitScriptIndicator(currentPath, response.settings.showInitScriptIndicator);
|
||||
}
|
||||
if (response.settings.defaultDeleteBranchWithWorktree !== undefined) {
|
||||
setDefaultDeleteBranch(currentPath, response.settings.defaultDeleteBranchWithWorktree);
|
||||
}
|
||||
if (response.settings.autoDismissInitScriptIndicator !== undefined) {
|
||||
setAutoDismissInitScriptIndicator(
|
||||
currentPath,
|
||||
response.settings.autoDismissInitScriptIndicator
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCancelled) {
|
||||
console.error('Failed to load project settings:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadProjectSettings();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [
|
||||
project.path,
|
||||
setProjectUseWorktrees,
|
||||
setShowInitScriptIndicator,
|
||||
setDefaultDeleteBranch,
|
||||
setAutoDismissInitScriptIndicator,
|
||||
]);
|
||||
|
||||
// Load init script content when project changes
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
const currentPath = project.path;
|
||||
|
||||
const loadInitScript = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await apiGet<InitScriptResponse>(
|
||||
`/api/worktree/init-script?projectPath=${encodeURIComponent(currentPath)}`
|
||||
);
|
||||
|
||||
// Avoid updating state if component unmounted or project changed
|
||||
if (isCancelled) return;
|
||||
|
||||
if (response.success) {
|
||||
const content = response.content || '';
|
||||
setScriptContent(content);
|
||||
setOriginalContent(content);
|
||||
setScriptExists(response.exists);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCancelled) {
|
||||
console.error('Failed to load init script:', error);
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadInitScript();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [project.path]);
|
||||
|
||||
// Save script
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await apiPut<{ success: boolean; error?: string }>(
|
||||
'/api/worktree/init-script',
|
||||
{
|
||||
projectPath: project.path,
|
||||
content: scriptContent,
|
||||
}
|
||||
);
|
||||
if (response.success) {
|
||||
setOriginalContent(scriptContent);
|
||||
setScriptExists(true);
|
||||
toast.success('Init script saved');
|
||||
} else {
|
||||
toast.error('Failed to save init script', {
|
||||
description: response.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save init script:', error);
|
||||
toast.error('Failed to save init script');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [project.path, scriptContent]);
|
||||
|
||||
// Reset to original content
|
||||
const handleReset = useCallback(() => {
|
||||
setScriptContent(originalContent);
|
||||
}, [originalContent]);
|
||||
|
||||
// Delete script
|
||||
const handleDelete = useCallback(async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const response = await apiDelete<{ success: boolean; error?: string }>(
|
||||
'/api/worktree/init-script',
|
||||
{
|
||||
body: { projectPath: project.path },
|
||||
}
|
||||
);
|
||||
if (response.success) {
|
||||
setScriptContent('');
|
||||
setOriginalContent('');
|
||||
setScriptExists(false);
|
||||
toast.success('Init script deleted');
|
||||
} else {
|
||||
toast.error('Failed to delete init script', {
|
||||
description: response.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete init script:', error);
|
||||
toast.error('Failed to delete init script');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [project.path]);
|
||||
|
||||
// Handle content change (no auto-save)
|
||||
const handleContentChange = useCallback((value: string) => {
|
||||
setScriptContent(value);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<GitBranch className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Worktree Preferences
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Configure worktree behavior for this project.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Enable Git Worktree Isolation Toggle */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="project-use-worktrees"
|
||||
checked={effectiveUseWorktrees}
|
||||
onCheckedChange={async (checked) => {
|
||||
const value = checked === true;
|
||||
setProjectUseWorktrees(project.path, value);
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
await httpClient.settings.updateProject(project.path, {
|
||||
useWorktrees: value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to persist useWorktrees:', error);
|
||||
}
|
||||
}}
|
||||
className="mt-1"
|
||||
data-testid="project-use-worktrees-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="project-use-worktrees"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||
Enable Git Worktree Isolation
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Creates isolated git branches for each feature in this project. When disabled, agents
|
||||
work directly in the main project directory.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Show Init Script Indicator Toggle */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="show-init-script-indicator"
|
||||
checked={showIndicator}
|
||||
onCheckedChange={async (checked) => {
|
||||
const value = checked === true;
|
||||
setShowInitScriptIndicator(project.path, value);
|
||||
// Persist to server
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
await httpClient.settings.updateProject(project.path, {
|
||||
showInitScriptIndicator: value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to persist showInitScriptIndicator:', error);
|
||||
}
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="show-init-script-indicator"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<PanelBottomClose className="w-4 h-4 text-brand-500" />
|
||||
Show Init Script Indicator
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Display a floating panel in the bottom-right corner showing init script execution
|
||||
status and output when a worktree is created.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-dismiss Init Script Indicator Toggle */}
|
||||
{showIndicator && (
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 ml-6">
|
||||
<Checkbox
|
||||
id="auto-dismiss-indicator"
|
||||
checked={autoDismiss}
|
||||
onCheckedChange={async (checked) => {
|
||||
const value = checked === true;
|
||||
setAutoDismissInitScriptIndicator(project.path, value);
|
||||
// Persist to server
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
await httpClient.settings.updateProject(project.path, {
|
||||
autoDismissInitScriptIndicator: value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to persist autoDismissInitScriptIndicator:', error);
|
||||
}
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="auto-dismiss-indicator"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
Auto-dismiss After Completion
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Automatically hide the indicator 5 seconds after the script completes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default Delete Branch Toggle */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="default-delete-branch"
|
||||
checked={defaultDeleteBranch}
|
||||
onCheckedChange={async (checked) => {
|
||||
const value = checked === true;
|
||||
setDefaultDeleteBranch(project.path, value);
|
||||
// Persist to server
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
await httpClient.settings.updateProject(project.path, {
|
||||
defaultDeleteBranch: value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to persist defaultDeleteBranch:', error);
|
||||
}
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="default-delete-branch"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-brand-500" />
|
||||
Delete Branch by Default
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
When deleting a worktree, automatically check the "Also delete the branch" option.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Init Script Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4 text-brand-500" />
|
||||
<Label className="text-foreground font-medium">Initialization Script</Label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash
|
||||
on Windows for cross-platform compatibility.
|
||||
</p>
|
||||
|
||||
{/* File path indicator */}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground/60">
|
||||
<FileCode className="w-3.5 h-3.5" />
|
||||
<code className="font-mono">.automaker/worktree-init.sh</code>
|
||||
{hasChanges && <span className="text-amber-500 font-medium">(unsaved changes)</span>}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ShellSyntaxEditor
|
||||
value={scriptContent}
|
||||
onChange={handleContentChange}
|
||||
placeholder={`# Example initialization commands
|
||||
npm install
|
||||
|
||||
# Or use pnpm
|
||||
# pnpm install
|
||||
|
||||
# Copy environment file
|
||||
# cp .env.example .env`}
|
||||
minHeight="200px"
|
||||
maxHeight="500px"
|
||||
data-testid="init-script-editor"
|
||||
/>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges || isSaving || isDeleting}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={!scriptExists || isSaving || isDeleting}
|
||||
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isSaving || isDeleting}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { useSettingsView, type SettingsViewId } from './settings-view/hooks';
|
||||
import { NAV_ITEMS } from './settings-view/config/navigation';
|
||||
import { SettingsHeader } from './settings-view/components/settings-header';
|
||||
import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog';
|
||||
import { DeleteProjectDialog } from './settings-view/components/delete-project-dialog';
|
||||
import { SettingsNavigation } from './settings-view/components/settings-navigation';
|
||||
import { ApiKeysSection } from './settings-view/api-keys/api-keys-section';
|
||||
import { ModelDefaultsSection } from './settings-view/model-defaults';
|
||||
@@ -16,7 +15,6 @@ import { AudioSection } from './settings-view/audio/audio-section';
|
||||
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
||||
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
|
||||
import { WorktreesSection } from './settings-view/worktrees';
|
||||
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
||||
import { AccountSection } from './settings-view/account';
|
||||
import { SecuritySection } from './settings-view/security';
|
||||
import { DeveloperSection } from './settings-view/developer/developer-section';
|
||||
@@ -30,8 +28,7 @@ import { MCPServersSection } from './settings-view/mcp-servers';
|
||||
import { PromptCustomizationSection } from './settings-view/prompts';
|
||||
import { EventHooksSection } from './settings-view/event-hooks';
|
||||
import { ImportExportDialog } from './settings-view/components/import-export-dialog';
|
||||
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
||||
import type { Project as ElectronProject } from '@/lib/electron';
|
||||
import type { Theme } from './settings-view/shared/types';
|
||||
|
||||
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
|
||||
const LG_BREAKPOINT = 1024;
|
||||
@@ -40,7 +37,6 @@ export function SettingsView() {
|
||||
const {
|
||||
theme,
|
||||
setTheme,
|
||||
setProjectTheme,
|
||||
defaultSkipTests,
|
||||
setDefaultSkipTests,
|
||||
enableDependencyBlocking,
|
||||
@@ -54,7 +50,6 @@ export function SettingsView() {
|
||||
muteDoneSound,
|
||||
setMuteDoneSound,
|
||||
currentProject,
|
||||
moveProjectToTrash,
|
||||
defaultPlanningMode,
|
||||
setDefaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
@@ -69,34 +64,8 @@ export function SettingsView() {
|
||||
setSkipSandboxWarning,
|
||||
} = useAppStore();
|
||||
|
||||
// Convert electron Project to settings-view Project type
|
||||
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
|
||||
if (!project) return null;
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
path: project.path,
|
||||
theme: project.theme as Theme | undefined,
|
||||
icon: project.icon,
|
||||
customIconPath: project.customIconPath,
|
||||
};
|
||||
};
|
||||
|
||||
const settingsProject = convertProject(currentProject);
|
||||
|
||||
// Compute the effective theme for the current project
|
||||
const effectiveTheme = (settingsProject?.theme || theme) as Theme;
|
||||
|
||||
// Handler to set theme - always updates global theme (user's preference),
|
||||
// and also sets per-project theme if a project is selected
|
||||
const handleSetTheme = (newTheme: typeof theme) => {
|
||||
// Always update global theme so user's preference persists across all projects
|
||||
setTheme(newTheme);
|
||||
// Also set per-project theme if a project is selected
|
||||
if (currentProject) {
|
||||
setProjectTheme(currentProject.id, newTheme);
|
||||
}
|
||||
};
|
||||
// Global theme (project-specific themes are managed in Project Settings)
|
||||
const globalTheme = theme as Theme;
|
||||
|
||||
// Get initial view from URL search params
|
||||
const { view: initialView } = useSearch({ from: '/settings' });
|
||||
@@ -113,7 +82,6 @@ export function SettingsView() {
|
||||
}
|
||||
};
|
||||
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
||||
const [showImportExportDialog, setShowImportExportDialog] = useState(false);
|
||||
|
||||
@@ -172,9 +140,8 @@ export function SettingsView() {
|
||||
case 'appearance':
|
||||
return (
|
||||
<AppearanceSection
|
||||
effectiveTheme={effectiveTheme as any}
|
||||
currentProject={settingsProject as any}
|
||||
onThemeChange={(theme) => handleSetTheme(theme as any)}
|
||||
effectiveTheme={globalTheme}
|
||||
onThemeChange={(newTheme) => setTheme(newTheme as typeof theme)}
|
||||
/>
|
||||
);
|
||||
case 'terminal':
|
||||
@@ -223,13 +190,6 @@ export function SettingsView() {
|
||||
);
|
||||
case 'developer':
|
||||
return <DeveloperSection />;
|
||||
case 'danger':
|
||||
return (
|
||||
<DangerZoneSection
|
||||
project={settingsProject}
|
||||
onDeleteClick={() => setShowDeleteDialog(true)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <ApiKeysSection />;
|
||||
}
|
||||
@@ -265,14 +225,6 @@ export function SettingsView() {
|
||||
{/* Keyboard Map Dialog */}
|
||||
<KeyboardMapDialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog} />
|
||||
|
||||
{/* Delete Project Confirmation Dialog */}
|
||||
<DeleteProjectDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
project={currentProject}
|
||||
onConfirm={moveProjectToTrash}
|
||||
/>
|
||||
|
||||
{/* Import/Export Settings Dialog */}
|
||||
<ImportExportDialog open={showImportExportDialog} onOpenChange={setShowImportExportDialog} />
|
||||
</div>
|
||||
|
||||
@@ -1,118 +1,20 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Palette, Moon, Sun, Upload, X, ImageIcon } from 'lucide-react';
|
||||
import { Palette, Moon, Sun } from 'lucide-react';
|
||||
import { darkThemes, lightThemes } from '@/config/theme-options';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { IconPicker } from '@/components/layout/project-switcher/components/icon-picker';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import type { Theme, Project } from '../shared/types';
|
||||
import type { Theme } from '../shared/types';
|
||||
|
||||
interface AppearanceSectionProps {
|
||||
effectiveTheme: Theme;
|
||||
currentProject: Project | null;
|
||||
onThemeChange: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
export function AppearanceSection({
|
||||
effectiveTheme,
|
||||
currentProject,
|
||||
onThemeChange,
|
||||
}: AppearanceSectionProps) {
|
||||
const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore();
|
||||
export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceSectionProps) {
|
||||
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
|
||||
const [projectName, setProjectNameLocal] = useState(currentProject?.name || '');
|
||||
const [projectIcon, setProjectIconLocal] = useState<string | null>(currentProject?.icon || null);
|
||||
const [customIconPath, setCustomIconPathLocal] = useState<string | null>(
|
||||
currentProject?.customIconPath || null
|
||||
);
|
||||
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sync local state when currentProject changes
|
||||
useEffect(() => {
|
||||
setProjectNameLocal(currentProject?.name || '');
|
||||
setProjectIconLocal(currentProject?.icon || null);
|
||||
setCustomIconPathLocal(currentProject?.customIconPath || null);
|
||||
}, [currentProject]);
|
||||
|
||||
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
|
||||
|
||||
// Auto-save when values change
|
||||
const handleNameChange = (name: string) => {
|
||||
setProjectNameLocal(name);
|
||||
if (currentProject && name.trim() && name.trim() !== currentProject.name) {
|
||||
setProjectName(currentProject.id, name.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleIconChange = (icon: string | null) => {
|
||||
setProjectIconLocal(icon);
|
||||
if (currentProject) {
|
||||
setProjectIcon(currentProject.id, icon);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomIconChange = (path: string | null) => {
|
||||
setCustomIconPathLocal(path);
|
||||
if (currentProject) {
|
||||
setProjectCustomIcon(currentProject.id, path);
|
||||
// Clear Lucide icon when custom icon is set
|
||||
if (path) {
|
||||
setProjectIconLocal(null);
|
||||
setProjectIcon(currentProject.id, null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomIconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !currentProject) return;
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 2MB for icons)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploadingIcon(true);
|
||||
try {
|
||||
// Convert to base64
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const base64Data = reader.result as string;
|
||||
const result = await getHttpApiClient().saveImageToTemp(
|
||||
base64Data,
|
||||
`project-icon-${file.name}`,
|
||||
file.type,
|
||||
currentProject.path
|
||||
);
|
||||
if (result.success && result.path) {
|
||||
handleCustomIconChange(result.path);
|
||||
}
|
||||
setIsUploadingIcon(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch {
|
||||
setIsUploadingIcon(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCustomIcon = () => {
|
||||
handleCustomIconChange(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -134,94 +36,10 @@ export function AppearanceSection({
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Project Details Section */}
|
||||
{currentProject && (
|
||||
<div className="space-y-4 pb-6 border-b border-border/50">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-name-settings">Project Name</Label>
|
||||
<Input
|
||||
id="project-name-settings"
|
||||
value={projectName}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Enter project name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Project Icon</Label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Choose a preset icon or upload a custom image
|
||||
</p>
|
||||
|
||||
{/* Custom Icon Upload */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{customIconPath ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={getAuthenticatedImageUrl(customIconPath, currentProject.path)}
|
||||
alt="Custom project icon"
|
||||
className="w-12 h-12 rounded-lg object-cover border border-border"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveCustomIcon}
|
||||
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg border border-dashed border-border flex items-center justify-center bg-accent/30">
|
||||
<ImageIcon className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
onChange={handleCustomIconUpload}
|
||||
className="hidden"
|
||||
id="custom-icon-upload"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploadingIcon}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
PNG, JPG, GIF or WebP. Max 2MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preset Icon Picker - only show if no custom icon */}
|
||||
{!customIconPath && (
|
||||
<IconPicker selectedIcon={projectIcon} onSelectIcon={handleIconChange} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">
|
||||
Theme{' '}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{currentProject ? `(for ${currentProject.name})` : '(Global)'}
|
||||
</span>
|
||||
</Label>
|
||||
<Label className="text-foreground font-medium">Theme</Label>
|
||||
{/* Dark/Light Tabs */}
|
||||
<div className="flex gap-1 p-1 rounded-lg bg-accent/30">
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Settings, PanelLeft, PanelLeftClose, FileJson } from 'lucide-react';
|
||||
import { Cog, Menu, X, FileJson } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
@@ -11,7 +11,7 @@ interface SettingsHeaderProps {
|
||||
}
|
||||
|
||||
export function SettingsHeader({
|
||||
title = 'Settings',
|
||||
title = 'Global Settings',
|
||||
description = 'Configure your API keys and preferences',
|
||||
showNavigation,
|
||||
onToggleNavigation,
|
||||
@@ -28,6 +28,31 @@ export function SettingsHeader({
|
||||
<div className="px-4 py-4 lg:px-8 lg:py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 lg:gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 lg:w-12 lg:h-12 rounded-xl lg:rounded-2xl flex items-center justify-center',
|
||||
'bg-gradient-to-br from-brand-500 to-brand-600',
|
||||
'shadow-lg shadow-brand-500/25',
|
||||
'ring-1 ring-white/10'
|
||||
)}
|
||||
>
|
||||
<Cog className="w-5 h-5 lg:w-6 lg:h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl lg:text-2xl font-bold text-foreground tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-xs lg:text-sm text-muted-foreground/80 mt-0.5">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Import/Export button */}
|
||||
{onImportExportClick && (
|
||||
<Button variant="outline" size="sm" onClick={onImportExportClick} className="gap-2">
|
||||
<FileJson className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Import / Export</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Mobile menu toggle button - only visible on mobile */}
|
||||
{onToggleNavigation && (
|
||||
<Button
|
||||
@@ -37,37 +62,10 @@ export function SettingsHeader({
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden"
|
||||
aria-label={showNavigation ? 'Close navigation menu' : 'Open navigation menu'}
|
||||
>
|
||||
{showNavigation ? (
|
||||
<PanelLeftClose className="w-5 h-5" />
|
||||
) : (
|
||||
<PanelLeft className="w-5 h-5" />
|
||||
)}
|
||||
{showNavigation ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</Button>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 lg:w-12 lg:h-12 rounded-xl lg:rounded-2xl flex items-center justify-center',
|
||||
'bg-gradient-to-br from-brand-500 to-brand-600',
|
||||
'shadow-lg shadow-brand-500/25',
|
||||
'ring-1 ring-white/10'
|
||||
)}
|
||||
>
|
||||
<Settings className="w-5 h-5 lg:w-6 lg:h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl lg:text-2xl font-bold text-foreground tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-xs lg:text-sm text-muted-foreground/80 mt-0.5">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Import/Export button */}
|
||||
{onImportExportClick && (
|
||||
<Button variant="outline" size="sm" onClick={onImportExportClick} className="gap-2">
|
||||
<FileJson className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Import / Export</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import type { NavigationItem, NavigationGroup } from '../config/navigation';
|
||||
import { GLOBAL_NAV_GROUPS, PROJECT_NAV_ITEMS } from '../config/navigation';
|
||||
import { GLOBAL_NAV_GROUPS } from '../config/navigation';
|
||||
import type { SettingsViewId } from '../hooks/use-settings-view';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { ModelProvider } from '@automaker/types';
|
||||
@@ -210,15 +210,15 @@ export function SettingsNavigation({
|
||||
{/* Navigation sidebar */}
|
||||
<nav
|
||||
className={cn(
|
||||
// Mobile: fixed position overlay with slide transition
|
||||
'fixed inset-y-0 left-0 w-72 z-30',
|
||||
// Mobile: fixed position overlay with slide transition from right
|
||||
'fixed inset-y-0 right-0 w-72 z-30',
|
||||
'transition-transform duration-200 ease-out',
|
||||
// Hide on mobile when closed, show when open
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full',
|
||||
// Desktop: relative position in layout, always visible
|
||||
'lg:relative lg:w-64 lg:z-auto lg:translate-x-0',
|
||||
'shrink-0 overflow-y-auto',
|
||||
'border-r border-border/50',
|
||||
'border-l border-border/50 lg:border-l-0 lg:border-r',
|
||||
'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl',
|
||||
// Desktop background
|
||||
'lg:from-card/80 lg:via-card/60 lg:to-card/40'
|
||||
@@ -272,31 +272,6 @@ export function SettingsNavigation({
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Project Settings - only show when a project is selected */}
|
||||
{currentProject && (
|
||||
<>
|
||||
{/* Divider */}
|
||||
<div className="my-3 border-t border-border/50" />
|
||||
|
||||
{/* Project Settings Label */}
|
||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
|
||||
Project Settings
|
||||
</div>
|
||||
|
||||
{/* Project Settings Items */}
|
||||
<div className="space-y-1">
|
||||
{PROJECT_NAV_ITEMS.map((item) => (
|
||||
<NavButton
|
||||
key={item.id}
|
||||
item={item}
|
||||
isActive={activeSection === item.id}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
|
||||
@@ -8,13 +8,11 @@ import {
|
||||
Settings2,
|
||||
Volume2,
|
||||
FlaskConical,
|
||||
Trash2,
|
||||
Workflow,
|
||||
Plug,
|
||||
MessageSquareText,
|
||||
User,
|
||||
Shield,
|
||||
Cpu,
|
||||
GitBranch,
|
||||
Code2,
|
||||
Webhook,
|
||||
@@ -84,10 +82,5 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
|
||||
// Flat list of all global nav items for backwards compatibility
|
||||
export const GLOBAL_NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_GROUPS.flatMap((group) => group.items);
|
||||
|
||||
// Project-specific settings - only visible when a project is selected
|
||||
export const PROJECT_NAV_ITEMS: NavigationItem[] = [
|
||||
{ id: 'danger', label: 'Danger Zone', icon: Trash2 },
|
||||
];
|
||||
|
||||
// Legacy export for backwards compatibility
|
||||
export const NAV_ITEMS: NavigationItem[] = [...GLOBAL_NAV_ITEMS, ...PROJECT_NAV_ITEMS];
|
||||
export const NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_ITEMS;
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
History,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Play,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { StoredEventSummary, StoredEvent, EventHookTrigger } from '@automaker/types';
|
||||
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
|
||||
export function EventHistoryView() {
|
||||
const currentProject = useAppStore((state) => state.currentProject);
|
||||
const projectPath = currentProject?.path;
|
||||
const [events, setEvents] = useState<StoredEventSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedEvent, setExpandedEvent] = useState<string | null>(null);
|
||||
const [expandedEventData, setExpandedEventData] = useState<StoredEvent | null>(null);
|
||||
const [replayingEvent, setReplayingEvent] = useState<string | null>(null);
|
||||
const [clearDialogOpen, setClearDialogOpen] = useState(false);
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
if (!projectPath) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.eventHistory.list(projectPath, { limit: 100 });
|
||||
if (result.success && result.events) {
|
||||
setEvents(result.events);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load events:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
const handleExpand = async (eventId: string) => {
|
||||
if (expandedEvent === eventId) {
|
||||
setExpandedEvent(null);
|
||||
setExpandedEventData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectPath) return;
|
||||
|
||||
setExpandedEvent(eventId);
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.eventHistory.get(projectPath, eventId);
|
||||
if (result.success && result.event) {
|
||||
setExpandedEventData(result.event);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load event details:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplay = async (eventId: string) => {
|
||||
if (!projectPath) return;
|
||||
|
||||
setReplayingEvent(eventId);
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.eventHistory.replay(projectPath, eventId);
|
||||
if (result.success && result.result) {
|
||||
const { hooksTriggered, hookResults } = result.result;
|
||||
const successCount = hookResults.filter((r) => r.success).length;
|
||||
const failCount = hookResults.filter((r) => !r.success).length;
|
||||
|
||||
if (hooksTriggered === 0) {
|
||||
alert('No matching hooks found for this event trigger.');
|
||||
} else if (failCount === 0) {
|
||||
alert(`Successfully ran ${successCount} hook(s).`);
|
||||
} else {
|
||||
alert(`Ran ${hooksTriggered} hook(s): ${successCount} succeeded, ${failCount} failed.`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to replay event:', error);
|
||||
alert('Failed to replay event. Check console for details.');
|
||||
} finally {
|
||||
setReplayingEvent(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (eventId: string) => {
|
||||
if (!projectPath) return;
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.eventHistory.delete(projectPath, eventId);
|
||||
if (result.success) {
|
||||
setEvents((prev) => prev.filter((e) => e.id !== eventId));
|
||||
if (expandedEvent === eventId) {
|
||||
setExpandedEvent(null);
|
||||
setExpandedEventData(null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!projectPath) return;
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.eventHistory.clear(projectPath);
|
||||
if (result.success) {
|
||||
setEvents([]);
|
||||
setExpandedEvent(null);
|
||||
setExpandedEventData(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to clear events:', error);
|
||||
}
|
||||
setClearDialogOpen(false);
|
||||
};
|
||||
|
||||
const getTriggerIcon = (trigger: EventHookTrigger) => {
|
||||
switch (trigger) {
|
||||
case 'feature_created':
|
||||
return <Clock className="w-4 h-4 text-blue-500" />;
|
||||
case 'feature_success':
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case 'feature_error':
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
case 'auto_mode_complete':
|
||||
return <CheckCircle className="w-4 h-4 text-purple-500" />;
|
||||
case 'auto_mode_error':
|
||||
return <AlertCircle className="w-4 h-4 text-orange-500" />;
|
||||
default:
|
||||
return <History className="w-4 h-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<History className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">Select a project to view event history</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header with actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{events.length} event{events.length !== 1 ? 's' : ''} recorded
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={loadEvents} disabled={loading}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', loading && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
{events.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setClearDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events list */}
|
||||
{events.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<History className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">No events recorded yet</p>
|
||||
<p className="text-xs mt-1">
|
||||
Events will appear here when features are created or completed
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={cn(
|
||||
'rounded-lg border bg-background/50',
|
||||
expandedEvent === event.id && 'ring-1 ring-brand-500/30'
|
||||
)}
|
||||
>
|
||||
{/* Event header */}
|
||||
<div
|
||||
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/30 transition-colors"
|
||||
onClick={() => handleExpand(event.id)}
|
||||
>
|
||||
<button className="p-0.5">
|
||||
{expandedEvent === event.id ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{getTriggerIcon(event.trigger)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{EVENT_HOOK_TRIGGER_LABELS[event.trigger]}
|
||||
</p>
|
||||
{event.featureName && (
|
||||
<p className="text-xs text-muted-foreground truncate">{event.featureName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTimestamp(event.timestamp)}
|
||||
</span>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleReplay(event.id)}
|
||||
disabled={replayingEvent === event.id}
|
||||
title="Replay event (trigger matching hooks)"
|
||||
>
|
||||
<Play
|
||||
className={cn('w-3.5 h-3.5', replayingEvent === event.id && 'animate-pulse')}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(event.id)}
|
||||
title="Delete event"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expandedEvent === event.id && expandedEventData && (
|
||||
<div className="px-4 pb-4 pt-0 border-t border-border/50">
|
||||
<div className="mt-3 space-y-2 text-xs">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Event ID:</span>
|
||||
<p className="font-mono text-[10px] truncate">{expandedEventData.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Timestamp:</span>
|
||||
<p>{new Date(expandedEventData.timestamp).toLocaleString()}</p>
|
||||
</div>
|
||||
{expandedEventData.featureId && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Feature ID:</span>
|
||||
<p className="font-mono text-[10px] truncate">
|
||||
{expandedEventData.featureId}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{expandedEventData.passes !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Passed:</span>
|
||||
<p>{expandedEventData.passes ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{expandedEventData.error && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Error:</span>
|
||||
<p className="text-red-400 mt-1 p-2 bg-red-500/10 rounded text-[10px] font-mono whitespace-pre-wrap">
|
||||
{expandedEventData.error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-muted-foreground">Project:</span>
|
||||
<p className="font-mono text-[10px] truncate">
|
||||
{expandedEventData.projectPath}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={clearDialogOpen}
|
||||
onOpenChange={setClearDialogOpen}
|
||||
onConfirm={handleClearAll}
|
||||
title="Clear Event History"
|
||||
description={`This will permanently delete all ${events.length} recorded events. This action cannot be undone.`}
|
||||
icon={Trash2}
|
||||
iconClassName="text-destructive"
|
||||
confirmText="Clear All"
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ interface EventHookDialogProps {
|
||||
type ActionType = 'shell' | 'http';
|
||||
|
||||
const TRIGGER_OPTIONS: EventHookTrigger[] = [
|
||||
'feature_created',
|
||||
'feature_success',
|
||||
'feature_error',
|
||||
'auto_mode_complete',
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Webhook, Plus, Trash2, Pencil, Terminal, Globe } from 'lucide-react';
|
||||
import { Webhook, Plus, Trash2, Pencil, Terminal, Globe, History } from 'lucide-react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { EventHook, EventHookTrigger } from '@automaker/types';
|
||||
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
|
||||
import { EventHookDialog } from './event-hook-dialog';
|
||||
import { EventHistoryView } from './event-history-view';
|
||||
|
||||
export function EventHooksSection() {
|
||||
const { eventHooks, setEventHooks } = useAppStore();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingHook, setEditingHook] = useState<EventHook | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'hooks' | 'history'>('hooks');
|
||||
|
||||
const handleAddHook = () => {
|
||||
setEditingHook(null);
|
||||
@@ -78,58 +81,85 @@ export function EventHooksSection() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleAddHook} size="sm" className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Hook
|
||||
</Button>
|
||||
{activeTab === 'hooks' && (
|
||||
<Button onClick={handleAddHook} size="sm" className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Hook
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{eventHooks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Webhook className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">No event hooks configured</p>
|
||||
<p className="text-xs mt-1">
|
||||
Add hooks to run commands or send webhooks when features complete
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Group by trigger type */}
|
||||
{Object.entries(hooksByTrigger).map(([trigger, hooks]) => (
|
||||
<div key={trigger} className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
{EVENT_HOOK_TRIGGER_LABELS[trigger as EventHookTrigger]}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{hooks.map((hook) => (
|
||||
<HookCard
|
||||
key={hook.id}
|
||||
hook={hook}
|
||||
onEdit={() => handleEditHook(hook)}
|
||||
onDelete={() => handleDeleteHook(hook.id)}
|
||||
onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'hooks' | 'history')}>
|
||||
<div className="px-6 pt-4">
|
||||
<TabsList className="grid w-full max-w-xs grid-cols-2">
|
||||
<TabsTrigger value="hooks" className="gap-2">
|
||||
<Webhook className="w-4 h-4" />
|
||||
Hooks
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="gap-2">
|
||||
<History className="w-4 h-4" />
|
||||
History
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Hooks Tab */}
|
||||
<TabsContent value="hooks" className="m-0">
|
||||
<div className="p-6 pt-4">
|
||||
{eventHooks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Webhook className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">No event hooks configured</p>
|
||||
<p className="text-xs mt-1">
|
||||
Add hooks to run commands or send webhooks when features complete
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Group by trigger type */}
|
||||
{Object.entries(hooksByTrigger).map(([trigger, hooks]) => (
|
||||
<div key={trigger} className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
{EVENT_HOOK_TRIGGER_LABELS[trigger as EventHookTrigger]}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{hooks.map((hook) => (
|
||||
<HookCard
|
||||
key={hook.id}
|
||||
hook={hook}
|
||||
onEdit={() => handleEditHook(hook)}
|
||||
onDelete={() => handleDeleteHook(hook.id)}
|
||||
onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variable reference */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="rounded-lg bg-muted/30 p-4 text-xs text-muted-foreground">
|
||||
<p className="font-medium mb-2">Available variables:</p>
|
||||
<code className="text-[10px] leading-relaxed">
|
||||
{'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '}
|
||||
{'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
{/* Variable reference */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="rounded-lg bg-muted/30 p-4 text-xs text-muted-foreground">
|
||||
<p className="font-medium mb-2">Available variables:</p>
|
||||
<code className="text-[10px] leading-relaxed">
|
||||
{'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '}
|
||||
{'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* History Tab */}
|
||||
<TabsContent value="history" className="m-0">
|
||||
<div className="p-6 pt-4">
|
||||
<EventHistoryView />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Dialog */}
|
||||
<EventHookDialog
|
||||
|
||||
@@ -1,172 +1,14 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
|
||||
import {
|
||||
GitBranch,
|
||||
Terminal,
|
||||
FileCode,
|
||||
Save,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
PanelBottomClose,
|
||||
} from 'lucide-react';
|
||||
import { GitBranch } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
|
||||
interface WorktreesSectionProps {
|
||||
useWorktrees: boolean;
|
||||
onUseWorktreesChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
interface InitScriptResponse {
|
||||
success: boolean;
|
||||
exists: boolean;
|
||||
content: string;
|
||||
path: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) {
|
||||
const currentProject = useAppStore((s) => s.currentProject);
|
||||
const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
|
||||
const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
|
||||
const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch);
|
||||
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
|
||||
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
|
||||
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
|
||||
const [scriptContent, setScriptContent] = useState('');
|
||||
const [originalContent, setOriginalContent] = useState('');
|
||||
const [scriptExists, setScriptExists] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Get the current show indicator setting
|
||||
const showIndicator = currentProject?.path
|
||||
? getShowInitScriptIndicator(currentProject.path)
|
||||
: true;
|
||||
|
||||
// Get the default delete branch setting
|
||||
const defaultDeleteBranch = currentProject?.path
|
||||
? getDefaultDeleteBranch(currentProject.path)
|
||||
: false;
|
||||
|
||||
// Get the auto-dismiss setting
|
||||
const autoDismiss = currentProject?.path
|
||||
? getAutoDismissInitScriptIndicator(currentProject.path)
|
||||
: true;
|
||||
|
||||
// Check if there are unsaved changes
|
||||
const hasChanges = scriptContent !== originalContent;
|
||||
|
||||
// Load init script content when project changes
|
||||
useEffect(() => {
|
||||
if (!currentProject?.path) {
|
||||
setScriptContent('');
|
||||
setOriginalContent('');
|
||||
setScriptExists(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadInitScript = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await apiGet<InitScriptResponse>(
|
||||
`/api/worktree/init-script?projectPath=${encodeURIComponent(currentProject.path)}`
|
||||
);
|
||||
if (response.success) {
|
||||
const content = response.content || '';
|
||||
setScriptContent(content);
|
||||
setOriginalContent(content);
|
||||
setScriptExists(response.exists);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load init script:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadInitScript();
|
||||
}, [currentProject?.path]);
|
||||
|
||||
// Save script
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!currentProject?.path) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await apiPut<{ success: boolean; error?: string }>(
|
||||
'/api/worktree/init-script',
|
||||
{
|
||||
projectPath: currentProject.path,
|
||||
content: scriptContent,
|
||||
}
|
||||
);
|
||||
if (response.success) {
|
||||
setOriginalContent(scriptContent);
|
||||
setScriptExists(true);
|
||||
toast.success('Init script saved');
|
||||
} else {
|
||||
toast.error('Failed to save init script', {
|
||||
description: response.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save init script:', error);
|
||||
toast.error('Failed to save init script');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [currentProject?.path, scriptContent]);
|
||||
|
||||
// Reset to original content
|
||||
const handleReset = useCallback(() => {
|
||||
setScriptContent(originalContent);
|
||||
}, [originalContent]);
|
||||
|
||||
// Delete script
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!currentProject?.path) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const response = await apiDelete<{ success: boolean; error?: string }>(
|
||||
'/api/worktree/init-script',
|
||||
{
|
||||
body: { projectPath: currentProject.path },
|
||||
}
|
||||
);
|
||||
if (response.success) {
|
||||
setScriptContent('');
|
||||
setOriginalContent('');
|
||||
setScriptExists(false);
|
||||
toast.success('Init script deleted');
|
||||
} else {
|
||||
toast.error('Failed to delete init script', {
|
||||
description: response.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete init script:', error);
|
||||
toast.error('Failed to delete init script');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [currentProject?.path]);
|
||||
|
||||
// Handle content change (no auto-save)
|
||||
const handleContentChange = useCallback((value: string) => {
|
||||
setScriptContent(value);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -184,7 +26,7 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Worktrees</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Configure git worktree isolation and initialization scripts.
|
||||
Configure git worktree isolation for feature development.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
@@ -212,217 +54,12 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show Init Script Indicator Toggle */}
|
||||
{currentProject && (
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 mt-4">
|
||||
<Checkbox
|
||||
id="show-init-script-indicator"
|
||||
checked={showIndicator}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (currentProject?.path) {
|
||||
const value = checked === true;
|
||||
setShowInitScriptIndicator(currentProject.path, value);
|
||||
// Persist to server
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
await httpClient.settings.updateProject(currentProject.path, {
|
||||
showInitScriptIndicator: value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to persist showInitScriptIndicator:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="show-init-script-indicator"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<PanelBottomClose className="w-4 h-4 text-brand-500" />
|
||||
Show Init Script Indicator
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Display a floating panel in the bottom-right corner showing init script execution
|
||||
status and output when a worktree is created.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto-dismiss Init Script Indicator Toggle */}
|
||||
{currentProject && showIndicator && (
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 ml-6">
|
||||
<Checkbox
|
||||
id="auto-dismiss-indicator"
|
||||
checked={autoDismiss}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (currentProject?.path) {
|
||||
const value = checked === true;
|
||||
setAutoDismissInitScriptIndicator(currentProject.path, value);
|
||||
// Persist to server
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
await httpClient.settings.updateProject(currentProject.path, {
|
||||
autoDismissInitScriptIndicator: value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to persist autoDismissInitScriptIndicator:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="auto-dismiss-indicator"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
Auto-dismiss After Completion
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Automatically hide the indicator 5 seconds after the script completes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default Delete Branch Toggle */}
|
||||
{currentProject && (
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="default-delete-branch"
|
||||
checked={defaultDeleteBranch}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (currentProject?.path) {
|
||||
const value = checked === true;
|
||||
setDefaultDeleteBranch(currentProject.path, value);
|
||||
// Persist to server
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
await httpClient.settings.updateProject(currentProject.path, {
|
||||
defaultDeleteBranch: value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to persist defaultDeleteBranch:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="default-delete-branch"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-brand-500" />
|
||||
Delete Branch by Default
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
When deleting a worktree, automatically check the "Also delete the branch" option.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Init Script Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4 text-brand-500" />
|
||||
<Label className="text-foreground font-medium">Initialization Script</Label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash
|
||||
on Windows for cross-platform compatibility.
|
||||
{/* Info about project-specific settings */}
|
||||
<div className="rounded-xl border border-border/30 bg-muted/30 p-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Project-specific worktree preferences (init script, delete branch behavior) can be
|
||||
configured in each project's settings via the sidebar.
|
||||
</p>
|
||||
|
||||
{currentProject ? (
|
||||
<>
|
||||
{/* File path indicator */}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground/60">
|
||||
<FileCode className="w-3.5 h-3.5" />
|
||||
<code className="font-mono">.automaker/worktree-init.sh</code>
|
||||
{hasChanges && (
|
||||
<span className="text-amber-500 font-medium">(unsaved changes)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ShellSyntaxEditor
|
||||
value={scriptContent}
|
||||
onChange={handleContentChange}
|
||||
placeholder={`# Example initialization commands
|
||||
npm install
|
||||
|
||||
# Or use pnpm
|
||||
# pnpm install
|
||||
|
||||
# Copy environment file
|
||||
# cp .env.example .env`}
|
||||
minHeight="200px"
|
||||
maxHeight="500px"
|
||||
data-testid="init-script-editor"
|
||||
/>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges || isSaving || isDeleting}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={!scriptExists || isSaving || isDeleting}
|
||||
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isSaving || isDeleting}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground/60 py-4 text-center">
|
||||
Select a project to configure the init script.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
@@ -13,6 +14,9 @@ import { CreateSpecDialog, RegenerateSpecDialog } from './spec-view/dialogs';
|
||||
export function SpecView() {
|
||||
const { currentProject, appSpec } = useAppStore();
|
||||
|
||||
// Actions panel state (for tablet/mobile)
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
|
||||
// Loading state
|
||||
const { isLoading, specExists, isGenerationRunning, loadSpec } = useSpecLoading();
|
||||
|
||||
@@ -52,6 +56,9 @@ export function SpecView() {
|
||||
// Feature generation
|
||||
isGeneratingFeatures,
|
||||
|
||||
// Sync
|
||||
isSyncing,
|
||||
|
||||
// Status
|
||||
currentPhase,
|
||||
errorMessage,
|
||||
@@ -59,6 +66,8 @@ export function SpecView() {
|
||||
// Handlers
|
||||
handleCreateSpec,
|
||||
handleRegenerate,
|
||||
handleGenerateFeatures,
|
||||
handleSync,
|
||||
} = useSpecGeneration({ loadSpec });
|
||||
|
||||
// Reset hasChanges when spec is reloaded
|
||||
@@ -82,10 +91,9 @@ export function SpecView() {
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state - no spec exists or generation is running
|
||||
// When generation is running, we skip loading the spec to avoid 500 errors,
|
||||
// so we show the empty state with generation indicator
|
||||
if (!specExists || isGenerationRunning) {
|
||||
// Empty state - only show when spec doesn't exist AND no generation is running
|
||||
// If generation is running but no spec exists, show the generating UI
|
||||
if (!specExists) {
|
||||
// If generation is running (from loading hook check), ensure we show the generating UI
|
||||
const showAsGenerating = isCreating || isGenerationRunning;
|
||||
|
||||
@@ -123,15 +131,20 @@ export function SpecView() {
|
||||
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="spec-view">
|
||||
<SpecHeader
|
||||
projectPath={currentProject.path}
|
||||
isRegenerating={isRegenerating}
|
||||
isRegenerating={isRegenerating || isGenerationRunning}
|
||||
isCreating={isCreating}
|
||||
isGeneratingFeatures={isGeneratingFeatures}
|
||||
isSyncing={isSyncing}
|
||||
isSaving={isSaving}
|
||||
hasChanges={hasChanges}
|
||||
currentPhase={currentPhase}
|
||||
currentPhase={currentPhase || (isGenerationRunning ? 'working' : '')}
|
||||
errorMessage={errorMessage}
|
||||
onRegenerateClick={() => setShowRegenerateDialog(true)}
|
||||
onGenerateFeaturesClick={handleGenerateFeatures}
|
||||
onSyncClick={handleSync}
|
||||
onSaveClick={saveSpec}
|
||||
showActionsPanel={showActionsPanel}
|
||||
onToggleActionsPanel={() => setShowActionsPanel(!showActionsPanel)}
|
||||
/>
|
||||
|
||||
<SpecEditor value={appSpec} onChange={handleChange} />
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Save, Sparkles, Loader2, FileText, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
HeaderActionsPanel,
|
||||
HeaderActionsPanelTrigger,
|
||||
} from '@/components/ui/header-actions-panel';
|
||||
import { Save, Sparkles, Loader2, FileText, AlertCircle, ListPlus, RefreshCcw } from 'lucide-react';
|
||||
import { PHASE_LABELS } from '../constants';
|
||||
|
||||
interface SpecHeaderProps {
|
||||
@@ -7,12 +11,17 @@ interface SpecHeaderProps {
|
||||
isRegenerating: boolean;
|
||||
isCreating: boolean;
|
||||
isGeneratingFeatures: boolean;
|
||||
isSyncing: boolean;
|
||||
isSaving: boolean;
|
||||
hasChanges: boolean;
|
||||
currentPhase: string;
|
||||
errorMessage: string;
|
||||
onRegenerateClick: () => void;
|
||||
onGenerateFeaturesClick: () => void;
|
||||
onSyncClick: () => void;
|
||||
onSaveClick: () => void;
|
||||
showActionsPanel: boolean;
|
||||
onToggleActionsPanel: () => void;
|
||||
}
|
||||
|
||||
export function SpecHeader({
|
||||
@@ -20,87 +29,200 @@ export function SpecHeader({
|
||||
isRegenerating,
|
||||
isCreating,
|
||||
isGeneratingFeatures,
|
||||
isSyncing,
|
||||
isSaving,
|
||||
hasChanges,
|
||||
currentPhase,
|
||||
errorMessage,
|
||||
onRegenerateClick,
|
||||
onGenerateFeaturesClick,
|
||||
onSyncClick,
|
||||
onSaveClick,
|
||||
showActionsPanel,
|
||||
onToggleActionsPanel,
|
||||
}: SpecHeaderProps) {
|
||||
const isProcessing = isRegenerating || isCreating || isGeneratingFeatures;
|
||||
const isProcessing = isRegenerating || isCreating || isGeneratingFeatures || isSyncing;
|
||||
const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">App Specification</h1>
|
||||
<p className="text-sm text-muted-foreground">{projectPath}/.automaker/app_spec.txt</p>
|
||||
<>
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">App Specification</h1>
|
||||
<p className="text-sm text-muted-foreground">{projectPath}/.automaker/app_spec.txt</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status indicators - always visible */}
|
||||
{isProcessing && (
|
||||
<div className="hidden lg:flex items-center gap-3 px-6 py-3.5 rounded-xl bg-linear-to-r from-primary/15 to-primary/5 border border-primary/30 shadow-lg backdrop-blur-md">
|
||||
<div className="relative">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary shrink-0" />
|
||||
<div className="absolute inset-0 w-5 h-5 animate-ping text-primary/20" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="text-sm font-semibold text-primary leading-tight tracking-tight">
|
||||
{isSyncing
|
||||
? 'Syncing Specification'
|
||||
: isGeneratingFeatures
|
||||
? 'Generating Features'
|
||||
: isCreating
|
||||
? 'Generating Specification'
|
||||
: 'Regenerating Specification'}
|
||||
</span>
|
||||
{currentPhase && (
|
||||
<span className="text-xs text-muted-foreground/90 leading-tight font-medium">
|
||||
{phaseLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Mobile processing indicator */}
|
||||
{isProcessing && (
|
||||
<div className="lg:hidden flex items-center gap-2 px-3 py-2 rounded-lg bg-primary/10 border border-primary/20">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
<span className="text-xs font-medium text-primary">Processing...</span>
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<div className="hidden lg:flex items-center gap-3 px-6 py-3.5 rounded-xl bg-linear-to-r from-destructive/15 to-destructive/5 border border-destructive/30 shadow-lg backdrop-blur-md">
|
||||
<AlertCircle className="w-5 h-5 text-destructive shrink-0" />
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="text-sm font-semibold text-destructive leading-tight tracking-tight">
|
||||
Error
|
||||
</span>
|
||||
<span className="text-xs text-destructive/90 leading-tight font-medium">
|
||||
{errorMessage}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Mobile error indicator */}
|
||||
{errorMessage && (
|
||||
<div className="lg:hidden flex items-center gap-2 px-3 py-2 rounded-lg bg-destructive/10 border border-destructive/20">
|
||||
<AlertCircle className="w-4 h-4 text-destructive" />
|
||||
<span className="text-xs font-medium text-destructive">Error</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Desktop: show actions inline - hidden when processing since status card shows progress */}
|
||||
{!isProcessing && (
|
||||
<div className="hidden lg:flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onSyncClick} data-testid="sync-spec">
|
||||
<RefreshCcw className="w-4 h-4 mr-2" />
|
||||
Sync
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRegenerateClick}
|
||||
data-testid="regenerate-spec"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Regenerate
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onGenerateFeaturesClick}
|
||||
data-testid="generate-features"
|
||||
>
|
||||
<ListPlus className="w-4 h-4 mr-2" />
|
||||
Generate Features
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSaveClick}
|
||||
disabled={!hasChanges || isSaving}
|
||||
data-testid="save-spec"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Tablet/Mobile: show trigger for actions panel */}
|
||||
<HeaderActionsPanelTrigger isOpen={showActionsPanel} onToggle={onToggleActionsPanel} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
{/* Actions Panel (tablet/mobile) */}
|
||||
<HeaderActionsPanel
|
||||
isOpen={showActionsPanel}
|
||||
onClose={onToggleActionsPanel}
|
||||
title="Specification Actions"
|
||||
>
|
||||
{/* Status messages in panel */}
|
||||
{isProcessing && (
|
||||
<div className="flex items-center gap-3 px-6 py-3.5 rounded-xl bg-linear-to-r from-primary/15 to-primary/5 border border-primary/30 shadow-lg backdrop-blur-md">
|
||||
<div className="relative">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary shrink-0" />
|
||||
<div className="absolute inset-0 w-5 h-5 animate-ping text-primary/20" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="text-sm font-semibold text-primary leading-tight tracking-tight">
|
||||
{isGeneratingFeatures
|
||||
? 'Generating Features'
|
||||
: isCreating
|
||||
? 'Generating Specification'
|
||||
: 'Regenerating Specification'}
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-primary/10 border border-primary/20">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary shrink-0" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-sm font-medium text-primary">
|
||||
{isSyncing
|
||||
? 'Syncing Specification'
|
||||
: isGeneratingFeatures
|
||||
? 'Generating Features'
|
||||
: isCreating
|
||||
? 'Generating Specification'
|
||||
: 'Regenerating Specification'}
|
||||
</span>
|
||||
{currentPhase && (
|
||||
<span className="text-xs text-muted-foreground/90 leading-tight font-medium">
|
||||
{phaseLabel}
|
||||
</span>
|
||||
)}
|
||||
{currentPhase && <span className="text-xs text-muted-foreground">{phaseLabel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<div className="flex items-center gap-3 px-6 py-3.5 rounded-xl bg-linear-to-r from-destructive/15 to-destructive/5 border border-destructive/30 shadow-lg backdrop-blur-md">
|
||||
<AlertCircle className="w-5 h-5 text-destructive shrink-0" />
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="text-sm font-semibold text-destructive leading-tight tracking-tight">
|
||||
Error
|
||||
</span>
|
||||
<span className="text-xs text-destructive/90 leading-tight font-medium">
|
||||
{errorMessage}
|
||||
</span>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-destructive/10 border border-destructive/20">
|
||||
<AlertCircle className="w-4 h-4 text-destructive shrink-0" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-sm font-medium text-destructive">Error</span>
|
||||
<span className="text-xs text-destructive/80">{errorMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRegenerateClick}
|
||||
disabled={isProcessing}
|
||||
data-testid="regenerate-spec"
|
||||
>
|
||||
{isRegenerating ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
{/* Hide action buttons when processing - status card shows progress */}
|
||||
{!isProcessing && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={onSyncClick}
|
||||
data-testid="sync-spec-mobile"
|
||||
>
|
||||
<RefreshCcw className="w-4 h-4 mr-2" />
|
||||
Sync
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={onRegenerateClick}
|
||||
data-testid="regenerate-spec-mobile"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isRegenerating ? 'Regenerating...' : 'Regenerate'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSaveClick}
|
||||
disabled={!hasChanges || isSaving || isProcessing}
|
||||
data-testid="save-spec"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Regenerate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={onGenerateFeaturesClick}
|
||||
data-testid="generate-features-mobile"
|
||||
>
|
||||
<ListPlus className="w-4 h-4 mr-2" />
|
||||
Generate Features
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
onClick={onSaveClick}
|
||||
disabled={!hasChanges || isSaving}
|
||||
data-testid="save-spec-mobile"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</HeaderActionsPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export const PHASE_LABELS: Record<string, string> = {
|
||||
analysis: 'Analyzing project structure...',
|
||||
spec_complete: 'Spec created! Generating features...',
|
||||
feature_generation: 'Creating features from roadmap...',
|
||||
working: 'Working...',
|
||||
complete: 'Complete!',
|
||||
error: 'Error occurred',
|
||||
};
|
||||
|
||||
@@ -39,6 +39,9 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
// Generate features only state
|
||||
const [isGeneratingFeatures, setIsGeneratingFeatures] = useState(false);
|
||||
|
||||
// Sync state
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
// Logs state (kept for internal tracking)
|
||||
const [logs, setLogs] = useState<string>('');
|
||||
const logsRef = useRef<string>('');
|
||||
@@ -55,6 +58,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
setIsCreating(false);
|
||||
setIsRegenerating(false);
|
||||
setIsGeneratingFeatures(false);
|
||||
setIsSyncing(false);
|
||||
setCurrentPhase('');
|
||||
setErrorMessage('');
|
||||
setLogs('');
|
||||
@@ -135,7 +139,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
if (
|
||||
!document.hidden &&
|
||||
currentProject &&
|
||||
(isCreating || isRegenerating || isGeneratingFeatures)
|
||||
(isCreating || isRegenerating || isGeneratingFeatures || isSyncing)
|
||||
) {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
@@ -151,6 +155,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
setIsCreating(false);
|
||||
setIsRegenerating(false);
|
||||
setIsGeneratingFeatures(false);
|
||||
setIsSyncing(false);
|
||||
setCurrentPhase('');
|
||||
stateRestoredRef.current = false;
|
||||
loadSpec();
|
||||
@@ -167,11 +172,12 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [currentProject, isCreating, isRegenerating, isGeneratingFeatures, loadSpec]);
|
||||
}, [currentProject, isCreating, isRegenerating, isGeneratingFeatures, isSyncing, loadSpec]);
|
||||
|
||||
// Periodic status check
|
||||
useEffect(() => {
|
||||
if (!currentProject || (!isCreating && !isRegenerating && !isGeneratingFeatures)) return;
|
||||
if (!currentProject || (!isCreating && !isRegenerating && !isGeneratingFeatures && !isSyncing))
|
||||
return;
|
||||
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
@@ -187,6 +193,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
setIsCreating(false);
|
||||
setIsRegenerating(false);
|
||||
setIsGeneratingFeatures(false);
|
||||
setIsSyncing(false);
|
||||
setCurrentPhase('');
|
||||
stateRestoredRef.current = false;
|
||||
loadSpec();
|
||||
@@ -205,7 +212,15 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [currentProject, isCreating, isRegenerating, isGeneratingFeatures, currentPhase, loadSpec]);
|
||||
}, [
|
||||
currentProject,
|
||||
isCreating,
|
||||
isRegenerating,
|
||||
isGeneratingFeatures,
|
||||
isSyncing,
|
||||
currentPhase,
|
||||
loadSpec,
|
||||
]);
|
||||
|
||||
// Subscribe to spec regeneration events
|
||||
useEffect(() => {
|
||||
@@ -317,7 +332,8 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
event.message === 'All tasks completed!' ||
|
||||
event.message === 'All tasks completed' ||
|
||||
event.message === 'Spec regeneration complete!' ||
|
||||
event.message === 'Initial spec creation complete!';
|
||||
event.message === 'Initial spec creation complete!' ||
|
||||
event.message?.includes('Spec sync complete');
|
||||
|
||||
const hasCompletePhase = logsRef.current.includes('[Phase: complete]');
|
||||
|
||||
@@ -337,6 +353,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
setIsRegenerating(false);
|
||||
setIsCreating(false);
|
||||
setIsGeneratingFeatures(false);
|
||||
setIsSyncing(false);
|
||||
setCurrentPhase('');
|
||||
setShowRegenerateDialog(false);
|
||||
setShowCreateDialog(false);
|
||||
@@ -349,18 +366,23 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
loadSpec();
|
||||
}, SPEC_FILE_WRITE_DELAY);
|
||||
|
||||
const isSyncComplete = event.message?.includes('sync');
|
||||
const isRegeneration = event.message?.includes('regeneration');
|
||||
const isFeatureGeneration = event.message?.includes('Feature generation');
|
||||
toast.success(
|
||||
isFeatureGeneration
|
||||
? 'Feature Generation Complete'
|
||||
: isRegeneration
|
||||
? 'Spec Regeneration Complete'
|
||||
: 'Spec Creation Complete',
|
||||
isSyncComplete
|
||||
? 'Spec Sync Complete'
|
||||
: isFeatureGeneration
|
||||
? 'Feature Generation Complete'
|
||||
: isRegeneration
|
||||
? 'Spec Regeneration Complete'
|
||||
: 'Spec Creation Complete',
|
||||
{
|
||||
description: isFeatureGeneration
|
||||
? 'Features have been created from the app specification.'
|
||||
: 'Your app specification has been saved.',
|
||||
description: isSyncComplete
|
||||
? 'Your spec has been updated with the latest changes.'
|
||||
: isFeatureGeneration
|
||||
? 'Features have been created from the app specification.'
|
||||
: 'Your app specification has been saved.',
|
||||
icon: createElement(CheckCircle2, { className: 'w-4 h-4' }),
|
||||
}
|
||||
);
|
||||
@@ -378,6 +400,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
setIsRegenerating(false);
|
||||
setIsCreating(false);
|
||||
setIsGeneratingFeatures(false);
|
||||
setIsSyncing(false);
|
||||
setCurrentPhase('error');
|
||||
setErrorMessage(event.error);
|
||||
stateRestoredRef.current = false;
|
||||
@@ -544,6 +567,46 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
}
|
||||
}, [currentProject]);
|
||||
|
||||
const handleSync = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsSyncing(true);
|
||||
setCurrentPhase('sync');
|
||||
setErrorMessage('');
|
||||
logsRef.current = '';
|
||||
setLogs('');
|
||||
logger.debug('[useSpecGeneration] Starting spec sync');
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) {
|
||||
logger.error('[useSpecGeneration] Spec regeneration not available');
|
||||
setIsSyncing(false);
|
||||
return;
|
||||
}
|
||||
const result = await api.specRegeneration.sync(currentProject.path);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMsg = result.error || 'Unknown error';
|
||||
logger.error('[useSpecGeneration] Failed to start spec sync:', errorMsg);
|
||||
setIsSyncing(false);
|
||||
setCurrentPhase('error');
|
||||
setErrorMessage(errorMsg);
|
||||
const errorLog = `[Error] Failed to start spec sync: ${errorMsg}\n`;
|
||||
logsRef.current = errorLog;
|
||||
setLogs(errorLog);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('[useSpecGeneration] Failed to sync spec:', errorMsg);
|
||||
setIsSyncing(false);
|
||||
setCurrentPhase('error');
|
||||
setErrorMessage(errorMsg);
|
||||
const errorLog = `[Error] Failed to sync spec: ${errorMsg}\n`;
|
||||
logsRef.current = errorLog;
|
||||
setLogs(errorLog);
|
||||
}
|
||||
}, [currentProject]);
|
||||
|
||||
return {
|
||||
// Dialog state
|
||||
showCreateDialog,
|
||||
@@ -576,6 +639,9 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
// Feature generation state
|
||||
isGeneratingFeatures,
|
||||
|
||||
// Sync state
|
||||
isSyncing,
|
||||
|
||||
// Status state
|
||||
currentPhase,
|
||||
errorMessage,
|
||||
@@ -584,6 +650,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
// Handlers
|
||||
handleCreateSpec,
|
||||
handleRegenerate,
|
||||
handleSync,
|
||||
handleGenerateFeatures,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,20 +18,21 @@ export function useSpecLoading() {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Check if spec generation is running before trying to load
|
||||
// This prevents showing "No App Specification Found" during generation
|
||||
// Check if spec generation is running
|
||||
if (api.specRegeneration) {
|
||||
const status = await api.specRegeneration.status(currentProject.path);
|
||||
if (status.success && status.isRunning) {
|
||||
logger.debug('Spec generation is running for this project, skipping load');
|
||||
logger.debug('Spec generation is running for this project');
|
||||
setIsGenerationRunning(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
} else {
|
||||
setIsGenerationRunning(false);
|
||||
}
|
||||
} else {
|
||||
setIsGenerationRunning(false);
|
||||
}
|
||||
// Always reset when generation is not running (handles edge case where api.specRegeneration might not be available)
|
||||
setIsGenerationRunning(false);
|
||||
|
||||
// Always try to load the spec file, even if generation is running
|
||||
// This allows users to view their existing spec while generating features
|
||||
const result = await api.readFile(`${currentProject.path}/.automaker/app_spec.txt`);
|
||||
|
||||
if (result.success && result.content) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user