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:
Cody Seibert
2025-12-17 22:45:39 -05:00
parent 0549b8085a
commit c80ae3367a
9 changed files with 278 additions and 76 deletions

View File

@@ -49,16 +49,18 @@ function DialogOverlay({
); );
} }
function DialogContent({ export type DialogContentProps = Omit<
className, React.ComponentProps<typeof DialogPrimitive.Content>,
children, "ref"
showCloseButton = true, > & {
compact = false,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean; showCloseButton?: boolean;
compact?: 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 // Check if className contains a custom max-width
const hasCustomMaxWidth = const hasCustomMaxWidth =
typeof className === "string" && className.includes("max-w-"); typeof className === "string" && className.includes("max-w-");
@@ -67,6 +69,7 @@ function DialogContent({
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref}
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]", "fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
@@ -110,7 +113,9 @@ function DialogContent({
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
); );
} });
DialogContent.displayName = "DialogContent";
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (

View File

@@ -43,3 +43,4 @@ RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem }; export { RadioGroup, RadioGroupItem };

View File

@@ -28,3 +28,4 @@ Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch }; export { Switch };

View File

@@ -53,3 +53,4 @@ export function ArchiveAllVerifiedDialog({
); );
} }

View File

@@ -120,7 +120,9 @@ export async function getDragHandleForFeature(
*/ */
export async function clickAddFeature(page: Page): Promise<void> { export async function clickAddFeature(page: Page): Promise<void> {
await page.click('[data-testid="add-feature-button"]'); 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 } options?: { branch?: string; category?: string }
): Promise<void> { ): Promise<void> {
// Fill description (using the dropzone textarea) // 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); await descriptionInput.fill(description);
// Fill branch if provided (it's a combobox autocomplete) // Fill branch if provided (it's a combobox autocomplete)
if (options?.branch) { if (options?.branch) {
// First, select "Other branch" radio option if not already selected // 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.waitFor({ state: "visible", timeout: 5000 });
await otherBranchRadio.click(); await otherBranchRadio.click();
// Wait for the branch input to appear // Wait for the branch input to appear
await page.waitForTimeout(300); await page.waitForTimeout(300);
// Now click on the branch input (autocomplete) // Now click on the branch input (autocomplete)
const branchInput = page.locator('[data-testid="feature-input"]'); const branchInput = page.locator('[data-testid="feature-input"]');
await branchInput.waitFor({ state: "visible", timeout: 5000 }); await branchInput.waitFor({ state: "visible", timeout: 5000 });
@@ -151,7 +157,7 @@ export async function fillAddFeatureDialog(
// Wait for the popover to open // Wait for the popover to open
await page.waitForTimeout(300); await page.waitForTimeout(300);
// Type in the command input // Type in the command input
const commandInput = page.locator('[cmdk-input]'); const commandInput = page.locator("[cmdk-input]");
await commandInput.fill(options.branch); await commandInput.fill(options.branch);
// Press Enter to select/create the branch // Press Enter to select/create the branch
await commandInput.press("Enter"); await commandInput.press("Enter");
@@ -161,10 +167,12 @@ export async function fillAddFeatureDialog(
// Fill category if provided (it's also a combobox autocomplete) // Fill category if provided (it's also a combobox autocomplete)
if (options?.category) { 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 categoryButton.click();
await page.waitForTimeout(300); await page.waitForTimeout(300);
const commandInput = page.locator('[cmdk-input]'); const commandInput = page.locator("[cmdk-input]");
await commandInput.fill(options.category); await commandInput.fill(options.category);
await commandInput.press("Enter"); await commandInput.press("Enter");
await page.waitForTimeout(200); 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 * Click on a branch button in the worktree selector
*/ */
export async function selectWorktreeBranch(page: Page, branchName: string): Promise<void> { export async function selectWorktreeBranch(
const branchButton = page.getByRole("button", { name: new RegExp(branchName, "i") }); page: Page,
branchName: string
): Promise<void> {
const branchButton = page.getByRole("button", {
name: new RegExp(branchName, "i"),
});
await branchButton.click(); await branchButton.click();
await page.waitForTimeout(500); // Wait for UI to update 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 * 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 // 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); const text = await selectedButton.textContent().catch(() => null);
return text?.trim() || 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 * Check if a branch button is visible in the worktree selector
*/ */
export async function isWorktreeBranchVisible(page: Page, branchName: string): Promise<boolean> { export async function isWorktreeBranchVisible(
const branchButton = page.getByRole("button", { name: new RegExp(branchName, "i") }); page: Page,
branchName: string
): Promise<boolean> {
const branchButton = page.getByRole("button", {
name: new RegExp(branchName, "i"),
});
return await branchButton.isVisible().catch(() => false); return await branchButton.isVisible().catch(() => false);
} }

View File

@@ -23,3 +23,4 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
} }

View File

@@ -103,3 +103,4 @@ export function createDeleteApiKeyHandler() {
} }

View File

@@ -21,6 +21,7 @@ import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
import { createAutoModeOptions } from "../lib/sdk-options.js"; import { createAutoModeOptions } from "../lib/sdk-options.js";
import { isAbortError, classifyError } from "../lib/error-handler.js"; import { isAbortError, classifyError } from "../lib/error-handler.js";
import type { Feature } from "./feature-loader.js"; import type { Feature } from "./feature-loader.js";
import { FeatureLoader } from "./feature-loader.js";
import { getFeatureDir, getAutomakerDir } from "../lib/automaker-paths.js"; import { getFeatureDir, getAutomakerDir } from "../lib/automaker-paths.js";
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -35,9 +36,18 @@ interface RunningFeature {
startTime: number; startTime: number;
} }
interface AutoLoopState {
projectPath: string;
maxConcurrency: number;
abortController: AbortController;
isRunning: boolean;
}
export class AutoModeService { export class AutoModeService {
private events: EventEmitter; private events: EventEmitter;
private runningFeatures = new Map<string, RunningFeature>(); private runningFeatures = new Map<string, RunningFeature>();
private autoLoop: AutoLoopState | null = null;
private featureLoader = new FeatureLoader();
constructor(events: EventEmitter) { constructor(events: EventEmitter) {
this.events = events; this.events = events;
@@ -57,32 +67,47 @@ export class AutoModeService {
isAutoMode = false isAutoMode = false
): Promise<void> { ): Promise<void> {
if (this.runningFeatures.has(featureId)) { if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`); throw new Error("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);
} }
// Add to running features immediately to prevent race conditions
const abortController = new AbortController(); const abortController = new AbortController();
const tempRunningFeature: RunningFeature = {
// Emit feature start event early
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId, featureId,
projectPath, projectPath,
feature: { worktreePath: null,
id: featureId, branchName: null,
title: "Loading...", abortController,
description: "Feature is starting", isAutoMode,
}, startTime: Date.now(),
}); };
this.runningFeatures.set(featureId, tempRunningFeature);
try { 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 // Load feature details FIRST to get branchName
const feature = await this.loadFeature(projectPath, featureId); const feature = await this.loadFeature(projectPath, featureId);
if (!feature) { if (!feature) {
@@ -90,9 +115,9 @@ export class AutoModeService {
} }
// Derive workDir from feature.branchName // 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; let worktreePath: string | null = null;
const branchName = feature.branchName || null; const branchName = feature.branchName || `feature/${featureId}`;
if (useWorktrees && branchName) { if (useWorktrees && branchName) {
// Try to find existing worktree for this branch // Try to find existing worktree for this branch
@@ -120,15 +145,9 @@ export class AutoModeService {
? path.resolve(worktreePath) ? path.resolve(worktreePath)
: path.resolve(projectPath); : path.resolve(projectPath);
this.runningFeatures.set(featureId, { // Update running feature with actual worktree info
featureId, tempRunningFeature.worktreePath = worktreePath;
projectPath, tempRunningFeature.branchName = branchName;
worktreePath,
branchName,
abortController,
isAutoMode,
startTime: Date.now(),
});
// Update feature status to in_progress // Update feature status to in_progress
await this.updateFeatureStatus(projectPath, featureId, "in_progress"); await this.updateFeatureStatus(projectPath, featureId, "in_progress");
@@ -169,7 +188,7 @@ export class AutoModeService {
featureId, featureId,
passes: true, passes: true,
message: `Feature completed in ${Math.round( message: `Feature completed in ${Math.round(
(Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000 (Date.now() - tempRunningFeature.startTime) / 1000
)}s`, )}s`,
projectPath, projectPath,
}); });
@@ -219,6 +238,10 @@ export class AutoModeService {
featureId: string, featureId: string,
useWorktrees = false useWorktrees = false
): Promise<void> { ): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error("already running");
}
// Check if context exists in .automaker directory // Check if context exists in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId); const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, "agent-output.md"); 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); return this.executeFeature(projectPath, featureId, useWorktrees, false);
} }
@@ -266,9 +291,10 @@ export class AutoModeService {
const feature = await this.loadFeature(projectPath, featureId); const feature = await this.loadFeature(projectPath, featureId);
// Derive workDir from feature.branchName // Derive workDir from feature.branchName
// If no branchName, derive from feature ID: feature/{featureId}
let workDir = path.resolve(projectPath); let workDir = path.resolve(projectPath);
let worktreePath: string | null = null; let worktreePath: string | null = null;
const branchName = feature?.branchName || null; const branchName = feature?.branchName || `feature/${featureId}`;
if (useWorktrees && branchName) { if (useWorktrees && branchName) {
// Try to find existing worktree for this branch // 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 * Get current status
*/ */
@@ -734,6 +815,89 @@ Format your response as a structured markdown document.`;
// Private helpers // 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 * 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 useWorktrees: boolean
): Promise<void> { ): Promise<void> {
if (this.runningFeatures.has(featureId)) { if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`); throw new Error("already running");
} }
const abortController = new AbortController(); const abortController = new AbortController();
// Add to running features immediately
// Emit feature start event early const tempRunningFeature: RunningFeature = {
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId, featureId,
projectPath, projectPath,
feature: { worktreePath: null,
id: featureId, branchName: null,
title: "Resuming...", abortController,
description: "Feature is resuming from previous context", isAutoMode: false,
}, startTime: Date.now(),
}); };
this.runningFeatures.set(featureId, tempRunningFeature);
try { 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); const feature = await this.loadFeature(projectPath, featureId);
if (!feature) { if (!feature) {
throw new Error(`Feature ${featureId} not found`); throw new Error(`Feature ${featureId} not found`);
} }
// Derive workDir from feature.branchName // Derive workDir from feature.branchName
// If no branchName, derive from feature ID: feature/{featureId}
let worktreePath: string | null = null; let worktreePath: string | null = null;
const branchName = feature.branchName || null; const branchName = feature.branchName || `feature/${featureId}`;
if (useWorktrees && branchName) { if (useWorktrees && branchName) {
worktreePath = await this.findExistingWorktreeForBranch( 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(worktreePath)
: path.resolve(projectPath); : path.resolve(projectPath);
this.runningFeatures.set(featureId, { // Update running feature with actual worktree info
featureId, tempRunningFeature.worktreePath = worktreePath;
projectPath, tempRunningFeature.branchName = branchName;
worktreePath,
branchName,
abortController,
isAutoMode: false,
startTime: Date.now(),
});
// Update feature status to in_progress // Update feature status to in_progress
await this.updateFeatureStatus(projectPath, featureId, "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, featureId,
passes: true, passes: true,
message: `Feature resumed and completed in ${Math.round( message: `Feature resumed and completed in ${Math.round(
(Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000 (Date.now() - tempRunningFeature.startTime) / 1000
)}s`, )}s`,
projectPath, projectPath,
}); });

View File

@@ -582,3 +582,4 @@ The route organization pattern provides:
Apply this pattern to all route modules for consistency and improved code quality. Apply this pattern to all route modules for consistency and improved code quality.