mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
refactor: improve dialog and auto mode service functionality
- Refactored DialogContent component to use forwardRef for better integration with refs. - Enhanced auto mode service by introducing an auto loop for processing features concurrently. - Updated error handling and feature management logic to streamline operations. - Cleaned up code formatting and improved readability across various components and services.
This commit is contained in:
@@ -49,16 +49,18 @@ function DialogOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
compact = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
export type DialogContentProps = Omit<
|
||||
React.ComponentProps<typeof DialogPrimitive.Content>,
|
||||
"ref"
|
||||
> & {
|
||||
showCloseButton?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
};
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
DialogContentProps
|
||||
>(({ className, children, showCloseButton = true, compact = false, ...props }, ref) => {
|
||||
// Check if className contains a custom max-width
|
||||
const hasCustomMaxWidth =
|
||||
typeof className === "string" && className.includes("max-w-");
|
||||
@@ -67,6 +69,7 @@ function DialogContent({
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
|
||||
@@ -110,7 +113,9 @@ function DialogContent({
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
|
||||
@@ -43,3 +43,4 @@ RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
|
||||
|
||||
|
||||
@@ -28,3 +28,4 @@ Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
|
||||
|
||||
|
||||
@@ -53,3 +53,4 @@ export function ArchiveAllVerifiedDialog({
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -120,7 +120,9 @@ export async function getDragHandleForFeature(
|
||||
*/
|
||||
export async function clickAddFeature(page: Page): Promise<void> {
|
||||
await page.click('[data-testid="add-feature-button"]');
|
||||
await page.waitForSelector('[data-testid="add-feature-dialog"]', { timeout: 5000 });
|
||||
await page.waitForSelector('[data-testid="add-feature-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,18 +134,22 @@ export async function fillAddFeatureDialog(
|
||||
options?: { branch?: string; category?: string }
|
||||
): Promise<void> {
|
||||
// Fill description (using the dropzone textarea)
|
||||
const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first();
|
||||
const descriptionInput = page
|
||||
.locator('[data-testid="add-feature-dialog"] textarea')
|
||||
.first();
|
||||
await descriptionInput.fill(description);
|
||||
|
||||
// Fill branch if provided (it's a combobox autocomplete)
|
||||
if (options?.branch) {
|
||||
// First, select "Other branch" radio option if not already selected
|
||||
const otherBranchRadio = page.locator('[data-testid="feature-radio-group"]').locator('[id="feature-other"]');
|
||||
const otherBranchRadio = page
|
||||
.locator('[data-testid="feature-radio-group"]')
|
||||
.locator('[id="feature-other"]');
|
||||
await otherBranchRadio.waitFor({ state: "visible", timeout: 5000 });
|
||||
await otherBranchRadio.click();
|
||||
// Wait for the branch input to appear
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
|
||||
// Now click on the branch input (autocomplete)
|
||||
const branchInput = page.locator('[data-testid="feature-input"]');
|
||||
await branchInput.waitFor({ state: "visible", timeout: 5000 });
|
||||
@@ -151,7 +157,7 @@ export async function fillAddFeatureDialog(
|
||||
// Wait for the popover to open
|
||||
await page.waitForTimeout(300);
|
||||
// Type in the command input
|
||||
const commandInput = page.locator('[cmdk-input]');
|
||||
const commandInput = page.locator("[cmdk-input]");
|
||||
await commandInput.fill(options.branch);
|
||||
// Press Enter to select/create the branch
|
||||
await commandInput.press("Enter");
|
||||
@@ -161,10 +167,12 @@ export async function fillAddFeatureDialog(
|
||||
|
||||
// Fill category if provided (it's also a combobox autocomplete)
|
||||
if (options?.category) {
|
||||
const categoryButton = page.locator('[data-testid="feature-category-input"]');
|
||||
const categoryButton = page.locator(
|
||||
'[data-testid="feature-category-input"]'
|
||||
);
|
||||
await categoryButton.click();
|
||||
await page.waitForTimeout(300);
|
||||
const commandInput = page.locator('[cmdk-input]');
|
||||
const commandInput = page.locator("[cmdk-input]");
|
||||
await commandInput.fill(options.category);
|
||||
await commandInput.press("Enter");
|
||||
await page.waitForTimeout(200);
|
||||
@@ -210,8 +218,13 @@ export async function getWorktreeSelector(page: Page): Promise<Locator> {
|
||||
/**
|
||||
* Click on a branch button in the worktree selector
|
||||
*/
|
||||
export async function selectWorktreeBranch(page: Page, branchName: string): Promise<void> {
|
||||
const branchButton = page.getByRole("button", { name: new RegExp(branchName, "i") });
|
||||
export async function selectWorktreeBranch(
|
||||
page: Page,
|
||||
branchName: string
|
||||
): Promise<void> {
|
||||
const branchButton = page.getByRole("button", {
|
||||
name: new RegExp(branchName, "i"),
|
||||
});
|
||||
await branchButton.click();
|
||||
await page.waitForTimeout(500); // Wait for UI to update
|
||||
}
|
||||
@@ -219,9 +232,13 @@ export async function selectWorktreeBranch(page: Page, branchName: string): Prom
|
||||
/**
|
||||
* Get the currently selected branch in the worktree selector
|
||||
*/
|
||||
export async function getSelectedWorktreeBranch(page: Page): Promise<string | null> {
|
||||
export async function getSelectedWorktreeBranch(
|
||||
page: Page
|
||||
): Promise<string | null> {
|
||||
// The main branch button has aria-pressed="true" when selected
|
||||
const selectedButton = page.locator('[data-testid="worktree-selector"] button[aria-pressed="true"]');
|
||||
const selectedButton = page.locator(
|
||||
'[data-testid="worktree-selector"] button[aria-pressed="true"]'
|
||||
);
|
||||
const text = await selectedButton.textContent().catch(() => null);
|
||||
return text?.trim() || null;
|
||||
}
|
||||
@@ -229,7 +246,12 @@ export async function getSelectedWorktreeBranch(page: Page): Promise<string | nu
|
||||
/**
|
||||
* Check if a branch button is visible in the worktree selector
|
||||
*/
|
||||
export async function isWorktreeBranchVisible(page: Page, branchName: string): Promise<boolean> {
|
||||
const branchButton = page.getByRole("button", { name: new RegExp(branchName, "i") });
|
||||
export async function isWorktreeBranchVisible(
|
||||
page: Page,
|
||||
branchName: string
|
||||
): Promise<boolean> {
|
||||
const branchButton = page.getByRole("button", {
|
||||
name: new RegExp(branchName, "i"),
|
||||
});
|
||||
return await branchButton.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
@@ -23,3 +23,4 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -103,3 +103,4 @@ export function createDeleteApiKeyHandler() {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
|
||||
import { createAutoModeOptions } from "../lib/sdk-options.js";
|
||||
import { isAbortError, classifyError } from "../lib/error-handler.js";
|
||||
import type { Feature } from "./feature-loader.js";
|
||||
import { FeatureLoader } from "./feature-loader.js";
|
||||
import { getFeatureDir, getAutomakerDir } from "../lib/automaker-paths.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -35,9 +36,18 @@ interface RunningFeature {
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
interface AutoLoopState {
|
||||
projectPath: string;
|
||||
maxConcurrency: number;
|
||||
abortController: AbortController;
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
export class AutoModeService {
|
||||
private events: EventEmitter;
|
||||
private runningFeatures = new Map<string, RunningFeature>();
|
||||
private autoLoop: AutoLoopState | null = null;
|
||||
private featureLoader = new FeatureLoader();
|
||||
|
||||
constructor(events: EventEmitter) {
|
||||
this.events = events;
|
||||
@@ -57,32 +67,47 @@ export class AutoModeService {
|
||||
isAutoMode = false
|
||||
): Promise<void> {
|
||||
if (this.runningFeatures.has(featureId)) {
|
||||
throw new Error(`Feature ${featureId} is already running`);
|
||||
}
|
||||
|
||||
// Check if feature has existing context - if so, resume instead of starting fresh
|
||||
const hasExistingContext = await this.contextExists(projectPath, featureId);
|
||||
if (hasExistingContext) {
|
||||
console.log(
|
||||
`[AutoMode] Feature ${featureId} has existing context, resuming instead of starting fresh`
|
||||
);
|
||||
return this.resumeFeature(projectPath, featureId, useWorktrees);
|
||||
throw new Error("already running");
|
||||
}
|
||||
|
||||
// Add to running features immediately to prevent race conditions
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Emit feature start event early
|
||||
this.emitAutoModeEvent("auto_mode_feature_start", {
|
||||
const tempRunningFeature: RunningFeature = {
|
||||
featureId,
|
||||
projectPath,
|
||||
feature: {
|
||||
id: featureId,
|
||||
title: "Loading...",
|
||||
description: "Feature is starting",
|
||||
},
|
||||
});
|
||||
worktreePath: null,
|
||||
branchName: null,
|
||||
abortController,
|
||||
isAutoMode,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
this.runningFeatures.set(featureId, tempRunningFeature);
|
||||
|
||||
try {
|
||||
// Check if feature has existing context - if so, resume instead of starting fresh
|
||||
const hasExistingContext = await this.contextExists(
|
||||
projectPath,
|
||||
featureId
|
||||
);
|
||||
if (hasExistingContext) {
|
||||
console.log(
|
||||
`[AutoMode] Feature ${featureId} has existing context, resuming instead of starting fresh`
|
||||
);
|
||||
// Remove from running features temporarily, resumeFeature will add it back
|
||||
this.runningFeatures.delete(featureId);
|
||||
return this.resumeFeature(projectPath, featureId, useWorktrees);
|
||||
}
|
||||
|
||||
// Emit feature start event early
|
||||
this.emitAutoModeEvent("auto_mode_feature_start", {
|
||||
featureId,
|
||||
projectPath,
|
||||
feature: {
|
||||
id: featureId,
|
||||
title: "Loading...",
|
||||
description: "Feature is starting",
|
||||
},
|
||||
});
|
||||
// Load feature details FIRST to get branchName
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
if (!feature) {
|
||||
@@ -90,9 +115,9 @@ export class AutoModeService {
|
||||
}
|
||||
|
||||
// Derive workDir from feature.branchName
|
||||
// If no branchName, use the project path directly
|
||||
// If no branchName, derive from feature ID: feature/{featureId}
|
||||
let worktreePath: string | null = null;
|
||||
const branchName = feature.branchName || null;
|
||||
const branchName = feature.branchName || `feature/${featureId}`;
|
||||
|
||||
if (useWorktrees && branchName) {
|
||||
// Try to find existing worktree for this branch
|
||||
@@ -120,15 +145,9 @@ export class AutoModeService {
|
||||
? path.resolve(worktreePath)
|
||||
: path.resolve(projectPath);
|
||||
|
||||
this.runningFeatures.set(featureId, {
|
||||
featureId,
|
||||
projectPath,
|
||||
worktreePath,
|
||||
branchName,
|
||||
abortController,
|
||||
isAutoMode,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
// Update running feature with actual worktree info
|
||||
tempRunningFeature.worktreePath = worktreePath;
|
||||
tempRunningFeature.branchName = branchName;
|
||||
|
||||
// Update feature status to in_progress
|
||||
await this.updateFeatureStatus(projectPath, featureId, "in_progress");
|
||||
@@ -169,7 +188,7 @@ export class AutoModeService {
|
||||
featureId,
|
||||
passes: true,
|
||||
message: `Feature completed in ${Math.round(
|
||||
(Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000
|
||||
(Date.now() - tempRunningFeature.startTime) / 1000
|
||||
)}s`,
|
||||
projectPath,
|
||||
});
|
||||
@@ -219,6 +238,10 @@ export class AutoModeService {
|
||||
featureId: string,
|
||||
useWorktrees = false
|
||||
): Promise<void> {
|
||||
if (this.runningFeatures.has(featureId)) {
|
||||
throw new Error("already running");
|
||||
}
|
||||
|
||||
// Check if context exists in .automaker directory
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const contextPath = path.join(featureDir, "agent-output.md");
|
||||
@@ -242,7 +265,9 @@ export class AutoModeService {
|
||||
);
|
||||
}
|
||||
|
||||
// No context, start fresh
|
||||
// No context, start fresh - executeFeature will handle adding to runningFeatures
|
||||
// Remove the temporary entry we added
|
||||
this.runningFeatures.delete(featureId);
|
||||
return this.executeFeature(projectPath, featureId, useWorktrees, false);
|
||||
}
|
||||
|
||||
@@ -266,9 +291,10 @@ export class AutoModeService {
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
|
||||
// Derive workDir from feature.branchName
|
||||
// If no branchName, derive from feature ID: feature/{featureId}
|
||||
let workDir = path.resolve(projectPath);
|
||||
let worktreePath: string | null = null;
|
||||
const branchName = feature?.branchName || null;
|
||||
const branchName = feature?.branchName || `feature/${featureId}`;
|
||||
|
||||
if (useWorktrees && branchName) {
|
||||
// Try to find existing worktree for this branch
|
||||
@@ -700,6 +726,61 @@ Format your response as a structured markdown document.`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the auto loop to process pending features
|
||||
* @param projectPath - The project path
|
||||
* @param maxConcurrency - Maximum number of features to process concurrently
|
||||
* @returns Promise that resolves when the loop stops
|
||||
*/
|
||||
async startAutoLoop(
|
||||
projectPath: string,
|
||||
maxConcurrency: number
|
||||
): Promise<void> {
|
||||
if (this.autoLoop?.isRunning) {
|
||||
throw new Error("Auto mode is already running");
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
this.autoLoop = {
|
||||
projectPath,
|
||||
maxConcurrency,
|
||||
abortController,
|
||||
isRunning: true,
|
||||
};
|
||||
|
||||
// Emit start event
|
||||
this.emitAutoModeEvent("auto_mode_started", {
|
||||
projectPath,
|
||||
message: `Auto mode started with max concurrency: ${maxConcurrency}`,
|
||||
});
|
||||
|
||||
// Start the loop
|
||||
return this.runAutoLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the auto loop
|
||||
* @returns Number of running features (should be 0 after stopping)
|
||||
*/
|
||||
async stopAutoLoop(): Promise<number> {
|
||||
if (!this.autoLoop?.isRunning) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const runningCount = this.runningFeatures.size;
|
||||
this.autoLoop.abortController.abort();
|
||||
this.autoLoop.isRunning = false;
|
||||
this.autoLoop = null;
|
||||
|
||||
// Emit stop event
|
||||
this.emitAutoModeEvent("auto_mode_stopped", {
|
||||
message: "Auto mode stopped",
|
||||
runningCount,
|
||||
});
|
||||
|
||||
return runningCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current status
|
||||
*/
|
||||
@@ -734,6 +815,89 @@ Format your response as a structured markdown document.`;
|
||||
|
||||
// Private helpers
|
||||
|
||||
/**
|
||||
* Run the auto loop to process pending features
|
||||
*/
|
||||
private async runAutoLoop(): Promise<void> {
|
||||
if (!this.autoLoop) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { projectPath, maxConcurrency, abortController } = this.autoLoop;
|
||||
const runningPromises = new Map<string, Promise<void>>();
|
||||
|
||||
try {
|
||||
while (!abortController.signal.aborted && this.autoLoop.isRunning) {
|
||||
// Get all features
|
||||
const allFeatures = await this.featureLoader.getAll(projectPath);
|
||||
|
||||
// Filter to pending features that aren't already running
|
||||
const pendingFeatures = allFeatures.filter(
|
||||
(feature) =>
|
||||
feature.status === "pending" &&
|
||||
!this.runningFeatures.has(feature.id) &&
|
||||
!runningPromises.has(feature.id)
|
||||
);
|
||||
|
||||
// Start new features up to max concurrency
|
||||
while (
|
||||
runningPromises.size < maxConcurrency &&
|
||||
pendingFeatures.length > 0 &&
|
||||
!abortController.signal.aborted
|
||||
) {
|
||||
const feature = pendingFeatures.shift();
|
||||
if (!feature) break;
|
||||
|
||||
const promise = this.executeFeature(
|
||||
projectPath,
|
||||
feature.id,
|
||||
true, // useWorktrees
|
||||
true // isAutoMode
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`[AutoMode] Feature ${feature.id} failed in auto loop:`,
|
||||
error
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
runningPromises.delete(feature.id);
|
||||
});
|
||||
|
||||
runningPromises.set(feature.id, promise);
|
||||
}
|
||||
|
||||
// Wait a bit before checking again
|
||||
if (runningPromises.size > 0) {
|
||||
await Promise.race([
|
||||
Promise.allSettled(Array.from(runningPromises.values())),
|
||||
new Promise<void>((resolve) => {
|
||||
abortController.signal.addEventListener(
|
||||
"abort",
|
||||
() => resolve(),
|
||||
{
|
||||
once: true,
|
||||
}
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
// No features running, wait before checking again
|
||||
await this.sleep(1000, abortController.signal);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isAbortError(error)) {
|
||||
console.error("[AutoMode] Auto loop error:", error);
|
||||
}
|
||||
} finally {
|
||||
// Wait for all running features to complete
|
||||
if (runningPromises.size > 0) {
|
||||
await Promise.allSettled(Array.from(runningPromises.values()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing worktree for a given branch by checking git worktree list
|
||||
*/
|
||||
@@ -1209,31 +1373,42 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
useWorktrees: boolean
|
||||
): Promise<void> {
|
||||
if (this.runningFeatures.has(featureId)) {
|
||||
throw new Error(`Feature ${featureId} is already running`);
|
||||
throw new Error("already running");
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Emit feature start event early
|
||||
this.emitAutoModeEvent("auto_mode_feature_start", {
|
||||
// Add to running features immediately
|
||||
const tempRunningFeature: RunningFeature = {
|
||||
featureId,
|
||||
projectPath,
|
||||
feature: {
|
||||
id: featureId,
|
||||
title: "Resuming...",
|
||||
description: "Feature is resuming from previous context",
|
||||
},
|
||||
});
|
||||
worktreePath: null,
|
||||
branchName: null,
|
||||
abortController,
|
||||
isAutoMode: false,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
this.runningFeatures.set(featureId, tempRunningFeature);
|
||||
|
||||
try {
|
||||
// Emit feature start event early
|
||||
this.emitAutoModeEvent("auto_mode_feature_start", {
|
||||
featureId,
|
||||
projectPath,
|
||||
feature: {
|
||||
id: featureId,
|
||||
title: "Resuming...",
|
||||
description: "Feature is resuming from previous context",
|
||||
},
|
||||
});
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
|
||||
// Derive workDir from feature.branchName
|
||||
// If no branchName, derive from feature ID: feature/{featureId}
|
||||
let worktreePath: string | null = null;
|
||||
const branchName = feature.branchName || null;
|
||||
const branchName = feature.branchName || `feature/${featureId}`;
|
||||
|
||||
if (useWorktrees && branchName) {
|
||||
worktreePath = await this.findExistingWorktreeForBranch(
|
||||
@@ -1256,15 +1431,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
? path.resolve(worktreePath)
|
||||
: path.resolve(projectPath);
|
||||
|
||||
this.runningFeatures.set(featureId, {
|
||||
featureId,
|
||||
projectPath,
|
||||
worktreePath,
|
||||
branchName,
|
||||
abortController,
|
||||
isAutoMode: false,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
// Update running feature with actual worktree info
|
||||
tempRunningFeature.worktreePath = worktreePath;
|
||||
tempRunningFeature.branchName = branchName;
|
||||
|
||||
// Update feature status to in_progress
|
||||
await this.updateFeatureStatus(projectPath, featureId, "in_progress");
|
||||
@@ -1315,7 +1484,7 @@ Review the previous work and continue the implementation. If the feature appears
|
||||
featureId,
|
||||
passes: true,
|
||||
message: `Feature resumed and completed in ${Math.round(
|
||||
(Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000
|
||||
(Date.now() - tempRunningFeature.startTime) / 1000
|
||||
)}s`,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
@@ -582,3 +582,4 @@ The route organization pattern provides:
|
||||
Apply this pattern to all route modules for consistency and improved code quality.
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user