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({
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 (

View File

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

View File

@@ -28,3 +28,4 @@ Switch.displayName = SwitchPrimitives.Root.displayName;
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> {
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);
}

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 { 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,
});

View File

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