mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
Merge main into refactor/frontend
Merge latest features from main including: - PR #161 (worktree-confusion): Clarified branch handling in dialogs - PR #160 (speckits-rebase): Planning mode functionality Resolved conflicts: - add-feature-dialog.tsx: Combined TanStack Router navigation with branch selection state - worktree-integration.spec.ts: Updated tests for new worktree behavior (created at execution time) - package-lock.json: Regenerated after merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,22 @@ export function isAbortError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a user-initiated cancellation
|
||||
*
|
||||
* @param errorMessage - The error message to check
|
||||
* @returns True if the error is a user-initiated cancellation
|
||||
*/
|
||||
export function isCancellationError(errorMessage: string): boolean {
|
||||
const lowerMessage = errorMessage.toLowerCase();
|
||||
return (
|
||||
lowerMessage.includes("cancelled") ||
|
||||
lowerMessage.includes("canceled") ||
|
||||
lowerMessage.includes("stopped") ||
|
||||
lowerMessage.includes("aborted")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is an authentication/API key error
|
||||
*
|
||||
@@ -39,7 +55,7 @@ export function isAuthenticationError(errorMessage: string): boolean {
|
||||
/**
|
||||
* Error type classification
|
||||
*/
|
||||
export type ErrorType = "authentication" | "abort" | "execution" | "unknown";
|
||||
export type ErrorType = "authentication" | "cancellation" | "abort" | "execution" | "unknown";
|
||||
|
||||
/**
|
||||
* Classified error information
|
||||
@@ -49,6 +65,7 @@ export interface ErrorInfo {
|
||||
message: string;
|
||||
isAbort: boolean;
|
||||
isAuth: boolean;
|
||||
isCancellation: boolean;
|
||||
originalError: unknown;
|
||||
}
|
||||
|
||||
@@ -62,12 +79,15 @@ export function classifyError(error: unknown): ErrorInfo {
|
||||
const message = error instanceof Error ? error.message : String(error || "Unknown error");
|
||||
const isAbort = isAbortError(error);
|
||||
const isAuth = isAuthenticationError(message);
|
||||
const isCancellation = isCancellationError(message);
|
||||
|
||||
let type: ErrorType;
|
||||
if (isAuth) {
|
||||
type = "authentication";
|
||||
} else if (isAbort) {
|
||||
type = "abort";
|
||||
} else if (isCancellation) {
|
||||
type = "cancellation";
|
||||
} else if (error instanceof Error) {
|
||||
type = "execution";
|
||||
} else {
|
||||
@@ -79,6 +99,7 @@ export function classifyError(error: unknown): ErrorInfo {
|
||||
message,
|
||||
isAbort,
|
||||
isAuth,
|
||||
isCancellation,
|
||||
originalError: error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,3 +23,4 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
import { Router } from "express";
|
||||
import type { AutoModeService } from "../../services/auto-mode-service.js";
|
||||
import { createStartHandler } from "./routes/start.js";
|
||||
import { createStopHandler } from "./routes/stop.js";
|
||||
import { createStopFeatureHandler } from "./routes/stop-feature.js";
|
||||
import { createStatusHandler } from "./routes/status.js";
|
||||
import { createRunFeatureHandler } from "./routes/run-feature.js";
|
||||
@@ -17,12 +15,11 @@ import { createContextExistsHandler } from "./routes/context-exists.js";
|
||||
import { createAnalyzeProjectHandler } from "./routes/analyze-project.js";
|
||||
import { createFollowUpFeatureHandler } from "./routes/follow-up-feature.js";
|
||||
import { createCommitFeatureHandler } from "./routes/commit-feature.js";
|
||||
import { createApprovePlanHandler } from "./routes/approve-plan.js";
|
||||
|
||||
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post("/start", createStartHandler(autoModeService));
|
||||
router.post("/stop", createStopHandler(autoModeService));
|
||||
router.post("/stop-feature", createStopFeatureHandler(autoModeService));
|
||||
router.post("/status", createStatusHandler(autoModeService));
|
||||
router.post("/run-feature", createRunFeatureHandler(autoModeService));
|
||||
@@ -35,6 +32,7 @@ export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||
createFollowUpFeatureHandler(autoModeService)
|
||||
);
|
||||
router.post("/commit-feature", createCommitFeatureHandler(autoModeService));
|
||||
router.post("/approve-plan", createApprovePlanHandler(autoModeService));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
78
apps/server/src/routes/auto-mode/routes/approve-plan.ts
Normal file
78
apps/server/src/routes/auto-mode/routes/approve-plan.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* POST /approve-plan endpoint - Approve or reject a generated plan/spec
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { AutoModeService } from "../../../services/auto-mode-service.js";
|
||||
import { createLogger } from "../../../lib/logger.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
const logger = createLogger("AutoMode");
|
||||
|
||||
export function createApprovePlanHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { featureId, approved, editedPlan, feedback, projectPath } = req.body as {
|
||||
featureId: string;
|
||||
approved: boolean;
|
||||
editedPlan?: string;
|
||||
feedback?: string;
|
||||
projectPath?: string;
|
||||
};
|
||||
|
||||
if (!featureId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "featureId is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof approved !== "boolean") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "approved must be a boolean",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: We no longer check hasPendingApproval here because resolvePlanApproval
|
||||
// can handle recovery when pending approval is not in Map but feature has planSpec.status='generated'
|
||||
// This supports cases where the server restarted while waiting for approval
|
||||
|
||||
logger.info(
|
||||
`[AutoMode] Plan ${approved ? "approved" : "rejected"} for feature ${featureId}${
|
||||
editedPlan ? " (with edits)" : ""
|
||||
}${feedback ? ` - Feedback: ${feedback}` : ""}`
|
||||
);
|
||||
|
||||
// Resolve the pending approval (with recovery support)
|
||||
const result = await autoModeService.resolvePlanApproval(
|
||||
featureId,
|
||||
approved,
|
||||
editedPlan,
|
||||
feedback,
|
||||
projectPath
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
approved,
|
||||
message: approved
|
||||
? "Plan approved - implementation will continue"
|
||||
: "Plan rejected - feature execution stopped",
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Approve plan failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -12,13 +12,14 @@ const logger = createLogger("AutoMode");
|
||||
export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, prompt, imagePaths, worktreePath } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
prompt: string;
|
||||
imagePaths?: string[];
|
||||
worktreePath?: string;
|
||||
};
|
||||
const { projectPath, featureId, prompt, imagePaths, useWorktrees } =
|
||||
req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
prompt: string;
|
||||
imagePaths?: string[];
|
||||
useWorktrees?: boolean;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId || !prompt) {
|
||||
res.status(400).json({
|
||||
@@ -28,14 +29,25 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start follow-up in background, using the feature's worktreePath for correct branch
|
||||
// Start follow-up in background
|
||||
// followUpFeature derives workDir from feature.branchName
|
||||
autoModeService
|
||||
.followUpFeature(projectPath, featureId, prompt, imagePaths, worktreePath)
|
||||
.followUpFeature(
|
||||
projectPath,
|
||||
featureId,
|
||||
prompt,
|
||||
imagePaths,
|
||||
useWorktrees ?? true
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error(
|
||||
`[AutoMode] Follow up feature ${featureId} error:`,
|
||||
error
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
// Release the starting slot when follow-up completes (success or error)
|
||||
// Note: The feature should be in runningFeatures by this point
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -19,12 +19,10 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) {
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({
|
||||
success: false,
|
||||
error: "projectPath and featureId are required",
|
||||
});
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath and featureId are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,30 +12,30 @@ const logger = createLogger("AutoMode");
|
||||
export function createRunFeatureHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, useWorktrees, worktreePath } = req.body as {
|
||||
const { projectPath, featureId, useWorktrees } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
useWorktrees?: boolean;
|
||||
worktreePath?: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({
|
||||
success: false,
|
||||
error: "projectPath and featureId are required",
|
||||
});
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath and featureId are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start execution in background
|
||||
// If worktreePath is provided, use it directly; otherwise let the service decide
|
||||
// Default to false - worktrees should only be used when explicitly enabled
|
||||
// executeFeature derives workDir from feature.branchName
|
||||
autoModeService
|
||||
.executeFeature(projectPath, featureId, useWorktrees ?? false, false, worktreePath)
|
||||
.executeFeature(projectPath, featureId, useWorktrees ?? false, false)
|
||||
.catch((error) => {
|
||||
logger.error(`[AutoMode] Feature ${featureId} error:`, error);
|
||||
})
|
||||
.finally(() => {
|
||||
// Release the starting slot when execution completes (success or error)
|
||||
// Note: The feature should be in runningFeatures by this point
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* POST /start endpoint - Start auto mode loop
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { AutoModeService } from "../../../services/auto-mode-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
export function createStartHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, maxConcurrency } = req.body as {
|
||||
projectPath: string;
|
||||
maxConcurrency?: number;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
await autoModeService.startAutoLoop(projectPath, maxConcurrency || 3);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, "Start auto loop failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* POST /stop endpoint - Stop auto mode loop
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { AutoModeService } from "../../../services/auto-mode-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
export function createStopHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const runningCount = await autoModeService.stopAutoLoop();
|
||||
res.json({ success: true, runningFeatures: runningCount });
|
||||
} catch (error) {
|
||||
logError(error, "Stop auto loop failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -16,7 +16,6 @@ export function createIndexHandler(autoModeService: AutoModeService) {
|
||||
success: true,
|
||||
runningAgents,
|
||||
totalCount: runningAgents.length,
|
||||
autoLoopRunning: status.autoLoopRunning,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Get running agents failed");
|
||||
|
||||
@@ -103,3 +103,4 @@ export function createDeleteApiKeyHandler() {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { existsSync } from "fs";
|
||||
import { isGitRepo, getErrorMessage, logError, normalizePath } from "../common.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -58,10 +59,12 @@ export function createListHandler() {
|
||||
});
|
||||
|
||||
const worktrees: WorktreeInfo[] = [];
|
||||
const removedWorktrees: Array<{ path: string; branch: string }> = [];
|
||||
const lines = stdout.split("\n");
|
||||
let current: { path?: string; branch?: string } = {};
|
||||
let isFirst = true;
|
||||
|
||||
// First pass: detect removed worktrees
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
current.path = normalizePath(line.slice(9));
|
||||
@@ -69,19 +72,40 @@ export function createListHandler() {
|
||||
current.branch = line.slice(7).replace("refs/heads/", "");
|
||||
} else if (line === "") {
|
||||
if (current.path && current.branch) {
|
||||
worktrees.push({
|
||||
path: current.path,
|
||||
branch: current.branch,
|
||||
isMain: isFirst,
|
||||
isCurrent: current.branch === currentBranch,
|
||||
hasWorktree: true,
|
||||
});
|
||||
isFirst = false;
|
||||
const isMainWorktree = isFirst;
|
||||
// Check if the worktree directory actually exists
|
||||
// Skip checking/pruning the main worktree (projectPath itself)
|
||||
if (!isMainWorktree && !existsSync(current.path)) {
|
||||
// Worktree directory doesn't exist - it was manually deleted
|
||||
removedWorktrees.push({
|
||||
path: current.path,
|
||||
branch: current.branch,
|
||||
});
|
||||
} else {
|
||||
// Worktree exists (or is main worktree), add it to the list
|
||||
worktrees.push({
|
||||
path: current.path,
|
||||
branch: current.branch,
|
||||
isMain: isMainWorktree,
|
||||
isCurrent: current.branch === currentBranch,
|
||||
hasWorktree: true,
|
||||
});
|
||||
isFirst = false;
|
||||
}
|
||||
}
|
||||
current = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Prune removed worktrees from git (only if any were detected)
|
||||
if (removedWorktrees.length > 0) {
|
||||
try {
|
||||
await execAsync("git worktree prune", { cwd: projectPath });
|
||||
} catch {
|
||||
// Prune failed, but we'll still report the removed worktrees
|
||||
}
|
||||
}
|
||||
|
||||
// If includeDetails is requested, fetch change status for each worktree
|
||||
if (includeDetails) {
|
||||
for (const worktree of worktrees) {
|
||||
@@ -103,7 +127,11 @@ export function createListHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, worktrees });
|
||||
res.json({
|
||||
success: true,
|
||||
worktrees,
|
||||
removedWorktrees: removedWorktrees.length > 0 ? removedWorktrees : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "List worktrees failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,26 @@ export interface Feature {
|
||||
spec?: string;
|
||||
model?: string;
|
||||
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
|
||||
[key: string]: unknown;
|
||||
// Branch info - worktree path is derived at runtime from branchName
|
||||
branchName?: string; // Name of the feature branch (undefined = use current worktree)
|
||||
skipTests?: boolean;
|
||||
thinkingLevel?: string;
|
||||
planningMode?: 'skip' | 'lite' | 'spec' | 'full';
|
||||
requirePlanApproval?: boolean;
|
||||
planSpec?: {
|
||||
status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected';
|
||||
content?: string;
|
||||
version: number;
|
||||
generatedAt?: string;
|
||||
approvedAt?: string;
|
||||
reviewedByUser: boolean;
|
||||
tasksCompleted?: number;
|
||||
tasksTotal?: number;
|
||||
};
|
||||
error?: string;
|
||||
summary?: string;
|
||||
startedAt?: string;
|
||||
[key: string]: unknown; // Keep catch-all for extensibility
|
||||
}
|
||||
|
||||
export class FeatureLoader {
|
||||
|
||||
@@ -539,4 +539,201 @@ describe("auto-mode-service.ts (integration)", () => {
|
||||
expect(callCount).toBeGreaterThanOrEqual(1);
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe("planning mode", () => {
|
||||
it("should execute feature with skip planning mode", async () => {
|
||||
await createTestFeature(testRepo.path, "skip-plan-feature", {
|
||||
id: "skip-plan-feature",
|
||||
category: "test",
|
||||
description: "Feature with skip planning",
|
||||
status: "pending",
|
||||
planningMode: "skip",
|
||||
});
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Feature implemented" }],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
await service.executeFeature(
|
||||
testRepo.path,
|
||||
"skip-plan-feature",
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
const feature = await featureLoader.get(testRepo.path, "skip-plan-feature");
|
||||
expect(feature?.status).toBe("waiting_approval");
|
||||
}, 30000);
|
||||
|
||||
it("should execute feature with lite planning mode without approval", async () => {
|
||||
await createTestFeature(testRepo.path, "lite-plan-feature", {
|
||||
id: "lite-plan-feature",
|
||||
category: "test",
|
||||
description: "Feature with lite planning",
|
||||
status: "pending",
|
||||
planningMode: "lite",
|
||||
requirePlanApproval: false,
|
||||
});
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "[PLAN_GENERATED] Planning outline complete.\n\nFeature implemented" }],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
await service.executeFeature(
|
||||
testRepo.path,
|
||||
"lite-plan-feature",
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
const feature = await featureLoader.get(testRepo.path, "lite-plan-feature");
|
||||
expect(feature?.status).toBe("waiting_approval");
|
||||
}, 30000);
|
||||
|
||||
it("should emit planning_started event for spec mode", async () => {
|
||||
await createTestFeature(testRepo.path, "spec-plan-feature", {
|
||||
id: "spec-plan-feature",
|
||||
category: "test",
|
||||
description: "Feature with spec planning",
|
||||
status: "pending",
|
||||
planningMode: "spec",
|
||||
requirePlanApproval: false,
|
||||
});
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Spec generated\n\n[SPEC_GENERATED] Review the spec." }],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
await service.executeFeature(
|
||||
testRepo.path,
|
||||
"spec-plan-feature",
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
// Check planning_started event was emitted
|
||||
const planningEvent = mockEvents.emit.mock.calls.find(
|
||||
(call) => call[1]?.mode === "spec"
|
||||
);
|
||||
expect(planningEvent).toBeTruthy();
|
||||
}, 30000);
|
||||
|
||||
it("should handle feature with full planning mode", async () => {
|
||||
await createTestFeature(testRepo.path, "full-plan-feature", {
|
||||
id: "full-plan-feature",
|
||||
category: "test",
|
||||
description: "Feature with full planning",
|
||||
status: "pending",
|
||||
planningMode: "full",
|
||||
requirePlanApproval: false,
|
||||
});
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => "claude",
|
||||
executeQuery: async function* () {
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Full spec with phases\n\n[SPEC_GENERATED] Review." }],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
|
||||
mockProvider as any
|
||||
);
|
||||
|
||||
await service.executeFeature(
|
||||
testRepo.path,
|
||||
"full-plan-feature",
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
// Check planning_started event was emitted with full mode
|
||||
const planningEvent = mockEvents.emit.mock.calls.find(
|
||||
(call) => call[1]?.mode === "full"
|
||||
);
|
||||
expect(planningEvent).toBeTruthy();
|
||||
}, 30000);
|
||||
|
||||
it("should track pending approval correctly", async () => {
|
||||
// Initially no pending approvals
|
||||
expect(service.hasPendingApproval("non-existent")).toBe(false);
|
||||
});
|
||||
|
||||
it("should cancel pending approval gracefully", () => {
|
||||
// Should not throw when cancelling non-existent approval
|
||||
expect(() => service.cancelPlanApproval("non-existent")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should resolve approval with error for non-existent feature", async () => {
|
||||
const result = await service.resolvePlanApproval(
|
||||
"non-existent",
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("No pending approval");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { AutoModeService } from "@/services/auto-mode-service.js";
|
||||
|
||||
describe("auto-mode-service.ts - Planning Mode", () => {
|
||||
let service: AutoModeService;
|
||||
const mockEvents = {
|
||||
subscribe: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new AutoModeService(mockEvents as any);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up any running processes
|
||||
await service.stopAutoLoop().catch(() => {});
|
||||
});
|
||||
|
||||
describe("getPlanningPromptPrefix", () => {
|
||||
// Access private method through any cast for testing
|
||||
const getPlanningPromptPrefix = (svc: any, feature: any) => {
|
||||
return svc.getPlanningPromptPrefix(feature);
|
||||
};
|
||||
|
||||
it("should return empty string for skip mode", () => {
|
||||
const feature = { id: "test", planningMode: "skip" as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should return empty string when planningMode is undefined", () => {
|
||||
const feature = { id: "test" };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should return lite prompt for lite mode without approval", () => {
|
||||
const feature = {
|
||||
id: "test",
|
||||
planningMode: "lite" as const,
|
||||
requirePlanApproval: false
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain("Planning Phase (Lite Mode)");
|
||||
expect(result).toContain("[PLAN_GENERATED]");
|
||||
expect(result).toContain("Feature Request");
|
||||
});
|
||||
|
||||
it("should return lite_with_approval prompt for lite mode with approval", () => {
|
||||
const feature = {
|
||||
id: "test",
|
||||
planningMode: "lite" as const,
|
||||
requirePlanApproval: true
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain("Planning Phase (Lite Mode)");
|
||||
expect(result).toContain("[SPEC_GENERATED]");
|
||||
expect(result).toContain("DO NOT proceed with implementation");
|
||||
});
|
||||
|
||||
it("should return spec prompt for spec mode", () => {
|
||||
const feature = {
|
||||
id: "test",
|
||||
planningMode: "spec" as const
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain("Specification Phase (Spec Mode)");
|
||||
expect(result).toContain("```tasks");
|
||||
expect(result).toContain("T001");
|
||||
expect(result).toContain("[TASK_START]");
|
||||
expect(result).toContain("[TASK_COMPLETE]");
|
||||
});
|
||||
|
||||
it("should return full prompt for full mode", () => {
|
||||
const feature = {
|
||||
id: "test",
|
||||
planningMode: "full" as const
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain("Full Specification Phase (Full SDD Mode)");
|
||||
expect(result).toContain("Phase 1: Foundation");
|
||||
expect(result).toContain("Phase 2: Core Implementation");
|
||||
expect(result).toContain("Phase 3: Integration & Testing");
|
||||
});
|
||||
|
||||
it("should include the separator and Feature Request header", () => {
|
||||
const feature = {
|
||||
id: "test",
|
||||
planningMode: "spec" as const
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain("---");
|
||||
expect(result).toContain("## Feature Request");
|
||||
});
|
||||
|
||||
it("should instruct agent to NOT output exploration text", () => {
|
||||
const modes = ["lite", "spec", "full"] as const;
|
||||
for (const mode of modes) {
|
||||
const feature = { id: "test", planningMode: mode };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain("Do NOT output exploration text");
|
||||
expect(result).toContain("Start DIRECTLY");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTasksFromSpec (via module)", () => {
|
||||
// We need to test the module-level function
|
||||
// Import it directly for testing
|
||||
it("should parse tasks from a valid tasks block", async () => {
|
||||
// This tests the internal logic through integration
|
||||
// The function is module-level, so we verify behavior through the service
|
||||
const specContent = `
|
||||
## Specification
|
||||
|
||||
\`\`\`tasks
|
||||
- [ ] T001: Create user model | File: src/models/user.ts
|
||||
- [ ] T002: Add API endpoint | File: src/routes/users.ts
|
||||
- [ ] T003: Write unit tests | File: tests/user.test.ts
|
||||
\`\`\`
|
||||
`;
|
||||
// Since parseTasksFromSpec is a module-level function,
|
||||
// we verify its behavior indirectly through plan parsing
|
||||
expect(specContent).toContain("T001");
|
||||
expect(specContent).toContain("T002");
|
||||
expect(specContent).toContain("T003");
|
||||
});
|
||||
|
||||
it("should handle tasks block with phases", () => {
|
||||
const specContent = `
|
||||
\`\`\`tasks
|
||||
## Phase 1: Setup
|
||||
- [ ] T001: Initialize project | File: package.json
|
||||
- [ ] T002: Configure TypeScript | File: tsconfig.json
|
||||
|
||||
## Phase 2: Implementation
|
||||
- [ ] T003: Create main module | File: src/index.ts
|
||||
\`\`\`
|
||||
`;
|
||||
expect(specContent).toContain("Phase 1");
|
||||
expect(specContent).toContain("Phase 2");
|
||||
expect(specContent).toContain("T001");
|
||||
expect(specContent).toContain("T003");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan approval flow", () => {
|
||||
it("should track pending approvals correctly", () => {
|
||||
expect(service.hasPendingApproval("test-feature")).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow cancelling non-existent approval without error", () => {
|
||||
expect(() => service.cancelPlanApproval("non-existent")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should return running features count after stop", async () => {
|
||||
const count = await service.stopAutoLoop();
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePlanApproval", () => {
|
||||
it("should return error when no pending approval exists", async () => {
|
||||
const result = await service.resolvePlanApproval(
|
||||
"non-existent-feature",
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("No pending approval");
|
||||
});
|
||||
|
||||
it("should handle approval with edited plan", async () => {
|
||||
// Without a pending approval, this should fail gracefully
|
||||
const result = await service.resolvePlanApproval(
|
||||
"test-feature",
|
||||
true,
|
||||
"Edited plan content",
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle rejection with feedback", async () => {
|
||||
const result = await service.resolvePlanApproval(
|
||||
"test-feature",
|
||||
false,
|
||||
undefined,
|
||||
"Please add more details",
|
||||
undefined
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFeaturePrompt", () => {
|
||||
const buildFeaturePrompt = (svc: any, feature: any) => {
|
||||
return svc.buildFeaturePrompt(feature);
|
||||
};
|
||||
|
||||
it("should include feature ID and description", () => {
|
||||
const feature = {
|
||||
id: "feat-123",
|
||||
description: "Add user authentication",
|
||||
};
|
||||
const result = buildFeaturePrompt(service, feature);
|
||||
expect(result).toContain("feat-123");
|
||||
expect(result).toContain("Add user authentication");
|
||||
});
|
||||
|
||||
it("should include specification when present", () => {
|
||||
const feature = {
|
||||
id: "feat-123",
|
||||
description: "Test feature",
|
||||
spec: "Detailed specification here",
|
||||
};
|
||||
const result = buildFeaturePrompt(service, feature);
|
||||
expect(result).toContain("Specification:");
|
||||
expect(result).toContain("Detailed specification here");
|
||||
});
|
||||
|
||||
it("should include image paths when present", () => {
|
||||
const feature = {
|
||||
id: "feat-123",
|
||||
description: "Test feature",
|
||||
imagePaths: [
|
||||
{ path: "/tmp/image1.png", filename: "image1.png", mimeType: "image/png" },
|
||||
"/tmp/image2.jpg",
|
||||
],
|
||||
};
|
||||
const result = buildFeaturePrompt(service, feature);
|
||||
expect(result).toContain("Context Images Attached");
|
||||
expect(result).toContain("image1.png");
|
||||
expect(result).toContain("/tmp/image2.jpg");
|
||||
});
|
||||
|
||||
it("should include summary tags instruction", () => {
|
||||
const feature = {
|
||||
id: "feat-123",
|
||||
description: "Test feature",
|
||||
};
|
||||
const result = buildFeaturePrompt(service, feature);
|
||||
expect(result).toContain("<summary>");
|
||||
expect(result).toContain("</summary>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTitleFromDescription", () => {
|
||||
const extractTitle = (svc: any, description: string) => {
|
||||
return svc.extractTitleFromDescription(description);
|
||||
};
|
||||
|
||||
it("should return 'Untitled Feature' for empty description", () => {
|
||||
expect(extractTitle(service, "")).toBe("Untitled Feature");
|
||||
expect(extractTitle(service, " ")).toBe("Untitled Feature");
|
||||
});
|
||||
|
||||
it("should return first line if under 60 characters", () => {
|
||||
const description = "Add user login\nWith email validation";
|
||||
expect(extractTitle(service, description)).toBe("Add user login");
|
||||
});
|
||||
|
||||
it("should truncate long first lines to 60 characters", () => {
|
||||
const description = "This is a very long feature description that exceeds the sixty character limit significantly";
|
||||
const result = extractTitle(service, description);
|
||||
expect(result.length).toBe(60);
|
||||
expect(result).toContain("...");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PLANNING_PROMPTS structure", () => {
|
||||
const getPlanningPromptPrefix = (svc: any, feature: any) => {
|
||||
return svc.getPlanningPromptPrefix(feature);
|
||||
};
|
||||
|
||||
it("should have all required planning modes", () => {
|
||||
const modes = ["lite", "spec", "full"] as const;
|
||||
for (const mode of modes) {
|
||||
const feature = { id: "test", planningMode: mode };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result.length).toBeGreaterThan(100);
|
||||
}
|
||||
});
|
||||
|
||||
it("lite prompt should include correct structure", () => {
|
||||
const feature = { id: "test", planningMode: "lite" as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain("Goal");
|
||||
expect(result).toContain("Approach");
|
||||
expect(result).toContain("Files to Touch");
|
||||
expect(result).toContain("Tasks");
|
||||
expect(result).toContain("Risks");
|
||||
});
|
||||
|
||||
it("spec prompt should include task format instructions", () => {
|
||||
const feature = { id: "test", planningMode: "spec" as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain("Problem");
|
||||
expect(result).toContain("Solution");
|
||||
expect(result).toContain("Acceptance Criteria");
|
||||
expect(result).toContain("GIVEN-WHEN-THEN");
|
||||
expect(result).toContain("Implementation Tasks");
|
||||
expect(result).toContain("Verification");
|
||||
});
|
||||
|
||||
it("full prompt should include phases", () => {
|
||||
const feature = { id: "test", planningMode: "full" as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain("Problem Statement");
|
||||
expect(result).toContain("User Story");
|
||||
expect(result).toContain("Technical Context");
|
||||
expect(result).toContain("Non-Goals");
|
||||
expect(result).toContain("Phase 1");
|
||||
expect(result).toContain("Phase 2");
|
||||
expect(result).toContain("Phase 3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("status management", () => {
|
||||
it("should report correct status", () => {
|
||||
const status = service.getStatus();
|
||||
expect(status.runningFeatures).toEqual([]);
|
||||
expect(status.isRunning).toBe(false);
|
||||
expect(status.runningCount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
345
apps/server/tests/unit/services/auto-mode-task-parsing.test.ts
Normal file
345
apps/server/tests/unit/services/auto-mode-task-parsing.test.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
/**
|
||||
* Test the task parsing logic by reimplementing the parsing functions
|
||||
* These mirror the logic in auto-mode-service.ts parseTasksFromSpec and parseTaskLine
|
||||
*/
|
||||
|
||||
interface ParsedTask {
|
||||
id: string;
|
||||
description: string;
|
||||
filePath?: string;
|
||||
phase?: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}
|
||||
|
||||
function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
|
||||
// Match pattern: - [ ] T###: Description | File: path
|
||||
const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/);
|
||||
if (!taskMatch) {
|
||||
// Try simpler pattern without file
|
||||
const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/);
|
||||
if (simpleMatch) {
|
||||
return {
|
||||
id: simpleMatch[1],
|
||||
description: simpleMatch[2].trim(),
|
||||
phase: currentPhase,
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: taskMatch[1],
|
||||
description: taskMatch[2].trim(),
|
||||
filePath: taskMatch[3]?.trim(),
|
||||
phase: currentPhase,
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
function parseTasksFromSpec(specContent: string): ParsedTask[] {
|
||||
const tasks: ParsedTask[] = [];
|
||||
|
||||
// Extract content within ```tasks ... ``` block
|
||||
const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/);
|
||||
if (!tasksBlockMatch) {
|
||||
// Try fallback: look for task lines anywhere in content
|
||||
const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm);
|
||||
if (!taskLines) {
|
||||
return tasks;
|
||||
}
|
||||
// Parse fallback task lines
|
||||
let currentPhase: string | undefined;
|
||||
for (const line of taskLines) {
|
||||
const parsed = parseTaskLine(line, currentPhase);
|
||||
if (parsed) {
|
||||
tasks.push(parsed);
|
||||
}
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
const tasksContent = tasksBlockMatch[1];
|
||||
const lines = tasksContent.split('\n');
|
||||
|
||||
let currentPhase: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Check for phase header (e.g., "## Phase 1: Foundation")
|
||||
const phaseMatch = trimmedLine.match(/^##\s*(.+)$/);
|
||||
if (phaseMatch) {
|
||||
currentPhase = phaseMatch[1].trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for task line
|
||||
if (trimmedLine.startsWith('- [ ]')) {
|
||||
const parsed = parseTaskLine(trimmedLine, currentPhase);
|
||||
if (parsed) {
|
||||
tasks.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
describe("Task Parsing", () => {
|
||||
describe("parseTaskLine", () => {
|
||||
it("should parse task with file path", () => {
|
||||
const line = "- [ ] T001: Create user model | File: src/models/user.ts";
|
||||
const result = parseTaskLine(line);
|
||||
expect(result).toEqual({
|
||||
id: "T001",
|
||||
description: "Create user model",
|
||||
filePath: "src/models/user.ts",
|
||||
phase: undefined,
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse task without file path", () => {
|
||||
const line = "- [ ] T002: Setup database connection";
|
||||
const result = parseTaskLine(line);
|
||||
expect(result).toEqual({
|
||||
id: "T002",
|
||||
description: "Setup database connection",
|
||||
phase: undefined,
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("should include phase when provided", () => {
|
||||
const line = "- [ ] T003: Write tests | File: tests/user.test.ts";
|
||||
const result = parseTaskLine(line, "Phase 1: Foundation");
|
||||
expect(result?.phase).toBe("Phase 1: Foundation");
|
||||
});
|
||||
|
||||
it("should return null for invalid line", () => {
|
||||
expect(parseTaskLine("- [ ] Invalid format")).toBeNull();
|
||||
expect(parseTaskLine("Not a task line")).toBeNull();
|
||||
expect(parseTaskLine("")).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle multi-word descriptions", () => {
|
||||
const line = "- [ ] T004: Implement user authentication with JWT tokens | File: src/auth.ts";
|
||||
const result = parseTaskLine(line);
|
||||
expect(result?.description).toBe("Implement user authentication with JWT tokens");
|
||||
});
|
||||
|
||||
it("should trim whitespace from description and file path", () => {
|
||||
const line = "- [ ] T005: Create API endpoint | File: src/routes/api.ts ";
|
||||
const result = parseTaskLine(line);
|
||||
expect(result?.description).toBe("Create API endpoint");
|
||||
expect(result?.filePath).toBe("src/routes/api.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTasksFromSpec", () => {
|
||||
it("should parse tasks from a tasks code block", () => {
|
||||
const specContent = `
|
||||
## Specification
|
||||
|
||||
Some description here.
|
||||
|
||||
\`\`\`tasks
|
||||
- [ ] T001: Create user model | File: src/models/user.ts
|
||||
- [ ] T002: Add API endpoint | File: src/routes/users.ts
|
||||
- [ ] T003: Write unit tests | File: tests/user.test.ts
|
||||
\`\`\`
|
||||
|
||||
## Notes
|
||||
Some notes here.
|
||||
`;
|
||||
const tasks = parseTasksFromSpec(specContent);
|
||||
expect(tasks).toHaveLength(3);
|
||||
expect(tasks[0].id).toBe("T001");
|
||||
expect(tasks[1].id).toBe("T002");
|
||||
expect(tasks[2].id).toBe("T003");
|
||||
});
|
||||
|
||||
it("should parse tasks with phases", () => {
|
||||
const specContent = `
|
||||
\`\`\`tasks
|
||||
## Phase 1: Foundation
|
||||
- [ ] T001: Initialize project | File: package.json
|
||||
- [ ] T002: Configure TypeScript | File: tsconfig.json
|
||||
|
||||
## Phase 2: Implementation
|
||||
- [ ] T003: Create main module | File: src/index.ts
|
||||
- [ ] T004: Add utility functions | File: src/utils.ts
|
||||
|
||||
## Phase 3: Testing
|
||||
- [ ] T005: Write tests | File: tests/index.test.ts
|
||||
\`\`\`
|
||||
`;
|
||||
const tasks = parseTasksFromSpec(specContent);
|
||||
expect(tasks).toHaveLength(5);
|
||||
expect(tasks[0].phase).toBe("Phase 1: Foundation");
|
||||
expect(tasks[1].phase).toBe("Phase 1: Foundation");
|
||||
expect(tasks[2].phase).toBe("Phase 2: Implementation");
|
||||
expect(tasks[3].phase).toBe("Phase 2: Implementation");
|
||||
expect(tasks[4].phase).toBe("Phase 3: Testing");
|
||||
});
|
||||
|
||||
it("should return empty array for content without tasks", () => {
|
||||
const specContent = "Just some text without any tasks";
|
||||
const tasks = parseTasksFromSpec(specContent);
|
||||
expect(tasks).toEqual([]);
|
||||
});
|
||||
|
||||
it("should fallback to finding task lines outside code block", () => {
|
||||
const specContent = `
|
||||
## Implementation Plan
|
||||
|
||||
- [ ] T001: First task | File: src/first.ts
|
||||
- [ ] T002: Second task | File: src/second.ts
|
||||
`;
|
||||
const tasks = parseTasksFromSpec(specContent);
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(tasks[0].id).toBe("T001");
|
||||
expect(tasks[1].id).toBe("T002");
|
||||
});
|
||||
|
||||
it("should handle empty tasks block", () => {
|
||||
const specContent = `
|
||||
\`\`\`tasks
|
||||
\`\`\`
|
||||
`;
|
||||
const tasks = parseTasksFromSpec(specContent);
|
||||
expect(tasks).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle mixed valid and invalid lines", () => {
|
||||
const specContent = `
|
||||
\`\`\`tasks
|
||||
- [ ] T001: Valid task | File: src/valid.ts
|
||||
- Invalid line
|
||||
Some other text
|
||||
- [ ] T002: Another valid task
|
||||
\`\`\`
|
||||
`;
|
||||
const tasks = parseTasksFromSpec(specContent);
|
||||
expect(tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should preserve task order", () => {
|
||||
const specContent = `
|
||||
\`\`\`tasks
|
||||
- [ ] T003: Third
|
||||
- [ ] T001: First
|
||||
- [ ] T002: Second
|
||||
\`\`\`
|
||||
`;
|
||||
const tasks = parseTasksFromSpec(specContent);
|
||||
expect(tasks[0].id).toBe("T003");
|
||||
expect(tasks[1].id).toBe("T001");
|
||||
expect(tasks[2].id).toBe("T002");
|
||||
});
|
||||
|
||||
it("should handle task IDs with different numbers", () => {
|
||||
const specContent = `
|
||||
\`\`\`tasks
|
||||
- [ ] T001: First
|
||||
- [ ] T010: Tenth
|
||||
- [ ] T100: Hundredth
|
||||
\`\`\`
|
||||
`;
|
||||
const tasks = parseTasksFromSpec(specContent);
|
||||
expect(tasks).toHaveLength(3);
|
||||
expect(tasks[0].id).toBe("T001");
|
||||
expect(tasks[1].id).toBe("T010");
|
||||
expect(tasks[2].id).toBe("T100");
|
||||
});
|
||||
});
|
||||
|
||||
describe("spec content generation patterns", () => {
|
||||
it("should match the expected lite mode output format", () => {
|
||||
const liteModeOutput = `
|
||||
1. **Goal**: Implement user registration
|
||||
2. **Approach**: Create form component, add validation, connect to API
|
||||
3. **Files to Touch**: src/components/Register.tsx, src/api/auth.ts
|
||||
4. **Tasks**:
|
||||
1. Create registration form
|
||||
2. Add form validation
|
||||
3. Connect to backend API
|
||||
5. **Risks**: Form state management complexity
|
||||
|
||||
[PLAN_GENERATED] Planning outline complete.
|
||||
`;
|
||||
expect(liteModeOutput).toContain("[PLAN_GENERATED]");
|
||||
expect(liteModeOutput).toContain("Goal");
|
||||
expect(liteModeOutput).toContain("Approach");
|
||||
});
|
||||
|
||||
it("should match the expected spec mode output format", () => {
|
||||
const specModeOutput = `
|
||||
1. **Problem**: Users cannot register for accounts
|
||||
|
||||
2. **Solution**: Implement registration form with email/password validation
|
||||
|
||||
3. **Acceptance Criteria**:
|
||||
- GIVEN a new user, WHEN they fill in valid details, THEN account is created
|
||||
|
||||
4. **Files to Modify**:
|
||||
| File | Purpose | Action |
|
||||
|------|---------|--------|
|
||||
| src/Register.tsx | Registration form | create |
|
||||
|
||||
5. **Implementation Tasks**:
|
||||
\`\`\`tasks
|
||||
- [ ] T001: Create registration component | File: src/Register.tsx
|
||||
- [ ] T002: Add form validation | File: src/Register.tsx
|
||||
\`\`\`
|
||||
|
||||
6. **Verification**: Manual testing of registration flow
|
||||
|
||||
[SPEC_GENERATED] Please review the specification above.
|
||||
`;
|
||||
expect(specModeOutput).toContain("[SPEC_GENERATED]");
|
||||
expect(specModeOutput).toContain("```tasks");
|
||||
expect(specModeOutput).toContain("T001");
|
||||
});
|
||||
|
||||
it("should match the expected full mode output format", () => {
|
||||
const fullModeOutput = `
|
||||
1. **Problem Statement**: Users need ability to create accounts
|
||||
|
||||
2. **User Story**: As a new user, I want to register, so that I can access the app
|
||||
|
||||
3. **Acceptance Criteria**:
|
||||
- **Happy Path**: GIVEN valid email, WHEN registering, THEN account created
|
||||
- **Edge Cases**: GIVEN existing email, WHEN registering, THEN error shown
|
||||
|
||||
4. **Technical Context**:
|
||||
| Aspect | Value |
|
||||
|--------|-------|
|
||||
| Affected Files | src/Register.tsx |
|
||||
|
||||
5. **Non-Goals**: Social login, password recovery
|
||||
|
||||
6. **Implementation Tasks**:
|
||||
\`\`\`tasks
|
||||
## Phase 1: Foundation
|
||||
- [ ] T001: Setup component structure | File: src/Register.tsx
|
||||
|
||||
## Phase 2: Core Implementation
|
||||
- [ ] T002: Add form logic | File: src/Register.tsx
|
||||
|
||||
## Phase 3: Integration & Testing
|
||||
- [ ] T003: Connect to API | File: src/api/auth.ts
|
||||
\`\`\`
|
||||
|
||||
[SPEC_GENERATED] Please review the comprehensive specification above.
|
||||
`;
|
||||
expect(fullModeOutput).toContain("Phase 1");
|
||||
expect(fullModeOutput).toContain("Phase 2");
|
||||
expect(fullModeOutput).toContain("Phase 3");
|
||||
expect(fullModeOutput).toContain("[SPEC_GENERATED]");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -49,8 +49,11 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
|
||||
@@ -3,14 +3,15 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
const port = process.env.TEST_PORT || 5173;
|
||||
const serverPort = process.env.TEST_SERVER_PORT || 3008;
|
||||
const reuseServer = process.env.TEST_REUSE_SERVER === "true";
|
||||
const mockAgent = process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true";
|
||||
const mockAgent =
|
||||
process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: undefined,
|
||||
reporter: "html",
|
||||
timeout: 30000,
|
||||
use: {
|
||||
|
||||
@@ -34,6 +34,7 @@ interface AutocompleteProps {
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
error?: boolean;
|
||||
icon?: LucideIcon;
|
||||
allowCreate?: boolean;
|
||||
createLabel?: (value: string) => string;
|
||||
@@ -57,6 +58,7 @@ export function Autocomplete({
|
||||
emptyMessage = "No results found.",
|
||||
className,
|
||||
disabled = false,
|
||||
error = false,
|
||||
icon: Icon,
|
||||
allowCreate = false,
|
||||
createLabel = (v) => `Create "${v}"`,
|
||||
@@ -129,6 +131,7 @@ export function Autocomplete({
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
Icon && "font-mono text-sm",
|
||||
error && "border-destructive focus-visible:ring-destructive",
|
||||
className
|
||||
)}
|
||||
data-testid={testId}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface BranchAutocompleteProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
error?: boolean;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
@@ -20,6 +21,7 @@ export function BranchAutocomplete({
|
||||
placeholder = "Select a branch...",
|
||||
className,
|
||||
disabled = false,
|
||||
error = false,
|
||||
"data-testid": testId,
|
||||
}: BranchAutocompleteProps) {
|
||||
// Always include "main" at the top of suggestions
|
||||
@@ -42,6 +44,7 @@ export function BranchAutocomplete({
|
||||
emptyMessage="No branches found."
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
icon={GitBranch}
|
||||
allowCreate
|
||||
createLabel={(v) => `Create "${v}"`}
|
||||
|
||||
@@ -12,9 +12,23 @@ interface CheckboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElemen
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const CheckboxRoot = CheckboxPrimitive.Root as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
|
||||
const CheckboxIndicator = CheckboxPrimitive.Indicator as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Indicator> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLSpanElement>
|
||||
>;
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
({ className, onCheckedChange, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
({ className, onCheckedChange, children: _children, ...props }, ref) => (
|
||||
<CheckboxRoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
|
||||
@@ -28,12 +42,12 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
<CheckboxIndicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
)
|
||||
);
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
@@ -5,6 +5,36 @@ import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||
const DialogContentPrimitive = DialogPrimitive.Content as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DialogClosePrimitive = DialogPrimitive.Close as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
|
||||
const DialogTitlePrimitive = DialogPrimitive.Title as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLHeadingElement>
|
||||
>;
|
||||
|
||||
const DialogDescriptionPrimitive = DialogPrimitive.Description as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
} & React.RefAttributes<HTMLParagraphElement>
|
||||
>;
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
@@ -29,12 +59,20 @@ function DialogClose({
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
const DialogOverlayPrimitive = DialogPrimitive.Overlay as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay> & {
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
<DialogOverlayPrimitive
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
|
||||
@@ -48,16 +86,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-");
|
||||
@@ -65,7 +105,8 @@ function DialogContent({
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
<DialogContentPrimitive
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
|
||||
@@ -90,7 +131,7 @@ function DialogContent({
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
<DialogClosePrimitive
|
||||
data-slot="dialog-close"
|
||||
className={cn(
|
||||
"absolute rounded-lg opacity-60 transition-all duration-200 cursor-pointer",
|
||||
@@ -104,12 +145,14 @@ function DialogContent({
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogClosePrimitive>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogContentPrimitive>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
@@ -136,27 +179,42 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
<DialogTitlePrimitive
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</DialogTitlePrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
children,
|
||||
title,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
<DialogDescriptionPrimitive
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
|
||||
title={title}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</DialogDescriptionPrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,83 @@ import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||
const DropdownMenuTriggerPrimitive = DropdownMenuPrimitive.Trigger as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
} & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuSubTriggerPrimitive = DropdownMenuPrimitive.SubTrigger as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuRadioGroupPrimitive = DropdownMenuPrimitive.RadioGroup as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioGroup> & {
|
||||
children?: React.ReactNode;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuItemPrimitive = DropdownMenuPrimitive.Item as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuRadioItemPrimitive = DropdownMenuPrimitive.RadioItem as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuLabelPrimitive = DropdownMenuPrimitive.Label as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuCheckboxItemPrimitive = DropdownMenuPrimitive.CheckboxItem as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuItemIndicatorPrimitive = DropdownMenuPrimitive.ItemIndicator as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.ItemIndicator> & {
|
||||
children?: React.ReactNode;
|
||||
} & React.RefAttributes<HTMLSpanElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuSeparatorPrimitive = DropdownMenuPrimitive.Separator as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
function DropdownMenuTrigger({
|
||||
children,
|
||||
asChild,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuTriggerPrimitive asChild={asChild} {...props}>
|
||||
{children}
|
||||
</DropdownMenuTriggerPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
@@ -15,15 +89,26 @@ const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
function DropdownMenuRadioGroup({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup> & { children?: React.ReactNode }) {
|
||||
return (
|
||||
<DropdownMenuRadioGroupPrimitive {...props}>
|
||||
{children}
|
||||
</DropdownMenuRadioGroupPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
<DropdownMenuSubTriggerPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent hover:bg-accent",
|
||||
@@ -34,13 +119,15 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
</DropdownMenuSubTriggerPrimitive>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> & {
|
||||
className?: string;
|
||||
}
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
@@ -57,7 +144,9 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
|
||||
className?: string;
|
||||
}
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
@@ -77,9 +166,10 @@ const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
children?: React.ReactNode
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuItemPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
||||
@@ -87,15 +177,20 @@ const DropdownMenuItem = React.forwardRef<
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuItemPrimitive>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
<DropdownMenuCheckboxItemPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
||||
@@ -105,21 +200,23 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<DropdownMenuItemIndicatorPrimitive>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</DropdownMenuItemIndicatorPrimitive>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
</DropdownMenuCheckboxItemPrimitive>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
children?: React.ReactNode
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
<DropdownMenuRadioItemPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
||||
@@ -128,12 +225,12 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<DropdownMenuItemIndicatorPrimitive>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</DropdownMenuItemIndicatorPrimitive>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
</DropdownMenuRadioItemPrimitive>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
@@ -141,9 +238,11 @@ const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuLabelPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
@@ -151,15 +250,19 @@ const DropdownMenuLabel = React.forwardRef<
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuLabelPrimitive>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
|
||||
className?: string;
|
||||
}
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
<DropdownMenuSeparatorPrimitive
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
|
||||
@@ -4,6 +4,20 @@ import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||
const PopoverTriggerPrimitive = PopoverPrimitive.Trigger as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
} & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
|
||||
const PopoverContentPrimitive = PopoverPrimitive.Content as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
@@ -11,9 +25,18 @@ function Popover({
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
children,
|
||||
asChild,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<PopoverTriggerPrimitive data-slot="popover-trigger" asChild={asChild} {...props}>
|
||||
{children}
|
||||
</PopoverTriggerPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
@@ -21,10 +44,12 @@ function PopoverContent({
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
<PopoverContentPrimitive
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
|
||||
46
apps/ui/src/components/ui/radio-group.tsx
Normal file
46
apps/ui/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
|
||||
|
||||
160
apps/ui/src/components/ui/select.tsx
Normal file
160
apps/ui/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -3,6 +3,33 @@ import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||
const SliderRootPrimitive = SliderPrimitive.Root as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLSpanElement>
|
||||
>;
|
||||
|
||||
const SliderTrackPrimitive = SliderPrimitive.Track as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Track> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLSpanElement>
|
||||
>;
|
||||
|
||||
const SliderRangePrimitive = SliderPrimitive.Range as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Range> & {
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLSpanElement>
|
||||
>;
|
||||
|
||||
const SliderThumbPrimitive = SliderPrimitive.Thumb as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Thumb> & {
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLSpanElement>
|
||||
>;
|
||||
|
||||
interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "defaultValue" | "dir"> {
|
||||
value?: number[];
|
||||
defaultValue?: number[];
|
||||
@@ -20,7 +47,7 @@ interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "defau
|
||||
|
||||
const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
<SliderRootPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
@@ -28,11 +55,11 @@ const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
|
||||
<SliderPrimitive.Range className="slider-range absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
|
||||
</SliderPrimitive.Root>
|
||||
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
|
||||
<SliderRangePrimitive className="slider-range absolute h-full bg-primary" />
|
||||
</SliderTrackPrimitive>
|
||||
<SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
|
||||
</SliderRootPrimitive>
|
||||
)
|
||||
);
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
31
apps/ui/src/components/ui/switch.tsx
Normal file
31
apps/ui/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
|
||||
|
||||
@@ -4,41 +4,86 @@ import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||
const TabsRootPrimitive = TabsPrimitive.Root as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const TabsListPrimitive = TabsPrimitive.List as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const TabsTriggerPrimitive = TabsPrimitive.Trigger as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
|
||||
const TabsContentPrimitive = TabsPrimitive.Content as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
<TabsRootPrimitive
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</TabsRootPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
<TabsListPrimitive
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px] border border-border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</TabsListPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
<TabsTriggerPrimitive
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200 cursor-pointer",
|
||||
@@ -50,20 +95,28 @@ function TabsTrigger({
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</TabsTriggerPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
<TabsContentPrimitive
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</TabsContentPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
274
apps/ui/src/components/ui/task-progress-panel.tsx
Normal file
274
apps/ui/src/components/ui/task-progress-panel.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from "lucide-react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import type { AutoModeEvent } from "@/types/electron";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface TaskInfo {
|
||||
id: string;
|
||||
description: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
filePath?: string;
|
||||
phase?: string;
|
||||
}
|
||||
|
||||
interface TaskProgressPanelProps {
|
||||
featureId: string;
|
||||
projectPath?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TaskProgressPanel({ featureId, projectPath, className }: TaskProgressPanelProps) {
|
||||
const [tasks, setTasks] = useState<TaskInfo[]>([]);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
|
||||
// Load initial tasks from feature's planSpec
|
||||
const loadInitialTasks = useCallback(async () => {
|
||||
if (!projectPath) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.features) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.features.get(projectPath, featureId);
|
||||
if (result.success && result.feature?.planSpec?.tasks) {
|
||||
const planTasks = result.feature.planSpec.tasks;
|
||||
const currentId = result.feature.planSpec.currentTaskId;
|
||||
const completedCount = result.feature.planSpec.tasksCompleted || 0;
|
||||
|
||||
// Convert planSpec tasks to TaskInfo with proper status
|
||||
const initialTasks: TaskInfo[] = planTasks.map((t: any, index: number) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
filePath: t.filePath,
|
||||
phase: t.phase,
|
||||
status: index < completedCount
|
||||
? "completed" as const
|
||||
: t.id === currentId
|
||||
? "in_progress" as const
|
||||
: "pending" as const,
|
||||
}));
|
||||
|
||||
setTasks(initialTasks);
|
||||
setCurrentTaskId(currentId || null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load initial tasks:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [featureId, projectPath]);
|
||||
|
||||
// Load initial state on mount
|
||||
useEffect(() => {
|
||||
loadInitialTasks();
|
||||
}, [loadInitialTasks]);
|
||||
|
||||
// Listen to task events for real-time updates
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
|
||||
// Only handle events for this feature
|
||||
if (!("featureId" in event) || event.featureId !== featureId) return;
|
||||
|
||||
switch (event.type) {
|
||||
case "auto_mode_task_started":
|
||||
if ("taskId" in event && "taskDescription" in event) {
|
||||
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
|
||||
setCurrentTaskId(taskEvent.taskId);
|
||||
|
||||
setTasks((prev) => {
|
||||
// Check if task already exists
|
||||
const existingIndex = prev.findIndex((t) => t.id === taskEvent.taskId);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// Update status to in_progress and mark previous as completed
|
||||
return prev.map((t, idx) => {
|
||||
if (t.id === taskEvent.taskId) {
|
||||
return { ...t, status: "in_progress" as const };
|
||||
}
|
||||
// If we are moving to a task that is further down the list, assume previous ones are completed
|
||||
// This is a heuristic, but usually correct for sequential execution
|
||||
if (idx < existingIndex && t.status !== "completed") {
|
||||
return { ...t, status: "completed" as const };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
}
|
||||
|
||||
// Add new task if it doesn't exist (fallback)
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: taskEvent.taskId,
|
||||
description: taskEvent.taskDescription,
|
||||
status: "in_progress" as const,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "auto_mode_task_complete":
|
||||
if ("taskId" in event) {
|
||||
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === taskEvent.taskId ? { ...t, status: "completed" as const } : t
|
||||
)
|
||||
);
|
||||
setCurrentTaskId(null);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [featureId]);
|
||||
|
||||
const completedCount = tasks.filter((t) => t.status === "completed").length;
|
||||
const totalCount = tasks.length;
|
||||
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
|
||||
|
||||
if (isLoading || tasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("group rounded-xl border bg-card/50 shadow-sm overflow-hidden transition-all duration-200", className)}>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between p-4 bg-muted/10 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-lg border shadow-sm transition-colors",
|
||||
isExpanded ? "bg-background border-border" : "bg-muted border-transparent"
|
||||
)}>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-foreground/70" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<h3 className="font-semibold text-sm tracking-tight">Execution Plan</h3>
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wider font-medium">
|
||||
{completedCount} of {totalCount} tasks completed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Circular Progress (Mini) */}
|
||||
<div className="relative h-8 w-8 flex items-center justify-center">
|
||||
<svg className="h-full w-full -rotate-90 text-muted/20" viewBox="0 0 24 24">
|
||||
<circle className="text-muted/20" cx="12" cy="12" r="10" strokeWidth="3" fill="none" stroke="currentColor" />
|
||||
<circle
|
||||
className="text-primary transition-all duration-500 ease-in-out"
|
||||
cx="12" cy="12" r="10" strokeWidth="3" fill="none" stroke="currentColor"
|
||||
strokeDasharray={63}
|
||||
strokeDashoffset={63 - (63 * progressPercent) / 100}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-[9px] font-bold">{progressPercent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className={cn(
|
||||
"grid transition-all duration-300 ease-in-out",
|
||||
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||
)}>
|
||||
<div className="overflow-hidden">
|
||||
<div className="p-5 pt-2 relative max-h-[300px] overflow-y-auto scrollbar-visible">
|
||||
{/* Vertical Connector Line */}
|
||||
<div className="absolute left-[2.35rem] top-4 bottom-8 w-px bg-gradient-to-b from-border/80 via-border/40 to-transparent" />
|
||||
|
||||
<div className="space-y-5">
|
||||
{tasks.map((task, index) => {
|
||||
const isActive = task.status === "in_progress";
|
||||
const isCompleted = task.status === "completed";
|
||||
const isPending = task.status === "pending";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={cn(
|
||||
"relative flex gap-4 group/item transition-all duration-300",
|
||||
isPending && "opacity-60 hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
{/* Icon Status */}
|
||||
<div className={cn(
|
||||
"relative z-10 flex h-7 w-7 items-center justify-center rounded-full border shadow-sm transition-all duration-300",
|
||||
isCompleted && "bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400",
|
||||
isActive && "bg-primary border-primary text-primary-foreground ring-4 ring-primary/10 scale-110",
|
||||
isPending && "bg-muted border-border text-muted-foreground"
|
||||
)}>
|
||||
{isCompleted && <Check className="h-3.5 w-3.5" />}
|
||||
{isActive && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
|
||||
</div>
|
||||
|
||||
{/* Task Content */}
|
||||
<div className={cn(
|
||||
"flex-1 pt-1 min-w-0 transition-all",
|
||||
isActive && "translate-x-1"
|
||||
)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className={cn(
|
||||
"text-sm font-medium leading-none truncate pr-4",
|
||||
isCompleted && "text-muted-foreground line-through decoration-border/60",
|
||||
isActive && "text-primary font-semibold"
|
||||
)}>
|
||||
{task.description}
|
||||
</p>
|
||||
{isActive && (
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px] bg-primary/5 text-primary border-primary/20 animate-pulse">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(task.filePath || isActive) && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
|
||||
{task.filePath ? (
|
||||
<>
|
||||
<FileCode className="h-3 w-3 opacity-70" />
|
||||
<span className="truncate opacity-80 hover:opacity-100 transition-opacity">
|
||||
{task.filePath}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="h-3 block" /> /* Spacer */
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,18 +4,47 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||
const TooltipTriggerPrimitive = TooltipPrimitive.Trigger as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
} & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
|
||||
const TooltipContentPrimitive = TooltipPrimitive.Content as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
function TooltipTrigger({
|
||||
children,
|
||||
asChild,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TooltipTriggerPrimitive asChild={asChild} {...props}>
|
||||
{children}
|
||||
</TooltipTriggerPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
|
||||
className?: string;
|
||||
}
|
||||
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
<TooltipContentPrimitive
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
} from "@dnd-kit/core";
|
||||
import { useAppStore, Feature } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import type { AutoModeEvent } from "@/types/electron";
|
||||
import { pathsEqual } from "@/lib/utils";
|
||||
import { getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||
@@ -24,11 +26,12 @@ import {
|
||||
AddFeatureDialog,
|
||||
AgentOutputModal,
|
||||
CompletedFeaturesModal,
|
||||
DeleteAllVerifiedDialog,
|
||||
ArchiveAllVerifiedDialog,
|
||||
DeleteCompletedFeatureDialog,
|
||||
EditFeatureDialog,
|
||||
FeatureSuggestionsDialog,
|
||||
FollowUpDialog,
|
||||
PlanApprovalDialog,
|
||||
} from "./board-view/dialogs";
|
||||
import { CreateWorktreeDialog } from "./board-view/dialogs/create-worktree-dialog";
|
||||
import { DeleteWorktreeDialog } from "./board-view/dialogs/delete-worktree-dialog";
|
||||
@@ -67,10 +70,17 @@ export function BoardView() {
|
||||
setKanbanCardDetailLevel,
|
||||
specCreatingForProject,
|
||||
setSpecCreatingForProject,
|
||||
pendingPlanApproval,
|
||||
setPendingPlanApproval,
|
||||
updateFeature,
|
||||
getCurrentWorktree,
|
||||
setCurrentWorktree,
|
||||
getWorktrees,
|
||||
setWorktrees,
|
||||
useWorktrees,
|
||||
enableDependencyBlocking,
|
||||
isPrimaryWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
} = useAppStore();
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
const {
|
||||
@@ -88,13 +98,15 @@ export function BoardView() {
|
||||
const [featuresWithContext, setFeaturesWithContext] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] =
|
||||
const [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] =
|
||||
useState(false);
|
||||
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
|
||||
useState(false);
|
||||
const [showCompletedModal, setShowCompletedModal] = useState(false);
|
||||
const [deleteCompletedFeature, setDeleteCompletedFeature] =
|
||||
useState<Feature | null>(null);
|
||||
// State for viewing plan in read-only mode
|
||||
const [viewPlanFeature, setViewPlanFeature] = useState<Feature | null>(null);
|
||||
|
||||
// Worktree dialog states
|
||||
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] =
|
||||
@@ -144,6 +156,8 @@ export function BoardView() {
|
||||
} = useSuggestionsState();
|
||||
// Search filter for Kanban cards
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// Plan approval loading state
|
||||
const [isPlanApprovalLoading, setIsPlanApprovalLoading] = useState(false);
|
||||
// Derive spec creation state from store - check if current project is the one being created
|
||||
const isCreatingSpec = specCreatingForProject === currentProject?.path;
|
||||
const creatingSpecProjectPath = specCreatingForProject ?? undefined;
|
||||
@@ -276,6 +290,27 @@ export function BoardView() {
|
||||
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } =
|
||||
useBoardPersistence({ currentProject });
|
||||
|
||||
// Memoize the removed worktrees handler to prevent infinite loops
|
||||
const handleRemovedWorktrees = useCallback(
|
||||
(removedWorktrees: Array<{ path: string; branch: string }>) => {
|
||||
// Reset features that were assigned to the removed worktrees (by branch)
|
||||
hookFeatures.forEach((feature) => {
|
||||
const matchesRemovedWorktree = removedWorktrees.some((removed) => {
|
||||
// Match by branch name since worktreePath is no longer stored
|
||||
return feature.branchName === removed.branch;
|
||||
});
|
||||
|
||||
if (matchesRemovedWorktree) {
|
||||
// Reset the feature's branch assignment
|
||||
persistFeatureUpdate(feature.id, {
|
||||
branchName: null as unknown as string | undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
[hookFeatures, persistFeatureUpdate]
|
||||
);
|
||||
|
||||
// Get in-progress features for keyboard shortcuts (needed before actions hook)
|
||||
const inProgressFeaturesForShortcuts = useMemo(() => {
|
||||
return hookFeatures.filter((f) => {
|
||||
@@ -284,13 +319,12 @@ export function BoardView() {
|
||||
});
|
||||
}, [hookFeatures, runningAutoTasks]);
|
||||
|
||||
// Get current worktree info (path and branch) for filtering features
|
||||
// Get current worktree info (path) for filtering features
|
||||
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
|
||||
const currentWorktreeInfo = currentProject
|
||||
? getCurrentWorktree(currentProject.path)
|
||||
: null;
|
||||
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
||||
const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null;
|
||||
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
|
||||
const worktrees = useMemo(
|
||||
() =>
|
||||
@@ -300,8 +334,25 @@ export function BoardView() {
|
||||
[currentProject, worktreesByProject]
|
||||
);
|
||||
|
||||
// Get the branch for the currently selected worktree
|
||||
// Find the worktree that matches the current selection, or use main worktree
|
||||
const selectedWorktree = useMemo(() => {
|
||||
if (currentWorktreePath === null) {
|
||||
// Primary worktree selected - find the main worktree
|
||||
return worktrees.find((w) => w.isMain);
|
||||
} else {
|
||||
// Specific worktree selected - find it by path
|
||||
return worktrees.find(
|
||||
(w) => !w.isMain && pathsEqual(w.path, currentWorktreePath)
|
||||
);
|
||||
}
|
||||
}, [worktrees, currentWorktreePath]);
|
||||
|
||||
// Get the current branch from the selected worktree (not from store which may be stale)
|
||||
const currentWorktreeBranch = selectedWorktree?.branch ?? null;
|
||||
|
||||
// Get the branch for the currently selected worktree (for defaulting new features)
|
||||
// Use the branch from currentWorktreeInfo, or fall back to main worktree's branch
|
||||
// Use the branch from selectedWorktree, or fall back to main worktree's branch
|
||||
const selectedWorktreeBranch =
|
||||
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main";
|
||||
|
||||
@@ -325,7 +376,7 @@ export function BoardView() {
|
||||
handleOutputModalNumberKeyPress,
|
||||
handleForceStopFeature,
|
||||
handleStartNextFeatures,
|
||||
handleDeleteAllVerified,
|
||||
handleArchiveAllVerified,
|
||||
} = useBoardActions({
|
||||
currentProject,
|
||||
features: hookFeatures,
|
||||
@@ -353,6 +404,205 @@ export function BoardView() {
|
||||
currentWorktreeBranch,
|
||||
});
|
||||
|
||||
// Client-side auto mode: periodically check for backlog items and move them to in-progress
|
||||
// Use a ref to track the latest auto mode state so async operations always check the current value
|
||||
const autoModeRunningRef = useRef(autoMode.isRunning);
|
||||
useEffect(() => {
|
||||
autoModeRunningRef.current = autoMode.isRunning;
|
||||
}, [autoMode.isRunning]);
|
||||
|
||||
// Use a ref to track the latest features to avoid effect re-runs when features change
|
||||
const hookFeaturesRef = useRef(hookFeatures);
|
||||
useEffect(() => {
|
||||
hookFeaturesRef.current = hookFeatures;
|
||||
}, [hookFeatures]);
|
||||
|
||||
// Track features that are pending (started but not yet confirmed running)
|
||||
const pendingFeaturesRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Listen to auto mode events to remove features from pending when they start running
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
// Only process events for the current project
|
||||
const eventProjectPath =
|
||||
"projectPath" in event ? event.projectPath : undefined;
|
||||
if (eventProjectPath && eventProjectPath !== currentProject.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case "auto_mode_feature_start":
|
||||
// Feature is now confirmed running - remove from pending
|
||||
if (event.featureId) {
|
||||
pendingFeaturesRef.current.delete(event.featureId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "auto_mode_feature_complete":
|
||||
case "auto_mode_error":
|
||||
// Feature completed or errored - remove from pending if still there
|
||||
if (event.featureId) {
|
||||
pendingFeaturesRef.current.delete(event.featureId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [currentProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoMode.isRunning || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isChecking = false;
|
||||
let isActive = true; // Track if this effect is still active
|
||||
|
||||
const checkAndStartFeatures = async () => {
|
||||
// Check if auto mode is still running and effect is still active
|
||||
// Use ref to get the latest value, not the closure value
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent concurrent executions
|
||||
if (isChecking) {
|
||||
return;
|
||||
}
|
||||
|
||||
isChecking = true;
|
||||
try {
|
||||
// Double-check auto mode is still running before proceeding
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Count currently running tasks + pending features
|
||||
const currentRunning =
|
||||
runningAutoTasks.length + pendingFeaturesRef.current.size;
|
||||
const availableSlots = maxConcurrency - currentRunning;
|
||||
|
||||
// No available slots, skip check
|
||||
if (availableSlots <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter backlog features by the currently selected worktree branch
|
||||
// This logic mirrors use-board-column-features.ts for consistency
|
||||
// Use ref to get the latest features without causing effect re-runs
|
||||
const currentFeatures = hookFeaturesRef.current;
|
||||
const backlogFeatures = currentFeatures.filter((f) => {
|
||||
if (f.status !== "backlog") return false;
|
||||
|
||||
const featureBranch = f.branchName;
|
||||
|
||||
// Features without branchName are considered unassigned (show only on primary worktree)
|
||||
if (!featureBranch) {
|
||||
// No branch assigned - show only when viewing primary worktree
|
||||
const isViewingPrimary = currentWorktreePath === null;
|
||||
return isViewingPrimary;
|
||||
}
|
||||
|
||||
if (currentWorktreeBranch === null) {
|
||||
// We're viewing main but branch hasn't been initialized yet
|
||||
// Show features assigned to primary worktree's branch
|
||||
return currentProject.path
|
||||
? isPrimaryWorktreeBranch(currentProject.path, featureBranch)
|
||||
: false;
|
||||
}
|
||||
|
||||
// Match by branch name
|
||||
return featureBranch === currentWorktreeBranch;
|
||||
});
|
||||
|
||||
if (backlogFeatures.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by priority (lower number = higher priority, priority 1 is highest)
|
||||
const sortedBacklog = [...backlogFeatures].sort(
|
||||
(a, b) => (a.priority || 999) - (b.priority || 999)
|
||||
);
|
||||
|
||||
// Filter out features with blocking dependencies if dependency blocking is enabled
|
||||
const eligibleFeatures = enableDependencyBlocking
|
||||
? sortedBacklog.filter((f) => {
|
||||
const blockingDeps = getBlockingDependencies(f, currentFeatures);
|
||||
return blockingDeps.length === 0;
|
||||
})
|
||||
: sortedBacklog;
|
||||
|
||||
// Start features up to available slots
|
||||
const featuresToStart = eligibleFeatures.slice(0, availableSlots);
|
||||
|
||||
for (const feature of featuresToStart) {
|
||||
// Check again before starting each feature
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Simplified: No worktree creation on client - server derives workDir from feature.branchName
|
||||
// If feature has no branchName and primary worktree is selected, assign primary branch
|
||||
if (currentWorktreePath === null && !feature.branchName) {
|
||||
const primaryBranch =
|
||||
(currentProject.path
|
||||
? getPrimaryWorktreeBranch(currentProject.path)
|
||||
: null) || "main";
|
||||
await persistFeatureUpdate(feature.id, {
|
||||
branchName: primaryBranch,
|
||||
});
|
||||
}
|
||||
|
||||
// Final check before starting implementation
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the implementation - server will derive workDir from feature.branchName
|
||||
const started = await handleStartImplementation(feature);
|
||||
|
||||
// If successfully started, track it as pending until we receive the start event
|
||||
if (started) {
|
||||
pendingFeaturesRef.current.add(feature.id);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isChecking = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately, then every 3 seconds
|
||||
checkAndStartFeatures();
|
||||
const interval = setInterval(checkAndStartFeatures, 3000);
|
||||
|
||||
return () => {
|
||||
// Mark as inactive to prevent any pending async operations from continuing
|
||||
isActive = false;
|
||||
clearInterval(interval);
|
||||
// Clear pending features when effect unmounts or dependencies change
|
||||
pendingFeaturesRef.current.clear();
|
||||
};
|
||||
}, [
|
||||
autoMode.isRunning,
|
||||
currentProject,
|
||||
runningAutoTasks,
|
||||
maxConcurrency,
|
||||
// hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs
|
||||
currentWorktreeBranch,
|
||||
currentWorktreePath,
|
||||
getPrimaryWorktreeBranch,
|
||||
isPrimaryWorktreeBranch,
|
||||
enableDependencyBlocking,
|
||||
persistFeatureUpdate,
|
||||
handleStartImplementation,
|
||||
]);
|
||||
|
||||
// Use keyboard shortcuts hook (after actions hook)
|
||||
useBoardKeyboardShortcuts({
|
||||
features: hookFeatures,
|
||||
@@ -369,8 +619,6 @@ export function BoardView() {
|
||||
runningAutoTasks,
|
||||
persistFeatureUpdate,
|
||||
handleStartImplementation,
|
||||
projectPath: currentProject?.path || null,
|
||||
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
|
||||
});
|
||||
|
||||
// Use column features hook
|
||||
@@ -388,6 +636,130 @@ export function BoardView() {
|
||||
currentProject,
|
||||
});
|
||||
|
||||
// Find feature for pending plan approval
|
||||
const pendingApprovalFeature = useMemo(() => {
|
||||
if (!pendingPlanApproval) return null;
|
||||
return hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null;
|
||||
}, [pendingPlanApproval, hookFeatures]);
|
||||
|
||||
// Handle plan approval
|
||||
const handlePlanApprove = useCallback(
|
||||
async (editedPlan?: string) => {
|
||||
if (!pendingPlanApproval || !currentProject) return;
|
||||
|
||||
const featureId = pendingPlanApproval.featureId;
|
||||
setIsPlanApprovalLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.approvePlan) {
|
||||
throw new Error("Plan approval API not available");
|
||||
}
|
||||
|
||||
const result = await api.autoMode.approvePlan(
|
||||
pendingPlanApproval.projectPath,
|
||||
pendingPlanApproval.featureId,
|
||||
true,
|
||||
editedPlan
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
// Immediately update local feature state to hide "Approve Plan" button
|
||||
// Get current feature to preserve version
|
||||
const currentFeature = hookFeatures.find(f => f.id === featureId);
|
||||
updateFeature(featureId, {
|
||||
planSpec: {
|
||||
status: 'approved',
|
||||
content: editedPlan || pendingPlanApproval.planContent,
|
||||
version: currentFeature?.planSpec?.version || 1,
|
||||
approvedAt: new Date().toISOString(),
|
||||
reviewedByUser: true,
|
||||
},
|
||||
});
|
||||
// Reload features from server to ensure sync
|
||||
loadFeatures();
|
||||
} else {
|
||||
console.error("[Board] Failed to approve plan:", result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error approving plan:", error);
|
||||
} finally {
|
||||
setIsPlanApprovalLoading(false);
|
||||
setPendingPlanApproval(null);
|
||||
}
|
||||
},
|
||||
[pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures]
|
||||
);
|
||||
|
||||
// Handle plan rejection
|
||||
const handlePlanReject = useCallback(
|
||||
async (feedback?: string) => {
|
||||
if (!pendingPlanApproval || !currentProject) return;
|
||||
|
||||
const featureId = pendingPlanApproval.featureId;
|
||||
setIsPlanApprovalLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.approvePlan) {
|
||||
throw new Error("Plan approval API not available");
|
||||
}
|
||||
|
||||
const result = await api.autoMode.approvePlan(
|
||||
pendingPlanApproval.projectPath,
|
||||
pendingPlanApproval.featureId,
|
||||
false,
|
||||
undefined,
|
||||
feedback
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
// Immediately update local feature state
|
||||
// Get current feature to preserve version
|
||||
const currentFeature = hookFeatures.find(f => f.id === featureId);
|
||||
updateFeature(featureId, {
|
||||
status: 'backlog',
|
||||
planSpec: {
|
||||
status: 'rejected',
|
||||
content: pendingPlanApproval.planContent,
|
||||
version: currentFeature?.planSpec?.version || 1,
|
||||
reviewedByUser: true,
|
||||
},
|
||||
});
|
||||
// Reload features from server to ensure sync
|
||||
loadFeatures();
|
||||
} else {
|
||||
console.error("[Board] Failed to reject plan:", result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error rejecting plan:", error);
|
||||
} finally {
|
||||
setIsPlanApprovalLoading(false);
|
||||
setPendingPlanApproval(null);
|
||||
}
|
||||
},
|
||||
[pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures]
|
||||
);
|
||||
|
||||
// Handle opening approval dialog from feature card button
|
||||
const handleOpenApprovalDialog = useCallback(
|
||||
(feature: Feature) => {
|
||||
if (!feature.planSpec?.content || !currentProject) return;
|
||||
|
||||
// Determine the planning mode for approval (skip should never have a plan requiring approval)
|
||||
const mode = feature.planningMode;
|
||||
const approvalMode: "lite" | "spec" | "full" =
|
||||
mode === 'lite' || mode === 'spec' || mode === 'full' ? mode : 'spec';
|
||||
|
||||
// Re-open the approval dialog with the feature's plan data
|
||||
setPendingPlanApproval({
|
||||
featureId: feature.id,
|
||||
projectPath: currentProject.path,
|
||||
planContent: feature.planSpec.content,
|
||||
planningMode: approvalMode,
|
||||
});
|
||||
},
|
||||
[currentProject, setPendingPlanApproval]
|
||||
);
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div
|
||||
@@ -421,8 +793,13 @@ export function BoardView() {
|
||||
maxConcurrency={maxConcurrency}
|
||||
onConcurrencyChange={setMaxConcurrency}
|
||||
isAutoModeRunning={autoMode.isRunning}
|
||||
onStartAutoMode={() => autoMode.start()}
|
||||
onStopAutoMode={() => autoMode.stop()}
|
||||
onAutoModeToggle={(enabled) => {
|
||||
if (enabled) {
|
||||
autoMode.start();
|
||||
} else {
|
||||
autoMode.stop();
|
||||
}
|
||||
}}
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
addFeatureShortcut={{
|
||||
key: shortcuts.addFeature,
|
||||
@@ -453,10 +830,10 @@ export function BoardView() {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreateBranchDialog(true);
|
||||
}}
|
||||
onRemovedWorktrees={handleRemovedWorktrees}
|
||||
runningFeatureIds={runningAutoTasks}
|
||||
features={hookFeatures.map((f) => ({
|
||||
id: f.id,
|
||||
worktreePath: f.worktreePath,
|
||||
branchName: f.branchName,
|
||||
}))}
|
||||
/>
|
||||
@@ -505,13 +882,15 @@ export function BoardView() {
|
||||
onCommit={handleCommitFeature}
|
||||
onComplete={handleCompleteFeature}
|
||||
onImplement={handleStartImplementation}
|
||||
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
||||
onApprovePlan={handleOpenApprovalDialog}
|
||||
featuresWithContext={featuresWithContext}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
shortcuts={shortcuts}
|
||||
onStartNextFeatures={handleStartNextFeatures}
|
||||
onShowSuggestions={() => setShowSuggestionsDialog(true)}
|
||||
suggestionsCount={suggestionsCount}
|
||||
onDeleteAllVerified={() => setShowDeleteAllVerifiedDialog(true)}
|
||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -551,6 +930,7 @@ export function BoardView() {
|
||||
branchSuggestions={branchSuggestions}
|
||||
defaultSkipTests={defaultSkipTests}
|
||||
defaultBranch={selectedWorktreeBranch}
|
||||
currentBranch={currentWorktreeBranch || undefined}
|
||||
isMaximized={isMaximized}
|
||||
showProfilesOnly={showProfilesOnly}
|
||||
aiProfiles={aiProfiles}
|
||||
@@ -563,6 +943,7 @@ export function BoardView() {
|
||||
onUpdate={handleUpdateFeature}
|
||||
categorySuggestions={categorySuggestions}
|
||||
branchSuggestions={branchSuggestions}
|
||||
currentBranch={currentWorktreeBranch || undefined}
|
||||
isMaximized={isMaximized}
|
||||
showProfilesOnly={showProfilesOnly}
|
||||
aiProfiles={aiProfiles}
|
||||
@@ -579,14 +960,14 @@ export function BoardView() {
|
||||
onNumberKeyPress={handleOutputModalNumberKeyPress}
|
||||
/>
|
||||
|
||||
{/* Delete All Verified Dialog */}
|
||||
<DeleteAllVerifiedDialog
|
||||
open={showDeleteAllVerifiedDialog}
|
||||
onOpenChange={setShowDeleteAllVerifiedDialog}
|
||||
{/* Archive All Verified Dialog */}
|
||||
<ArchiveAllVerifiedDialog
|
||||
open={showArchiveAllVerifiedDialog}
|
||||
onOpenChange={setShowArchiveAllVerifiedDialog}
|
||||
verifiedCount={getColumnFeatures("verified").length}
|
||||
onConfirm={async () => {
|
||||
await handleDeleteAllVerified();
|
||||
setShowDeleteAllVerifiedDialog(false);
|
||||
await handleArchiveAllVerified();
|
||||
setShowArchiveAllVerifiedDialog(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -616,6 +997,34 @@ export function BoardView() {
|
||||
setIsGenerating={setIsGeneratingSuggestions}
|
||||
/>
|
||||
|
||||
{/* Plan Approval Dialog */}
|
||||
<PlanApprovalDialog
|
||||
open={pendingPlanApproval !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingPlanApproval(null);
|
||||
}
|
||||
}}
|
||||
feature={pendingApprovalFeature}
|
||||
planContent={pendingPlanApproval?.planContent || ""}
|
||||
onApprove={handlePlanApprove}
|
||||
onReject={handlePlanReject}
|
||||
isLoading={isPlanApprovalLoading}
|
||||
/>
|
||||
|
||||
{/* View Plan Dialog (read-only) */}
|
||||
{viewPlanFeature && viewPlanFeature.planSpec?.content && (
|
||||
<PlanApprovalDialog
|
||||
open={true}
|
||||
onOpenChange={(open) => !open && setViewPlanFeature(null)}
|
||||
feature={viewPlanFeature}
|
||||
planContent={viewPlanFeature.planSpec.content}
|
||||
onApprove={() => setViewPlanFeature(null)}
|
||||
onReject={() => setViewPlanFeature(null)}
|
||||
viewOnly={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Worktree Dialog */}
|
||||
<CreateWorktreeDialog
|
||||
open={showCreateWorktreeDialog}
|
||||
@@ -656,19 +1065,13 @@ export function BoardView() {
|
||||
projectPath={currentProject.path}
|
||||
worktree={selectedWorktreeForAction}
|
||||
onDeleted={(deletedWorktree, _deletedBranch) => {
|
||||
// Reset features that were assigned to the deleted worktree
|
||||
// Reset features that were assigned to the deleted worktree (by branch)
|
||||
hookFeatures.forEach((feature) => {
|
||||
const matchesByPath =
|
||||
feature.worktreePath &&
|
||||
pathsEqual(feature.worktreePath, deletedWorktree.path);
|
||||
const matchesByBranch =
|
||||
feature.branchName === deletedWorktree.branch;
|
||||
|
||||
if (matchesByPath || matchesByBranch) {
|
||||
// Reset the feature's worktree assignment
|
||||
// Match by branch name since worktreePath is no longer stored
|
||||
if (feature.branchName === deletedWorktree.branch) {
|
||||
// Reset the feature's branch assignment
|
||||
persistFeatureUpdate(feature.id, {
|
||||
branchName: null as unknown as string | undefined,
|
||||
worktreePath: null as unknown as string | undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Play, StopCircle, Plus, Users } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Plus, Users } from "lucide-react";
|
||||
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
|
||||
|
||||
interface BoardHeaderProps {
|
||||
@@ -10,8 +12,7 @@ interface BoardHeaderProps {
|
||||
maxConcurrency: number;
|
||||
onConcurrencyChange: (value: number) => void;
|
||||
isAutoModeRunning: boolean;
|
||||
onStartAutoMode: () => void;
|
||||
onStopAutoMode: () => void;
|
||||
onAutoModeToggle: (enabled: boolean) => void;
|
||||
onAddFeature: () => void;
|
||||
addFeatureShortcut: KeyboardShortcut;
|
||||
isMounted: boolean;
|
||||
@@ -22,8 +23,7 @@ export function BoardHeader({
|
||||
maxConcurrency,
|
||||
onConcurrencyChange,
|
||||
isAutoModeRunning,
|
||||
onStartAutoMode,
|
||||
onStopAutoMode,
|
||||
onAutoModeToggle,
|
||||
onAddFeature,
|
||||
addFeatureShortcut,
|
||||
isMounted,
|
||||
@@ -62,29 +62,20 @@ export function BoardHeader({
|
||||
|
||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && (
|
||||
<>
|
||||
{isAutoModeRunning ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onStopAutoMode}
|
||||
data-testid="stop-auto-mode"
|
||||
>
|
||||
<StopCircle className="w-4 h-4 mr-2" />
|
||||
Stop Auto Mode
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onStartAutoMode}
|
||||
data-testid="start-auto-mode"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Auto Mode
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
|
||||
<Label
|
||||
htmlFor="auto-mode-toggle"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Auto Mode
|
||||
</Label>
|
||||
<Switch
|
||||
id="auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
onCheckedChange={onAutoModeToggle}
|
||||
data-testid="auto-mode-toggle"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HotkeyButton
|
||||
|
||||
@@ -104,6 +104,8 @@ interface KanbanCardProps {
|
||||
onCommit?: () => void;
|
||||
onImplement?: () => void;
|
||||
onComplete?: () => void;
|
||||
onViewPlan?: () => void;
|
||||
onApprovePlan?: () => void;
|
||||
hasContext?: boolean;
|
||||
isCurrentAutoTask?: boolean;
|
||||
shortcutKey?: string;
|
||||
@@ -129,6 +131,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
onCommit,
|
||||
onImplement,
|
||||
onComplete,
|
||||
onViewPlan,
|
||||
onApprovePlan,
|
||||
hasContext,
|
||||
isCurrentAutoTask,
|
||||
shortcutKey,
|
||||
@@ -256,7 +260,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
feature.status === "backlog" ||
|
||||
feature.status === "waiting_approval" ||
|
||||
feature.status === "verified" ||
|
||||
(feature.skipTests && !isCurrentAutoTask);
|
||||
(feature.status === "in_progress" && !isCurrentAutoTask);
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -875,9 +879,26 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1.5">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{isCurrentAutoTask && (
|
||||
<>
|
||||
{/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */}
|
||||
{feature.planSpec?.status === 'generated' && onApprovePlan && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApprovePlan();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`approve-plan-running-${feature.id}`}
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-1 shrink-0" />
|
||||
<span className="truncate">Approve Plan</span>
|
||||
</Button>
|
||||
)}
|
||||
{onViewOutput && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -890,8 +911,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`view-output-${feature.id}`}
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-1" />
|
||||
Logs
|
||||
<FileText className="w-3 h-3 mr-1 shrink-0" />
|
||||
<span className="truncate">Logs</span>
|
||||
{shortcutKey && (
|
||||
<span
|
||||
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
|
||||
@@ -906,7 +927,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 text-[11px] px-2"
|
||||
className="h-7 text-[11px] px-2 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onForceStop();
|
||||
@@ -921,6 +942,23 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
||||
<>
|
||||
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
|
||||
{feature.planSpec?.status === 'generated' && onApprovePlan && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApprovePlan();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`approve-plan-${feature.id}`}
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-1" />
|
||||
Approve Plan
|
||||
</Button>
|
||||
)}
|
||||
{feature.skipTests && onManualVerify ? (
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -1075,6 +1113,22 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<Edit className="w-3 h-3 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
{feature.planSpec?.content && onViewPlan && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewPlan();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`view-plan-${feature.id}`}
|
||||
title="View Plan"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
{onImplement && (
|
||||
<Button
|
||||
variant="default"
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
||||
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
|
||||
import {
|
||||
DescriptionImageDropZone,
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
import {
|
||||
MessageSquare,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
FlaskConical,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
ThinkingLevel,
|
||||
FeatureImage,
|
||||
AIProfile,
|
||||
PlanningMode,
|
||||
} from "@/store/app-store";
|
||||
import {
|
||||
ModelSelector,
|
||||
@@ -42,6 +43,8 @@ import {
|
||||
ProfileQuickSelect,
|
||||
TestingTabContent,
|
||||
PrioritySelector,
|
||||
BranchSelector,
|
||||
PlanningModeSelector,
|
||||
} from "../shared";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -63,13 +66,16 @@ interface AddFeatureDialogProps {
|
||||
skipTests: boolean;
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
branchName: string;
|
||||
branchName: string; // Can be empty string to use current branch
|
||||
priority: number;
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
}) => void;
|
||||
categorySuggestions: string[];
|
||||
branchSuggestions: string[];
|
||||
defaultSkipTests: boolean;
|
||||
defaultBranch?: string;
|
||||
currentBranch?: string;
|
||||
isMaximized: boolean;
|
||||
showProfilesOnly: boolean;
|
||||
aiProfiles: AIProfile[];
|
||||
@@ -83,11 +89,13 @@ export function AddFeatureDialog({
|
||||
branchSuggestions,
|
||||
defaultSkipTests,
|
||||
defaultBranch = "main",
|
||||
currentBranch,
|
||||
isMaximized,
|
||||
showProfilesOnly,
|
||||
aiProfiles,
|
||||
}: AddFeatureDialogProps) {
|
||||
const navigate = useNavigate();
|
||||
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
|
||||
const [newFeature, setNewFeature] = useState({
|
||||
category: "",
|
||||
description: "",
|
||||
@@ -97,7 +105,7 @@ export function AddFeatureDialog({
|
||||
skipTests: false,
|
||||
model: "opus" as AgentModel,
|
||||
thinkingLevel: "none" as ThinkingLevel,
|
||||
branchName: "main",
|
||||
branchName: "",
|
||||
priority: 2 as number, // Default to medium priority
|
||||
});
|
||||
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
|
||||
@@ -108,9 +116,11 @@ export function AddFeatureDialog({
|
||||
const [enhancementMode, setEnhancementMode] = useState<
|
||||
"improve" | "technical" | "simplify" | "acceptance"
|
||||
>("improve");
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||
|
||||
// Get enhancement model and worktrees setting from store
|
||||
const { enhancementModel, useWorktrees } = useAppStore();
|
||||
// Get enhancement model, planning mode defaults, and worktrees setting from store
|
||||
const { enhancementModel, defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
|
||||
|
||||
// Sync defaults when dialog opens
|
||||
useEffect(() => {
|
||||
@@ -118,10 +128,13 @@ export function AddFeatureDialog({
|
||||
setNewFeature((prev) => ({
|
||||
...prev,
|
||||
skipTests: defaultSkipTests,
|
||||
branchName: defaultBranch,
|
||||
branchName: defaultBranch || "",
|
||||
}));
|
||||
setUseCurrentBranch(true);
|
||||
setPlanningMode(defaultPlanningMode);
|
||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||
}
|
||||
}, [open, defaultSkipTests, defaultBranch]);
|
||||
}, [open, defaultSkipTests, defaultBranch, defaultPlanningMode, defaultRequirePlanApproval]);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newFeature.description.trim()) {
|
||||
@@ -129,12 +142,25 @@ export function AddFeatureDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate branch selection when "other branch" is selected
|
||||
if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
|
||||
toast.error("Please select a branch name");
|
||||
return;
|
||||
}
|
||||
|
||||
const category = newFeature.category || "Uncategorized";
|
||||
const selectedModel = newFeature.model;
|
||||
const normalizedThinking = modelSupportsThinking(selectedModel)
|
||||
? newFeature.thinkingLevel
|
||||
: "none";
|
||||
|
||||
// Use current branch if toggle is on
|
||||
// If currentBranch is provided (non-primary worktree), use it
|
||||
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
||||
const finalBranchName = useCurrentBranch
|
||||
? (currentBranch || "")
|
||||
: newFeature.branchName || "";
|
||||
|
||||
onAdd({
|
||||
category,
|
||||
description: newFeature.description,
|
||||
@@ -144,8 +170,10 @@ export function AddFeatureDialog({
|
||||
skipTests: newFeature.skipTests,
|
||||
model: selectedModel,
|
||||
thinkingLevel: normalizedThinking,
|
||||
branchName: newFeature.branchName,
|
||||
branchName: finalBranchName,
|
||||
priority: newFeature.priority,
|
||||
planningMode,
|
||||
requirePlanApproval,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
@@ -159,8 +187,11 @@ export function AddFeatureDialog({
|
||||
model: "opus",
|
||||
priority: 2,
|
||||
thinkingLevel: "none",
|
||||
branchName: defaultBranch,
|
||||
branchName: "",
|
||||
});
|
||||
setUseCurrentBranch(true);
|
||||
setPlanningMode(defaultPlanningMode);
|
||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||
setNewFeaturePreviewMap(new Map());
|
||||
setShowAdvancedOptions(false);
|
||||
setDescriptionError(false);
|
||||
@@ -231,13 +262,13 @@ export function AddFeatureDialog({
|
||||
<DialogContent
|
||||
compact={!isMaximized}
|
||||
data-testid="add-feature-dialog"
|
||||
onPointerDownOutside={(e) => {
|
||||
onPointerDownOutside={(e: CustomEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
onInteractOutside={(e: CustomEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
||||
e.preventDefault();
|
||||
@@ -263,9 +294,9 @@ export function AddFeatureDialog({
|
||||
<Settings2 className="w-4 h-4 mr-2" />
|
||||
Model
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="testing" data-testid="tab-testing">
|
||||
<FlaskConical className="w-4 h-4 mr-2" />
|
||||
Testing
|
||||
<TabsTrigger value="options" data-testid="tab-options">
|
||||
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
||||
Options
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -360,22 +391,17 @@ export function AddFeatureDialog({
|
||||
/>
|
||||
</div>
|
||||
{useWorktrees && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="branch">Target Branch</Label>
|
||||
<BranchAutocomplete
|
||||
value={newFeature.branchName}
|
||||
onChange={(value) =>
|
||||
setNewFeature({ ...newFeature, branchName: value })
|
||||
}
|
||||
branches={branchSuggestions}
|
||||
placeholder="Select or create branch..."
|
||||
data-testid="feature-branch-input"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Work will be done in this branch. A worktree will be created if
|
||||
needed.
|
||||
</p>
|
||||
</div>
|
||||
<BranchSelector
|
||||
useCurrentBranch={useCurrentBranch}
|
||||
onUseCurrentBranchChange={setUseCurrentBranch}
|
||||
branchName={newFeature.branchName}
|
||||
onBranchNameChange={(value) =>
|
||||
setNewFeature({ ...newFeature, branchName: value })
|
||||
}
|
||||
branchSuggestions={branchSuggestions}
|
||||
currentBranch={currentBranch}
|
||||
testIdPrefix="feature"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Priority Selector */}
|
||||
@@ -454,11 +480,22 @@ export function AddFeatureDialog({
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Testing Tab */}
|
||||
<TabsContent
|
||||
value="testing"
|
||||
className="space-y-4 overflow-y-auto cursor-default"
|
||||
>
|
||||
{/* Options Tab */}
|
||||
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
|
||||
{/* Planning Mode Section */}
|
||||
<PlanningModeSelector
|
||||
mode={planningMode}
|
||||
onModeChange={setPlanningMode}
|
||||
requireApproval={requirePlanApproval}
|
||||
onRequireApprovalChange={setRequirePlanApproval}
|
||||
featureDescription={newFeature.description}
|
||||
testIdPrefix="add-feature"
|
||||
compact
|
||||
/>
|
||||
|
||||
<div className="border-t border-border my-4" />
|
||||
|
||||
{/* Testing Section */}
|
||||
<TestingTabContent
|
||||
skipTests={newFeature.skipTests}
|
||||
onSkipTestsChange={(skipTests) =>
|
||||
@@ -478,6 +515,11 @@ export function AddFeatureDialog({
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={open}
|
||||
data-testid="confirm-add-feature"
|
||||
disabled={
|
||||
useWorktrees &&
|
||||
!useCurrentBranch &&
|
||||
!newFeature.branchName.trim()
|
||||
}
|
||||
>
|
||||
Add Feature
|
||||
</HotkeyButton>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Loader2, List, FileText, GitBranch } from "lucide-react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { LogViewer } from "@/components/ui/log-viewer";
|
||||
import { GitDiffPanel } from "@/components/ui/git-diff-panel";
|
||||
import { TaskProgressPanel } from "@/components/ui/task-progress-panel";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import type { AutoModeEvent } from "@/types/electron";
|
||||
|
||||
@@ -168,6 +169,64 @@ export function AgentOutputModal({
|
||||
|
||||
newContent = prepContent;
|
||||
break;
|
||||
case "planning_started":
|
||||
// Show when planning mode begins
|
||||
if ("mode" in event && "message" in event) {
|
||||
const modeLabel =
|
||||
event.mode === "lite"
|
||||
? "Lite"
|
||||
: event.mode === "spec"
|
||||
? "Spec"
|
||||
: "Full";
|
||||
newContent = `\n📋 Planning Mode: ${modeLabel}\n${event.message}\n`;
|
||||
}
|
||||
break;
|
||||
case "plan_approval_required":
|
||||
// Show when plan requires approval
|
||||
if ("planningMode" in event) {
|
||||
newContent = `\n⏸️ Plan generated - waiting for your approval...\n`;
|
||||
}
|
||||
break;
|
||||
case "plan_approved":
|
||||
// Show when plan is manually approved
|
||||
if ("hasEdits" in event) {
|
||||
newContent = event.hasEdits
|
||||
? `\n✅ Plan approved (with edits) - continuing to implementation...\n`
|
||||
: `\n✅ Plan approved - continuing to implementation...\n`;
|
||||
}
|
||||
break;
|
||||
case "plan_auto_approved":
|
||||
// Show when plan is auto-approved
|
||||
newContent = `\n✅ Plan auto-approved - continuing to implementation...\n`;
|
||||
break;
|
||||
case "plan_revision_requested":
|
||||
// Show when user requests plan revision
|
||||
if ("planVersion" in event) {
|
||||
const revisionEvent = event as Extract<AutoModeEvent, { type: "plan_revision_requested" }>;
|
||||
newContent = `\n🔄 Revising plan based on your feedback (v${revisionEvent.planVersion})...\n`;
|
||||
}
|
||||
break;
|
||||
case "auto_mode_task_started":
|
||||
// Show when a task starts
|
||||
if ("taskId" in event && "taskDescription" in event) {
|
||||
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
|
||||
newContent = `\n▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}\n`;
|
||||
}
|
||||
break;
|
||||
case "auto_mode_task_complete":
|
||||
// Show task completion progress
|
||||
if ("taskId" in event && "tasksCompleted" in event && "tasksTotal" in event) {
|
||||
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
|
||||
newContent = `\n✓ ${taskEvent.taskId} completed (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})\n`;
|
||||
}
|
||||
break;
|
||||
case "auto_mode_phase_complete":
|
||||
// Show phase completion for full mode
|
||||
if ("phaseNumber" in event) {
|
||||
const phaseEvent = event as Extract<AutoModeEvent, { type: "auto_mode_phase_complete" }>;
|
||||
newContent = `\n🏁 Phase ${phaseEvent.phaseNumber} complete\n`;
|
||||
}
|
||||
break;
|
||||
case "auto_mode_feature_complete":
|
||||
const emoji = event.passes ? "✅" : "⚠️";
|
||||
newContent = `\n${emoji} Task completed: ${event.message}\n`;
|
||||
@@ -287,6 +346,13 @@ export function AgentOutputModal({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Task Progress Panel - shows when tasks are being executed */}
|
||||
<TaskProgressPanel
|
||||
featureId={featureId}
|
||||
projectPath={projectPath}
|
||||
className="flex-shrink-0 mx-1"
|
||||
/>
|
||||
|
||||
{viewMode === "changes" ? (
|
||||
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
|
||||
{projectPath ? (
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Archive } from "lucide-react";
|
||||
|
||||
interface ArchiveAllVerifiedDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
verifiedCount: number;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function ArchiveAllVerifiedDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
verifiedCount,
|
||||
onConfirm,
|
||||
}: ArchiveAllVerifiedDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent data-testid="archive-all-verified-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Archive All Verified Features</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to archive all verified features? They will be
|
||||
moved to the archive box.
|
||||
{verifiedCount > 0 && (
|
||||
<span className="block mt-2 text-yellow-500">
|
||||
{verifiedCount} feature(s) will be archived.
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" onClick={onConfirm} data-testid="confirm-archive-all-verified">
|
||||
<Archive className="w-4 h-4 mr-2" />
|
||||
Archive All
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -48,18 +48,26 @@ export function CreatePRDialog({
|
||||
const [prUrl, setPrUrl] = useState<string | null>(null);
|
||||
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
|
||||
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
|
||||
// Track whether an operation completed that warrants a refresh
|
||||
const operationCompletedRef = useRef(false);
|
||||
|
||||
// Reset state when dialog opens or worktree changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Only reset form fields, not the result states (prUrl, browserUrl, showBrowserFallback)
|
||||
// These are set by the API response and should persist until dialog closes
|
||||
// Reset form fields
|
||||
setTitle("");
|
||||
setBody("");
|
||||
setCommitMessage("");
|
||||
setBaseBranch("main");
|
||||
setIsDraft(false);
|
||||
setError(null);
|
||||
// Also reset result states when opening for a new worktree
|
||||
// This prevents showing stale PR URLs from previous worktrees
|
||||
setPrUrl(null);
|
||||
setBrowserUrl(null);
|
||||
setShowBrowserFallback(false);
|
||||
// Reset operation tracking
|
||||
operationCompletedRef.current = false;
|
||||
} else {
|
||||
// Reset everything when dialog closes
|
||||
setTitle("");
|
||||
@@ -71,6 +79,7 @@ export function CreatePRDialog({
|
||||
setPrUrl(null);
|
||||
setBrowserUrl(null);
|
||||
setShowBrowserFallback(false);
|
||||
operationCompletedRef.current = false;
|
||||
}
|
||||
}, [open, worktree?.path]);
|
||||
|
||||
@@ -97,6 +106,8 @@ export function CreatePRDialog({
|
||||
if (result.success && result.result) {
|
||||
if (result.result.prCreated && result.result.prUrl) {
|
||||
setPrUrl(result.result.prUrl);
|
||||
// Mark operation as completed for refresh on close
|
||||
operationCompletedRef.current = true;
|
||||
toast.success("Pull request created!", {
|
||||
description: `PR created from ${result.result.branch}`,
|
||||
action: {
|
||||
@@ -104,7 +115,8 @@ export function CreatePRDialog({
|
||||
onClick: () => window.open(result.result!.prUrl!, "_blank"),
|
||||
},
|
||||
});
|
||||
onCreated();
|
||||
// Don't call onCreated() here - keep dialog open to show success message
|
||||
// onCreated() will be called when user closes the dialog
|
||||
} else {
|
||||
// Branch was pushed successfully
|
||||
const prError = result.result.prError;
|
||||
@@ -116,6 +128,8 @@ export function CreatePRDialog({
|
||||
if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) {
|
||||
setBrowserUrl(result.result.browserUrl ?? null);
|
||||
setShowBrowserFallback(true);
|
||||
// Mark operation as completed - branch was pushed successfully
|
||||
operationCompletedRef.current = true;
|
||||
toast.success("Branch pushed", {
|
||||
description: result.result.committed
|
||||
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
|
||||
@@ -141,6 +155,8 @@ export function CreatePRDialog({
|
||||
// Show error but also provide browser option
|
||||
setBrowserUrl(result.result.browserUrl ?? null);
|
||||
setShowBrowserFallback(true);
|
||||
// Mark operation as completed - branch was pushed even though PR creation failed
|
||||
operationCompletedRef.current = true;
|
||||
toast.error("PR creation failed", {
|
||||
description: errorMessage,
|
||||
duration: 8000,
|
||||
@@ -181,19 +197,13 @@ export function CreatePRDialog({
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Only call onCreated() if an actual operation completed
|
||||
// This prevents unnecessary refreshes when user cancels
|
||||
if (operationCompletedRef.current) {
|
||||
onCreated();
|
||||
}
|
||||
onOpenChange(false);
|
||||
// Reset state after dialog closes
|
||||
setTimeout(() => {
|
||||
setTitle("");
|
||||
setBody("");
|
||||
setCommitMessage("");
|
||||
setBaseBranch("main");
|
||||
setIsDraft(false);
|
||||
setError(null);
|
||||
setPrUrl(null);
|
||||
setBrowserUrl(null);
|
||||
setShowBrowserFallback(false);
|
||||
}, 200);
|
||||
// State reset is handled by useEffect when open becomes false
|
||||
};
|
||||
|
||||
if (!worktree) return null;
|
||||
@@ -227,13 +237,18 @@ export function CreatePRDialog({
|
||||
Your PR is ready for review
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => window.open(prUrl, "_blank")}
|
||||
className="gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
View Pull Request
|
||||
</Button>
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button
|
||||
onClick={() => window.open(prUrl, "_blank")}
|
||||
className="gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
View Pull Request
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : shouldShowBrowserFallback ? (
|
||||
<div className="py-6 text-center space-y-4">
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
||||
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
|
||||
import {
|
||||
DescriptionImageDropZone,
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
import {
|
||||
MessageSquare,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
FlaskConical,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
ThinkingLevel,
|
||||
AIProfile,
|
||||
useAppStore,
|
||||
PlanningMode,
|
||||
} from "@/store/app-store";
|
||||
import {
|
||||
ModelSelector,
|
||||
@@ -43,6 +44,8 @@ import {
|
||||
ProfileQuickSelect,
|
||||
TestingTabContent,
|
||||
PrioritySelector,
|
||||
BranchSelector,
|
||||
PlanningModeSelector,
|
||||
} from "../shared";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -65,12 +68,15 @@ interface EditFeatureDialogProps {
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
imagePaths: DescriptionImagePath[];
|
||||
branchName: string;
|
||||
branchName: string; // Can be empty string to use current branch
|
||||
priority: number;
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
}
|
||||
) => void;
|
||||
categorySuggestions: string[];
|
||||
branchSuggestions: string[];
|
||||
currentBranch?: string;
|
||||
isMaximized: boolean;
|
||||
showProfilesOnly: boolean;
|
||||
aiProfiles: AIProfile[];
|
||||
@@ -83,12 +89,17 @@ export function EditFeatureDialog({
|
||||
onUpdate,
|
||||
categorySuggestions,
|
||||
branchSuggestions,
|
||||
currentBranch,
|
||||
isMaximized,
|
||||
showProfilesOnly,
|
||||
aiProfiles,
|
||||
allFeatures,
|
||||
}: EditFeatureDialogProps) {
|
||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
||||
const [useCurrentBranch, setUseCurrentBranch] = useState(() => {
|
||||
// If feature has no branchName, default to using current branch
|
||||
return !feature?.branchName;
|
||||
});
|
||||
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
|
||||
useState<ImagePreviewMap>(() => new Map());
|
||||
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
|
||||
@@ -97,13 +108,20 @@ export function EditFeatureDialog({
|
||||
"improve" | "technical" | "simplify" | "acceptance"
|
||||
>("improve");
|
||||
const [showDependencyTree, setShowDependencyTree] = useState(false);
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(feature?.requirePlanApproval ?? false);
|
||||
|
||||
// Get enhancement model and worktrees setting from store
|
||||
const { enhancementModel, useWorktrees } = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
setEditingFeature(feature);
|
||||
if (!feature) {
|
||||
if (feature) {
|
||||
setPlanningMode(feature.planningMode ?? 'skip');
|
||||
setRequirePlanApproval(feature.requirePlanApproval ?? false);
|
||||
// If feature has no branchName, default to using current branch
|
||||
setUseCurrentBranch(!feature.branchName);
|
||||
} else {
|
||||
setEditFeaturePreviewMap(new Map());
|
||||
setShowEditAdvancedOptions(false);
|
||||
}
|
||||
@@ -112,6 +130,18 @@ export function EditFeatureDialog({
|
||||
const handleUpdate = () => {
|
||||
if (!editingFeature) return;
|
||||
|
||||
// Validate branch selection when "other branch" is selected and branch selector is enabled
|
||||
const isBranchSelectorEnabled = editingFeature.status === "backlog";
|
||||
if (
|
||||
useWorktrees &&
|
||||
isBranchSelectorEnabled &&
|
||||
!useCurrentBranch &&
|
||||
!editingFeature.branchName?.trim()
|
||||
) {
|
||||
toast.error("Please select a branch name");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
|
||||
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
|
||||
selectedModel
|
||||
@@ -119,6 +149,13 @@ export function EditFeatureDialog({
|
||||
? editingFeature.thinkingLevel ?? "none"
|
||||
: "none";
|
||||
|
||||
// Use current branch if toggle is on
|
||||
// If currentBranch is provided (non-primary worktree), use it
|
||||
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
||||
const finalBranchName = useCurrentBranch
|
||||
? (currentBranch || "")
|
||||
: editingFeature.branchName || "";
|
||||
|
||||
const updates = {
|
||||
category: editingFeature.category,
|
||||
description: editingFeature.description,
|
||||
@@ -127,8 +164,10 @@ export function EditFeatureDialog({
|
||||
model: selectedModel,
|
||||
thinkingLevel: normalizedThinking,
|
||||
imagePaths: editingFeature.imagePaths ?? [],
|
||||
branchName: editingFeature.branchName ?? "main",
|
||||
branchName: finalBranchName,
|
||||
priority: editingFeature.priority ?? 2,
|
||||
planningMode,
|
||||
requirePlanApproval,
|
||||
};
|
||||
|
||||
onUpdate(editingFeature.id, updates);
|
||||
@@ -206,13 +245,13 @@ export function EditFeatureDialog({
|
||||
<DialogContent
|
||||
compact={!isMaximized}
|
||||
data-testid="edit-feature-dialog"
|
||||
onPointerDownOutside={(e) => {
|
||||
onPointerDownOutside={(e: CustomEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
onInteractOutside={(e: CustomEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
||||
e.preventDefault();
|
||||
@@ -236,9 +275,9 @@ export function EditFeatureDialog({
|
||||
<Settings2 className="w-4 h-4 mr-2" />
|
||||
Model
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="testing" data-testid="edit-tab-testing">
|
||||
<FlaskConical className="w-4 h-4 mr-2" />
|
||||
Testing
|
||||
<TabsTrigger value="options" data-testid="edit-tab-options">
|
||||
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
||||
Options
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -338,33 +377,21 @@ export function EditFeatureDialog({
|
||||
/>
|
||||
</div>
|
||||
{useWorktrees && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-branch">Target Branch</Label>
|
||||
<BranchAutocomplete
|
||||
value={editingFeature.branchName ?? "main"}
|
||||
onChange={(value) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
branchName: value,
|
||||
})
|
||||
}
|
||||
branches={branchSuggestions}
|
||||
placeholder="Select or create branch..."
|
||||
data-testid="edit-feature-branch"
|
||||
disabled={editingFeature.status !== "backlog"}
|
||||
/>
|
||||
{editingFeature.status !== "backlog" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Branch cannot be changed after work has started.
|
||||
</p>
|
||||
)}
|
||||
{editingFeature.status === "backlog" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Work will be done in this branch. A worktree will be created
|
||||
if needed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<BranchSelector
|
||||
useCurrentBranch={useCurrentBranch}
|
||||
onUseCurrentBranchChange={setUseCurrentBranch}
|
||||
branchName={editingFeature.branchName ?? ""}
|
||||
onBranchNameChange={(value) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
branchName: value,
|
||||
})
|
||||
}
|
||||
branchSuggestions={branchSuggestions}
|
||||
currentBranch={currentBranch}
|
||||
disabled={editingFeature.status !== "backlog"}
|
||||
testIdPrefix="edit-feature"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Priority Selector */}
|
||||
@@ -449,11 +476,22 @@ export function EditFeatureDialog({
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Testing Tab */}
|
||||
<TabsContent
|
||||
value="testing"
|
||||
className="space-y-4 overflow-y-auto cursor-default"
|
||||
>
|
||||
{/* Options Tab */}
|
||||
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
|
||||
{/* Planning Mode Section */}
|
||||
<PlanningModeSelector
|
||||
mode={planningMode}
|
||||
onModeChange={setPlanningMode}
|
||||
requireApproval={requirePlanApproval}
|
||||
onRequireApprovalChange={setRequirePlanApproval}
|
||||
featureDescription={editingFeature.description}
|
||||
testIdPrefix="edit-feature"
|
||||
compact
|
||||
/>
|
||||
|
||||
<div className="border-t border-border my-4" />
|
||||
|
||||
{/* Testing Section */}
|
||||
<TestingTabContent
|
||||
skipTests={editingFeature.skipTests ?? false}
|
||||
onSkipTestsChange={(skipTests) =>
|
||||
@@ -485,6 +523,12 @@ export function EditFeatureDialog({
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={!!editingFeature}
|
||||
data-testid="confirm-edit-feature"
|
||||
disabled={
|
||||
useWorktrees &&
|
||||
editingFeature.status === "backlog" &&
|
||||
!useCurrentBranch &&
|
||||
!editingFeature.branchName?.trim()
|
||||
}
|
||||
>
|
||||
Save Changes
|
||||
</HotkeyButton>
|
||||
|
||||
@@ -57,7 +57,7 @@ export function FollowUpDialog({
|
||||
<DialogContent
|
||||
compact={!isMaximized}
|
||||
data-testid="follow-up-dialog"
|
||||
onKeyDown={(e) => {
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && prompt.trim()) {
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export { AddFeatureDialog } from "./add-feature-dialog";
|
||||
export { AgentOutputModal } from "./agent-output-modal";
|
||||
export { CompletedFeaturesModal } from "./completed-features-modal";
|
||||
export { DeleteAllVerifiedDialog } from "./delete-all-verified-dialog";
|
||||
export { ArchiveAllVerifiedDialog } from "./archive-all-verified-dialog";
|
||||
export { DeleteCompletedFeatureDialog } from "./delete-completed-feature-dialog";
|
||||
export { EditFeatureDialog } from "./edit-feature-dialog";
|
||||
export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
|
||||
export { FollowUpDialog } from "./follow-up-dialog";
|
||||
export { PlanApprovalDialog } from "./plan-approval-dialog";
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { Check, RefreshCw, Edit2, Eye, Loader2 } from "lucide-react";
|
||||
|
||||
interface PlanApprovalDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
feature: Feature | null;
|
||||
planContent: string;
|
||||
onApprove: (editedPlan?: string) => void;
|
||||
onReject: (feedback?: string) => void;
|
||||
isLoading?: boolean;
|
||||
viewOnly?: boolean;
|
||||
}
|
||||
|
||||
export function PlanApprovalDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
feature,
|
||||
planContent,
|
||||
onApprove,
|
||||
onReject,
|
||||
isLoading = false,
|
||||
viewOnly = false,
|
||||
}: PlanApprovalDialogProps) {
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [editedPlan, setEditedPlan] = useState(planContent);
|
||||
const [showRejectFeedback, setShowRejectFeedback] = useState(false);
|
||||
const [rejectFeedback, setRejectFeedback] = useState("");
|
||||
|
||||
// Reset state when dialog opens or plan content changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setEditedPlan(planContent);
|
||||
setIsEditMode(false);
|
||||
setShowRejectFeedback(false);
|
||||
setRejectFeedback("");
|
||||
}
|
||||
}, [open, planContent]);
|
||||
|
||||
const handleApprove = () => {
|
||||
// Only pass edited plan if it was modified
|
||||
const wasEdited = editedPlan !== planContent;
|
||||
onApprove(wasEdited ? editedPlan : undefined);
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
if (showRejectFeedback) {
|
||||
onReject(rejectFeedback.trim() || undefined);
|
||||
} else {
|
||||
setShowRejectFeedback(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelReject = () => {
|
||||
setShowRejectFeedback(false);
|
||||
setRejectFeedback("");
|
||||
};
|
||||
|
||||
const handleClose = (open: boolean) => {
|
||||
if (!open && !isLoading) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className="max-w-4xl"
|
||||
data-testid="plan-approval-dialog"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{viewOnly ? "View Plan" : "Review Plan"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{viewOnly
|
||||
? "View the generated plan for this feature."
|
||||
: "Review the generated plan before implementation begins."}
|
||||
{feature && (
|
||||
<span className="block mt-2 text-primary">
|
||||
Feature: {feature.description.slice(0, 150)}
|
||||
{feature.description.length > 150 ? "..." : ""}
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||
{/* Mode Toggle - Only show when not in viewOnly mode */}
|
||||
{!viewOnly && (
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{isEditMode ? "Edit Mode" : "View Mode"}
|
||||
</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsEditMode(!isEditMode)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isEditMode ? (
|
||||
<>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
View
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Edit2 className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan Content */}
|
||||
<div className="flex-1 overflow-y-auto max-h-[70vh] border border-border rounded-lg">
|
||||
{isEditMode && !viewOnly ? (
|
||||
<Textarea
|
||||
value={editedPlan}
|
||||
onChange={(e) => setEditedPlan(e.target.value)}
|
||||
className="min-h-[400px] h-full w-full border-0 rounded-lg resize-none font-mono text-sm"
|
||||
placeholder="Enter plan content..."
|
||||
disabled={isLoading}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 overflow-auto">
|
||||
<Markdown>{editedPlan || "No plan content available."}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Revision Feedback Section - Only show when not in viewOnly mode */}
|
||||
{showRejectFeedback && !viewOnly && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor="reject-feedback">What changes would you like?</Label>
|
||||
<Textarea
|
||||
id="reject-feedback"
|
||||
value={rejectFeedback}
|
||||
onChange={(e) => setRejectFeedback(e.target.value)}
|
||||
placeholder="Describe the changes you'd like to see in the plan..."
|
||||
className="min-h-[80px]"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to cancel the feature, or provide feedback to regenerate the plan.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-shrink-0 gap-2">
|
||||
{viewOnly ? (
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
) : showRejectFeedback ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleCancelReject}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleReject}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{rejectFeedback.trim() ? "Revise Plan" : "Cancel Feature"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReject}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Request Changes
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
disabled={isLoading}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Approve
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
FeatureImage,
|
||||
AgentModel,
|
||||
ThinkingLevel,
|
||||
PlanningMode,
|
||||
useAppStore,
|
||||
} from "@/store/app-store";
|
||||
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
|
||||
@@ -76,67 +77,13 @@ export function useBoardActions({
|
||||
moveFeature,
|
||||
useWorktrees,
|
||||
enableDependencyBlocking,
|
||||
isPrimaryWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
} = useAppStore();
|
||||
const autoMode = useAutoMode();
|
||||
|
||||
/**
|
||||
* Get or create the worktree path for a feature based on its branchName.
|
||||
* - If branchName is "main" or empty, returns the project path
|
||||
* - Otherwise, creates a worktree for that branch if needed
|
||||
*/
|
||||
const getOrCreateWorktreeForFeature = useCallback(
|
||||
async (feature: Feature): Promise<string | null> => {
|
||||
if (!projectPath) return null;
|
||||
|
||||
const branchName = feature.branchName || "main";
|
||||
|
||||
// If targeting main branch, use the project path directly
|
||||
if (branchName === "main" || branchName === "master") {
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
// For other branches, create a worktree if it doesn't exist
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.create) {
|
||||
console.error("[BoardActions] Worktree API not available");
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
// Try to create the worktree (will return existing if already exists)
|
||||
const result = await api.worktree.create(projectPath, branchName);
|
||||
|
||||
if (result.success && result.worktree) {
|
||||
console.log(
|
||||
`[BoardActions] Worktree ready for branch "${branchName}": ${result.worktree.path}`
|
||||
);
|
||||
if (result.worktree.isNew) {
|
||||
toast.success(`Worktree created for branch "${branchName}"`, {
|
||||
description: "A new worktree was created for this feature.",
|
||||
});
|
||||
}
|
||||
return result.worktree.path;
|
||||
} else {
|
||||
console.error(
|
||||
"[BoardActions] Failed to create worktree:",
|
||||
result.error
|
||||
);
|
||||
toast.error("Failed to create worktree", {
|
||||
description:
|
||||
result.error || "Could not create worktree for this branch.",
|
||||
});
|
||||
return projectPath; // Fall back to project path
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[BoardActions] Error creating worktree:", error);
|
||||
toast.error("Error creating worktree", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return projectPath; // Fall back to project path
|
||||
}
|
||||
},
|
||||
[projectPath]
|
||||
);
|
||||
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
|
||||
// at execution time based on feature.branchName
|
||||
|
||||
const handleAddFeature = useCallback(
|
||||
async (featureData: {
|
||||
@@ -150,35 +97,27 @@ export function useBoardActions({
|
||||
thinkingLevel: ThinkingLevel;
|
||||
branchName: string;
|
||||
priority: number;
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
}) => {
|
||||
let worktreePath: string | undefined;
|
||||
|
||||
// If worktrees are enabled and a non-main branch is selected, create the worktree
|
||||
if (useWorktrees && featureData.branchName) {
|
||||
const branchName = featureData.branchName;
|
||||
if (branchName !== "main" && branchName !== "master") {
|
||||
// Create a temporary feature-like object for getOrCreateWorktreeForFeature
|
||||
const tempFeature = { branchName } as Feature;
|
||||
const path = await getOrCreateWorktreeForFeature(tempFeature);
|
||||
if (path && path !== projectPath) {
|
||||
worktreePath = path;
|
||||
// Refresh worktree selector after creating worktree
|
||||
onWorktreeCreated?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Simplified: Only store branchName, no worktree creation on add
|
||||
// Worktrees are created at execution time (when feature starts)
|
||||
// Empty string means "unassigned" (show only on primary worktree) - convert to undefined
|
||||
// Non-empty string is the actual branch name (for non-primary worktrees)
|
||||
const finalBranchName = featureData.branchName || undefined;
|
||||
|
||||
const newFeatureData = {
|
||||
...featureData,
|
||||
status: "backlog" as const,
|
||||
worktreePath,
|
||||
branchName: finalBranchName,
|
||||
// No worktreePath - derived at runtime from branchName
|
||||
};
|
||||
const createdFeature = addFeature(newFeatureData);
|
||||
// Must await to ensure feature exists on server before user can drag it
|
||||
await persistFeatureCreate(createdFeature);
|
||||
saveCategory(featureData.category);
|
||||
},
|
||||
[addFeature, persistFeatureCreate, saveCategory, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
|
||||
[addFeature, persistFeatureCreate, saveCategory]
|
||||
);
|
||||
|
||||
const handleUpdateFeature = useCallback(
|
||||
@@ -194,46 +133,16 @@ export function useBoardActions({
|
||||
imagePaths: DescriptionImagePath[];
|
||||
branchName: string;
|
||||
priority: number;
|
||||
planningMode?: PlanningMode;
|
||||
requirePlanApproval?: boolean;
|
||||
}
|
||||
) => {
|
||||
// Get the current feature to check if branch is changing
|
||||
const currentFeature = features.find((f) => f.id === featureId);
|
||||
const currentBranch = currentFeature?.branchName || "main";
|
||||
const newBranch = updates.branchName || "main";
|
||||
const branchIsChanging = currentBranch !== newBranch;
|
||||
const finalBranchName = updates.branchName || undefined;
|
||||
|
||||
let worktreePath: string | undefined;
|
||||
let shouldClearWorktreePath = false;
|
||||
|
||||
// If worktrees are enabled and branch is changing to a non-main branch, create worktree
|
||||
if (useWorktrees && branchIsChanging) {
|
||||
if (newBranch === "main" || newBranch === "master") {
|
||||
// Changing to main - clear the worktreePath
|
||||
shouldClearWorktreePath = true;
|
||||
} else {
|
||||
// Changing to a feature branch - create worktree if needed
|
||||
const tempFeature = { branchName: newBranch } as Feature;
|
||||
const path = await getOrCreateWorktreeForFeature(tempFeature);
|
||||
if (path && path !== projectPath) {
|
||||
worktreePath = path;
|
||||
// Refresh worktree selector after creating worktree
|
||||
onWorktreeCreated?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build final updates with worktreePath if it was changed
|
||||
let finalUpdates: typeof updates & { worktreePath?: string };
|
||||
if (branchIsChanging && useWorktrees) {
|
||||
if (shouldClearWorktreePath) {
|
||||
// Use null to clear the value in persistence (cast to work around type system)
|
||||
finalUpdates = { ...updates, worktreePath: null as unknown as string | undefined };
|
||||
} else {
|
||||
finalUpdates = { ...updates, worktreePath };
|
||||
}
|
||||
} else {
|
||||
finalUpdates = updates;
|
||||
}
|
||||
const finalUpdates = {
|
||||
...updates,
|
||||
branchName: finalBranchName,
|
||||
};
|
||||
|
||||
updateFeature(featureId, finalUpdates);
|
||||
persistFeatureUpdate(featureId, finalUpdates);
|
||||
@@ -242,7 +151,7 @@ export function useBoardActions({
|
||||
}
|
||||
setEditingFeature(null);
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, features, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
|
||||
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature]
|
||||
);
|
||||
|
||||
const handleDeleteFeature = useCallback(
|
||||
@@ -307,21 +216,18 @@ export function useBoardActions({
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the feature's assigned worktreePath (set when moving to in_progress)
|
||||
// This ensures work happens in the correct worktree based on the feature's branchName
|
||||
const featureWorktreePath = feature.worktreePath;
|
||||
|
||||
// Server derives workDir from feature.branchName at execution time
|
||||
const result = await api.autoMode.runFeature(
|
||||
currentProject.path,
|
||||
feature.id,
|
||||
useWorktrees,
|
||||
featureWorktreePath || undefined
|
||||
useWorktrees
|
||||
// No worktreePath - server derives from feature.branchName
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
"[Board] Feature run started successfully in worktree:",
|
||||
featureWorktreePath || "main"
|
||||
"[Board] Feature run started successfully, branch:",
|
||||
feature.branchName || "default"
|
||||
);
|
||||
} else {
|
||||
console.error("[Board] Failed to run feature:", result.error);
|
||||
@@ -350,10 +256,12 @@ export function useBoardActions({
|
||||
if (enableDependencyBlocking) {
|
||||
const blockingDeps = getBlockingDependencies(feature, features);
|
||||
if (blockingDeps.length > 0) {
|
||||
const depDescriptions = blockingDeps.map(depId => {
|
||||
const dep = features.find(f => f.id === depId);
|
||||
return dep ? truncateDescription(dep.description, 40) : depId;
|
||||
}).join(", ");
|
||||
const depDescriptions = blockingDeps
|
||||
.map((depId) => {
|
||||
const dep = features.find((f) => f.id === depId);
|
||||
return dep ? truncateDescription(dep.description, 40) : depId;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
toast.warning("Starting feature with incomplete dependencies", {
|
||||
description: `This feature depends on: ${depDescriptions}`,
|
||||
@@ -372,7 +280,14 @@ export function useBoardActions({
|
||||
await handleRunFeature(feature);
|
||||
return true;
|
||||
},
|
||||
[autoMode, enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
|
||||
[
|
||||
autoMode,
|
||||
enableDependencyBlocking,
|
||||
features,
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
handleRunFeature,
|
||||
]
|
||||
);
|
||||
|
||||
const handleVerifyFeature = useCallback(
|
||||
@@ -489,7 +404,6 @@ export function useBoardActions({
|
||||
|
||||
const featureId = followUpFeature.id;
|
||||
const featureDescription = followUpFeature.description;
|
||||
const prompt = followUpPrompt;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.followUpFeature) {
|
||||
@@ -521,15 +435,14 @@ export function useBoardActions({
|
||||
});
|
||||
|
||||
const imagePaths = followUpImagePaths.map((img) => img.path);
|
||||
// Use the feature's worktreePath to ensure work happens in the correct branch
|
||||
const featureWorktreePath = followUpFeature.worktreePath;
|
||||
// Server derives workDir from feature.branchName at execution time
|
||||
api.autoMode
|
||||
.followUpFeature(
|
||||
currentProject.path,
|
||||
followUpFeature.id,
|
||||
followUpPrompt,
|
||||
imagePaths,
|
||||
featureWorktreePath
|
||||
imagePaths
|
||||
// No worktreePath - server derives from feature.branchName
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error("[Board] Error sending follow-up:", error);
|
||||
@@ -569,11 +482,11 @@ export function useBoardActions({
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass the feature's worktreePath to ensure commits happen in the correct worktree
|
||||
// Server derives workDir from feature.branchName
|
||||
const result = await api.autoMode.commitFeature(
|
||||
currentProject.path,
|
||||
feature.id,
|
||||
feature.worktreePath
|
||||
feature.id
|
||||
// No worktreePath - server derives from feature.branchName
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
@@ -758,23 +671,25 @@ export function useBoardActions({
|
||||
const handleStartNextFeatures = useCallback(async () => {
|
||||
// Filter backlog features by the currently selected worktree branch
|
||||
// This ensures "G" only starts features from the filtered list
|
||||
const primaryBranch = projectPath
|
||||
? getPrimaryWorktreeBranch(projectPath)
|
||||
: null;
|
||||
const backlogFeatures = features.filter((f) => {
|
||||
if (f.status !== "backlog") return false;
|
||||
|
||||
// Determine the feature's branch (default to "main" if not set)
|
||||
const featureBranch = f.branchName || "main";
|
||||
// Determine the feature's branch (default to primary branch if not set)
|
||||
const featureBranch = f.branchName || primaryBranch || "main";
|
||||
|
||||
// If no worktree is selected (currentWorktreeBranch is null or main-like),
|
||||
// show features with no branch or "main"/"master" branch
|
||||
// If no worktree is selected (currentWorktreeBranch is null or matches primary),
|
||||
// show features with no branch or primary branch
|
||||
if (
|
||||
!currentWorktreeBranch ||
|
||||
currentWorktreeBranch === "main" ||
|
||||
currentWorktreeBranch === "master"
|
||||
(projectPath &&
|
||||
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch))
|
||||
) {
|
||||
return (
|
||||
!f.branchName ||
|
||||
featureBranch === "main" ||
|
||||
featureBranch === "master"
|
||||
(projectPath && isPrimaryWorktreeBranch(projectPath, featureBranch))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -794,57 +709,65 @@ export function useBoardActions({
|
||||
}
|
||||
|
||||
if (backlogFeatures.length === 0) {
|
||||
const isOnPrimaryBranch =
|
||||
!currentWorktreeBranch ||
|
||||
(projectPath &&
|
||||
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch));
|
||||
toast.info("Backlog empty", {
|
||||
description:
|
||||
currentWorktreeBranch &&
|
||||
currentWorktreeBranch !== "main" &&
|
||||
currentWorktreeBranch !== "master"
|
||||
? `No features in backlog for branch "${currentWorktreeBranch}".`
|
||||
: "No features in backlog to start.",
|
||||
description: !isOnPrimaryBranch
|
||||
? `No features in backlog for branch "${currentWorktreeBranch}".`
|
||||
: "No features in backlog to start.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by priority (lower number = higher priority, priority 1 is highest)
|
||||
// This matches the auto mode service behavior for consistency
|
||||
const sortedBacklog = [...backlogFeatures].sort(
|
||||
(a, b) => (a.priority || 999) - (b.priority || 999)
|
||||
);
|
||||
// Features with blocking dependencies are sorted to the end
|
||||
const sortedBacklog = [...backlogFeatures].sort((a, b) => {
|
||||
const aBlocked = enableDependencyBlocking
|
||||
? getBlockingDependencies(a, features).length > 0
|
||||
: false;
|
||||
const bBlocked = enableDependencyBlocking
|
||||
? getBlockingDependencies(b, features).length > 0
|
||||
: false;
|
||||
|
||||
// Blocked features go to the end
|
||||
if (aBlocked && !bBlocked) return 1;
|
||||
if (!aBlocked && bBlocked) return -1;
|
||||
|
||||
// Within same blocked/unblocked group, sort by priority
|
||||
return (a.priority || 999) - (b.priority || 999);
|
||||
});
|
||||
|
||||
// Find the first feature without blocking dependencies
|
||||
const featureToStart = sortedBacklog.find((f) => {
|
||||
if (!enableDependencyBlocking) return true;
|
||||
return getBlockingDependencies(f, features).length === 0;
|
||||
});
|
||||
|
||||
if (!featureToStart) {
|
||||
toast.info("No eligible features", {
|
||||
description:
|
||||
"All backlog features have unmet dependencies. Complete their dependencies first.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start only one feature per keypress (user must press again for next)
|
||||
const featuresToStart = sortedBacklog.slice(0, 1);
|
||||
|
||||
for (const feature of featuresToStart) {
|
||||
// Only create worktrees if the feature is enabled
|
||||
let worktreePath: string | null = null;
|
||||
if (useWorktrees) {
|
||||
// Get or create worktree based on the feature's assigned branch (same as drag-to-in-progress)
|
||||
worktreePath = await getOrCreateWorktreeForFeature(feature);
|
||||
if (worktreePath) {
|
||||
await persistFeatureUpdate(feature.id, { worktreePath });
|
||||
}
|
||||
// Refresh worktree selector after creating worktree
|
||||
onWorktreeCreated?.();
|
||||
}
|
||||
// Start the implementation
|
||||
// Pass feature with worktreePath so handleRunFeature uses the correct path
|
||||
await handleStartImplementation({
|
||||
...feature,
|
||||
worktreePath: worktreePath || undefined,
|
||||
});
|
||||
}
|
||||
// Simplified: No worktree creation on client - server derives workDir from feature.branchName
|
||||
await handleStartImplementation(featureToStart);
|
||||
}, [
|
||||
features,
|
||||
runningAutoTasks,
|
||||
handleStartImplementation,
|
||||
getOrCreateWorktreeForFeature,
|
||||
persistFeatureUpdate,
|
||||
onWorktreeCreated,
|
||||
currentWorktreeBranch,
|
||||
useWorktrees,
|
||||
projectPath,
|
||||
isPrimaryWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
enableDependencyBlocking,
|
||||
]);
|
||||
|
||||
const handleDeleteAllVerified = useCallback(async () => {
|
||||
const handleArchiveAllVerified = useCallback(async () => {
|
||||
const verifiedFeatures = features.filter((f) => f.status === "verified");
|
||||
|
||||
for (const feature of verifiedFeatures) {
|
||||
@@ -853,22 +776,29 @@ export function useBoardActions({
|
||||
try {
|
||||
await autoMode.stopFeature(feature.id);
|
||||
} catch (error) {
|
||||
console.error("[Board] Error stopping feature before delete:", error);
|
||||
console.error(
|
||||
"[Board] Error stopping feature before archive:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
removeFeature(feature.id);
|
||||
persistFeatureDelete(feature.id);
|
||||
// Archive the feature by setting status to completed
|
||||
const updates = {
|
||||
status: "completed" as const,
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
}
|
||||
|
||||
toast.success("All verified features deleted", {
|
||||
description: `Deleted ${verifiedFeatures.length} feature(s).`,
|
||||
toast.success("All verified features archived", {
|
||||
description: `Archived ${verifiedFeatures.length} feature(s).`,
|
||||
});
|
||||
}, [
|
||||
features,
|
||||
runningAutoTasks,
|
||||
autoMode,
|
||||
removeFeature,
|
||||
persistFeatureDelete,
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -890,6 +820,6 @@ export function useBoardActions({
|
||||
handleOutputModalNumberKeyPress,
|
||||
handleForceStopFeature,
|
||||
handleStartNextFeatures,
|
||||
handleDeleteAllVerified,
|
||||
handleArchiveAllVerified,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { resolveDependencies } from "@/lib/dependency-resolver";
|
||||
import { pathsEqual } from "@/lib/utils";
|
||||
import { Feature, useAppStore } from "@/store/app-store";
|
||||
import { resolveDependencies, getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||
|
||||
type ColumnId = Feature["status"];
|
||||
|
||||
@@ -56,26 +55,24 @@ export function useBoardColumnFeatures({
|
||||
// If feature has a running agent, always show it in "in_progress"
|
||||
const isRunning = runningAutoTasks.includes(f.id);
|
||||
|
||||
// Check if feature matches the current worktree
|
||||
// Match by worktreePath if set, OR by branchName if set
|
||||
// Features with neither are considered unassigned (show on ALL worktrees)
|
||||
const featureBranch = f.branchName || "main";
|
||||
const hasWorktreeAssigned = f.worktreePath || f.branchName;
|
||||
// Check if feature matches the current worktree by branchName
|
||||
// Features without branchName are considered unassigned (show only on primary worktree)
|
||||
const featureBranch = f.branchName;
|
||||
|
||||
let matchesWorktree: boolean;
|
||||
if (!hasWorktreeAssigned) {
|
||||
// No worktree or branch assigned - show on ALL worktrees (unassigned)
|
||||
matchesWorktree = true;
|
||||
} else if (f.worktreePath) {
|
||||
// Has worktreePath - match by path (use pathsEqual for cross-platform compatibility)
|
||||
matchesWorktree = pathsEqual(f.worktreePath, effectiveWorktreePath);
|
||||
if (!featureBranch) {
|
||||
// No branch assigned - show only on primary worktree
|
||||
const isViewingPrimary = currentWorktreePath === null;
|
||||
matchesWorktree = isViewingPrimary;
|
||||
} else if (effectiveBranch === null) {
|
||||
// We're viewing main but branch hasn't been initialized yet
|
||||
// (worktrees disabled or haven't loaded yet).
|
||||
// Show features assigned to main/master branch since we're on the main worktree.
|
||||
matchesWorktree = featureBranch === "main" || featureBranch === "master";
|
||||
// Show features assigned to primary worktree's branch.
|
||||
matchesWorktree = projectPath
|
||||
? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch)
|
||||
: false;
|
||||
} else {
|
||||
// Has branchName but no worktreePath - match by branch name
|
||||
// Match by branch name
|
||||
matchesWorktree = featureBranch === effectiveBranch;
|
||||
}
|
||||
|
||||
@@ -101,7 +98,9 @@ export function useBoardColumnFeatures({
|
||||
}
|
||||
} else {
|
||||
// Unknown status, default to backlog
|
||||
map.backlog.push(f);
|
||||
if (matchesWorktree) {
|
||||
map.backlog.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -111,7 +110,29 @@ export function useBoardColumnFeatures({
|
||||
// Within the same dependency level, features are sorted by priority
|
||||
if (map.backlog.length > 0) {
|
||||
const { orderedFeatures } = resolveDependencies(map.backlog);
|
||||
map.backlog = orderedFeatures;
|
||||
|
||||
// Get all features to check blocking dependencies against
|
||||
const allFeatures = features;
|
||||
const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
|
||||
|
||||
// Sort blocked features to the end of the backlog
|
||||
// This keeps the dependency order within each group (unblocked/blocked)
|
||||
if (enableDependencyBlocking) {
|
||||
const unblocked: Feature[] = [];
|
||||
const blocked: Feature[] = [];
|
||||
|
||||
for (const f of orderedFeatures) {
|
||||
if (getBlockingDependencies(f, allFeatures).length > 0) {
|
||||
blocked.push(f);
|
||||
} else {
|
||||
unblocked.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
map.backlog = [...unblocked, ...blocked];
|
||||
} else {
|
||||
map.backlog = orderedFeatures;
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Feature } from "@/store/app-store";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { toast } from "sonner";
|
||||
import { COLUMNS, ColumnId } from "../constants";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
|
||||
interface UseBoardDragDropProps {
|
||||
features: Feature[];
|
||||
@@ -15,8 +14,6 @@ interface UseBoardDragDropProps {
|
||||
updates: Partial<Feature>
|
||||
) => Promise<void>;
|
||||
handleStartImplementation: (feature: Feature) => Promise<boolean>;
|
||||
projectPath: string | null; // Main project path
|
||||
onWorktreeCreated?: () => void; // Callback when a new worktree is created
|
||||
}
|
||||
|
||||
export function useBoardDragDrop({
|
||||
@@ -25,66 +22,12 @@ export function useBoardDragDrop({
|
||||
runningAutoTasks,
|
||||
persistFeatureUpdate,
|
||||
handleStartImplementation,
|
||||
projectPath,
|
||||
onWorktreeCreated,
|
||||
}: UseBoardDragDropProps) {
|
||||
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||
const { moveFeature, useWorktrees } = useAppStore();
|
||||
const { moveFeature } = useAppStore();
|
||||
|
||||
/**
|
||||
* Get or create the worktree path for a feature based on its branchName.
|
||||
* - If branchName is "main" or empty, returns the project path
|
||||
* - Otherwise, creates a worktree for that branch if needed
|
||||
*/
|
||||
const getOrCreateWorktreeForFeature = useCallback(
|
||||
async (feature: Feature): Promise<string | null> => {
|
||||
if (!projectPath) return null;
|
||||
|
||||
const branchName = feature.branchName || "main";
|
||||
|
||||
// If targeting main branch, use the project path directly
|
||||
if (branchName === "main" || branchName === "master") {
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
// For other branches, create a worktree if it doesn't exist
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.create) {
|
||||
console.error("[DragDrop] Worktree API not available");
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
// Try to create the worktree (will return existing if already exists)
|
||||
const result = await api.worktree.create(projectPath, branchName);
|
||||
|
||||
if (result.success && result.worktree) {
|
||||
console.log(
|
||||
`[DragDrop] Worktree ready for branch "${branchName}": ${result.worktree.path}`
|
||||
);
|
||||
if (result.worktree.isNew) {
|
||||
toast.success(`Worktree created for branch "${branchName}"`, {
|
||||
description: "A new worktree was created for this feature.",
|
||||
});
|
||||
}
|
||||
return result.worktree.path;
|
||||
} else {
|
||||
console.error("[DragDrop] Failed to create worktree:", result.error);
|
||||
toast.error("Failed to create worktree", {
|
||||
description: result.error || "Could not create worktree for this branch.",
|
||||
});
|
||||
return projectPath; // Fall back to project path
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[DragDrop] Error creating worktree:", error);
|
||||
toast.error("Error creating worktree", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return projectPath; // Fall back to project path
|
||||
}
|
||||
},
|
||||
[projectPath]
|
||||
);
|
||||
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
|
||||
// at execution time based on feature.branchName
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
@@ -118,17 +61,13 @@ export function useBoardDragDrop({
|
||||
// - Backlog items can always be dragged
|
||||
// - waiting_approval items can always be dragged (to allow manual verification via drag)
|
||||
// - verified items can always be dragged (to allow moving back to waiting_approval)
|
||||
// - skipTests (non-TDD) items can be dragged between in_progress and verified
|
||||
// - Non-skipTests (TDD) items that are in progress cannot be dragged (they are running)
|
||||
if (
|
||||
draggedFeature.status !== "backlog" &&
|
||||
draggedFeature.status !== "waiting_approval" &&
|
||||
draggedFeature.status !== "verified"
|
||||
) {
|
||||
// Only allow dragging in_progress if it's a skipTests feature and not currently running
|
||||
if (!draggedFeature.skipTests || isRunningTask) {
|
||||
// - in_progress items can be dragged (but not if they're currently running)
|
||||
// - Non-skipTests (TDD) items that are in progress cannot be dragged if they are running
|
||||
if (draggedFeature.status === "in_progress") {
|
||||
// Only allow dragging in_progress if it's not currently running
|
||||
if (isRunningTask) {
|
||||
console.log(
|
||||
"[Board] Cannot drag feature - TDD feature or currently running"
|
||||
"[Board] Cannot drag feature - currently running"
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -154,23 +93,13 @@ export function useBoardDragDrop({
|
||||
if (targetStatus === draggedFeature.status) return;
|
||||
|
||||
// Handle different drag scenarios
|
||||
// Note: Worktrees are created server-side at execution time based on feature.branchName
|
||||
if (draggedFeature.status === "backlog") {
|
||||
// From backlog
|
||||
if (targetStatus === "in_progress") {
|
||||
// Only create worktrees if the feature is enabled
|
||||
let worktreePath: string | null = null;
|
||||
if (useWorktrees) {
|
||||
// Get or create worktree based on the feature's assigned branch
|
||||
worktreePath = await getOrCreateWorktreeForFeature(draggedFeature);
|
||||
if (worktreePath) {
|
||||
await persistFeatureUpdate(featureId, { worktreePath });
|
||||
}
|
||||
// Refresh worktree selector after moving to in_progress
|
||||
onWorktreeCreated?.();
|
||||
}
|
||||
// Use helper function to handle concurrency check and start implementation
|
||||
// Pass feature with worktreePath so handleRunFeature uses the correct path
|
||||
await handleStartImplementation({ ...draggedFeature, worktreePath: worktreePath || undefined });
|
||||
// Server will derive workDir from feature.branchName
|
||||
await handleStartImplementation(draggedFeature);
|
||||
} else {
|
||||
moveFeature(featureId, targetStatus);
|
||||
persistFeatureUpdate(featureId, { status: targetStatus });
|
||||
@@ -195,11 +124,10 @@ export function useBoardDragDrop({
|
||||
} else if (targetStatus === "backlog") {
|
||||
// Allow moving waiting_approval cards back to backlog
|
||||
moveFeature(featureId, "backlog");
|
||||
// Clear justFinishedAt timestamp and worktreePath when moving back to backlog
|
||||
// Clear justFinishedAt timestamp when moving back to backlog
|
||||
persistFeatureUpdate(featureId, {
|
||||
status: "backlog",
|
||||
justFinishedAt: undefined,
|
||||
worktreePath: undefined,
|
||||
});
|
||||
toast.info("Feature moved to backlog", {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
@@ -208,13 +136,23 @@ export function useBoardDragDrop({
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
}
|
||||
} else if (draggedFeature.skipTests) {
|
||||
// skipTests feature being moved between in_progress and verified
|
||||
if (
|
||||
} else if (draggedFeature.status === "in_progress") {
|
||||
// Handle in_progress features being moved
|
||||
if (targetStatus === "backlog") {
|
||||
// Allow moving in_progress cards back to backlog
|
||||
moveFeature(featureId, "backlog");
|
||||
persistFeatureUpdate(featureId, { status: "backlog" });
|
||||
toast.info("Feature moved to backlog", {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
} else if (
|
||||
targetStatus === "verified" &&
|
||||
draggedFeature.status === "in_progress"
|
||||
draggedFeature.skipTests
|
||||
) {
|
||||
// Manual verify via drag
|
||||
// Manual verify via drag (only for skipTests features)
|
||||
moveFeature(featureId, "verified");
|
||||
persistFeatureUpdate(featureId, { status: "verified" });
|
||||
toast.success("Feature verified", {
|
||||
@@ -223,7 +161,10 @@ export function useBoardDragDrop({
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
} else if (
|
||||
}
|
||||
} else if (draggedFeature.skipTests) {
|
||||
// skipTests feature being moved between verified and waiting_approval
|
||||
if (
|
||||
targetStatus === "waiting_approval" &&
|
||||
draggedFeature.status === "verified"
|
||||
) {
|
||||
@@ -237,10 +178,9 @@ export function useBoardDragDrop({
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
} else if (targetStatus === "backlog") {
|
||||
// Allow moving skipTests cards back to backlog
|
||||
// Allow moving skipTests cards back to backlog (from verified)
|
||||
moveFeature(featureId, "backlog");
|
||||
// Clear worktreePath when moving back to backlog
|
||||
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
|
||||
persistFeatureUpdate(featureId, { status: "backlog" });
|
||||
toast.info("Feature moved to backlog", {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
0,
|
||||
@@ -263,8 +203,7 @@ export function useBoardDragDrop({
|
||||
} else if (targetStatus === "backlog") {
|
||||
// Allow moving verified cards back to backlog
|
||||
moveFeature(featureId, "backlog");
|
||||
// Clear worktreePath when moving back to backlog
|
||||
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
|
||||
persistFeatureUpdate(featureId, { status: "backlog" });
|
||||
toast.info("Feature moved to backlog", {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
0,
|
||||
@@ -280,9 +219,6 @@ export function useBoardDragDrop({
|
||||
moveFeature,
|
||||
persistFeatureUpdate,
|
||||
handleStartImplementation,
|
||||
getOrCreateWorktreeForFeature,
|
||||
onWorktreeCreated,
|
||||
useWorktrees,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||
|
||||
interface UseBoardEffectsProps {
|
||||
currentProject: { path: string; id: string } | null;
|
||||
@@ -28,8 +27,6 @@ export function useBoardEffects({
|
||||
isLoading,
|
||||
setFeaturesWithContext,
|
||||
}: UseBoardEffectsProps) {
|
||||
const autoMode = useAutoMode();
|
||||
|
||||
// Make current project available globally for modal
|
||||
useEffect(() => {
|
||||
if (currentProject) {
|
||||
@@ -101,8 +98,7 @@ export function useBoardEffects({
|
||||
const status = await api.autoMode.status(currentProject.path);
|
||||
if (status.success) {
|
||||
const projectId = currentProject.id;
|
||||
const { clearRunningTasks, addRunningTask, setAutoModeRunning } =
|
||||
useAppStore.getState();
|
||||
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
|
||||
|
||||
if (status.runningFeatures) {
|
||||
console.log(
|
||||
@@ -116,14 +112,6 @@ export function useBoardEffects({
|
||||
addRunningTask(projectId, featureId);
|
||||
});
|
||||
}
|
||||
|
||||
const isAutoModeRunning =
|
||||
status.autoLoopRunning ?? status.isRunning ?? false;
|
||||
console.log(
|
||||
"[Board] Syncing auto mode running state:",
|
||||
isAutoModeRunning
|
||||
);
|
||||
setAutoModeRunning(projectId, isAutoModeRunning);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Failed to sync running tasks:", error);
|
||||
|
||||
@@ -210,6 +210,11 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
.play()
|
||||
.catch((err) => console.warn("Could not play ding sound:", err));
|
||||
}
|
||||
} else if (event.type === "plan_approval_required") {
|
||||
// Reload features when plan is generated and requires approval
|
||||
// This ensures the feature card shows the "Approve Plan" button
|
||||
console.log("[Board] Plan approval required, reloading features...");
|
||||
loadFeatures();
|
||||
} else if (event.type === "auto_mode_error") {
|
||||
// Reload features when an error occurs (feature moved to waiting_approval)
|
||||
console.log(
|
||||
|
||||
@@ -13,7 +13,7 @@ import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { KanbanColumn, KanbanCard } from "./components";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { FastForward, Lightbulb, Trash2 } from "lucide-react";
|
||||
import { FastForward, Lightbulb, Archive } from "lucide-react";
|
||||
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { COLUMNS, ColumnId } from "./constants";
|
||||
|
||||
@@ -46,13 +46,15 @@ interface KanbanBoardProps {
|
||||
onCommit: (feature: Feature) => void;
|
||||
onComplete: (feature: Feature) => void;
|
||||
onImplement: (feature: Feature) => void;
|
||||
onViewPlan: (feature: Feature) => void;
|
||||
onApprovePlan: (feature: Feature) => void;
|
||||
featuresWithContext: Set<string>;
|
||||
runningAutoTasks: string[];
|
||||
shortcuts: ReturnType<typeof useKeyboardShortcutsConfig>;
|
||||
onStartNextFeatures: () => void;
|
||||
onShowSuggestions: () => void;
|
||||
suggestionsCount: number;
|
||||
onDeleteAllVerified: () => void;
|
||||
onArchiveAllVerified: () => void;
|
||||
}
|
||||
|
||||
export function KanbanBoard({
|
||||
@@ -76,13 +78,15 @@ export function KanbanBoard({
|
||||
onCommit,
|
||||
onComplete,
|
||||
onImplement,
|
||||
onViewPlan,
|
||||
onApprovePlan,
|
||||
featuresWithContext,
|
||||
runningAutoTasks,
|
||||
shortcuts,
|
||||
onStartNextFeatures,
|
||||
onShowSuggestions,
|
||||
suggestionsCount,
|
||||
onDeleteAllVerified,
|
||||
onArchiveAllVerified,
|
||||
}: KanbanBoardProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -114,12 +118,12 @@ export function KanbanBoard({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={onDeleteAllVerified}
|
||||
data-testid="delete-all-verified-button"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onArchiveAllVerified}
|
||||
data-testid="archive-all-verified-button"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
Delete All
|
||||
<Archive className="w-3 h-3 mr-1" />
|
||||
Archive All
|
||||
</Button>
|
||||
) : column.id === "backlog" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -188,6 +192,8 @@ export function KanbanBoard({
|
||||
onCommit={() => onCommit(feature)}
|
||||
onComplete={() => onComplete(feature)}
|
||||
onImplement={() => onImplement(feature)}
|
||||
onViewPlan={() => onViewPlan(feature)}
|
||||
onApprovePlan={() => onApprovePlan(feature)}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(
|
||||
feature.id
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
|
||||
interface BranchSelectorProps {
|
||||
useCurrentBranch: boolean;
|
||||
onUseCurrentBranchChange: (useCurrent: boolean) => void;
|
||||
branchName: string;
|
||||
onBranchNameChange: (branchName: string) => void;
|
||||
branchSuggestions: string[];
|
||||
currentBranch?: string;
|
||||
disabled?: boolean;
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
export function BranchSelector({
|
||||
useCurrentBranch,
|
||||
onUseCurrentBranchChange,
|
||||
branchName,
|
||||
onBranchNameChange,
|
||||
branchSuggestions,
|
||||
currentBranch,
|
||||
disabled = false,
|
||||
testIdPrefix = "branch",
|
||||
}: BranchSelectorProps) {
|
||||
// Validate: if "other branch" is selected, branch name is required
|
||||
const isBranchRequired = !useCurrentBranch;
|
||||
const hasError = isBranchRequired && !branchName.trim();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label id={`${testIdPrefix}-label`}>Target Branch</Label>
|
||||
<RadioGroup
|
||||
value={useCurrentBranch ? "current" : "other"}
|
||||
onValueChange={(value: string) => onUseCurrentBranchChange(value === "current")}
|
||||
disabled={disabled}
|
||||
data-testid={`${testIdPrefix}-radio-group`}
|
||||
aria-labelledby={`${testIdPrefix}-label`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="current" id={`${testIdPrefix}-current`} />
|
||||
<Label
|
||||
htmlFor={`${testIdPrefix}-current`}
|
||||
className="font-normal cursor-pointer"
|
||||
>
|
||||
Use current selected branch
|
||||
{currentBranch && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({currentBranch})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="other" id={`${testIdPrefix}-other`} />
|
||||
<Label
|
||||
htmlFor={`${testIdPrefix}-other`}
|
||||
className="font-normal cursor-pointer"
|
||||
>
|
||||
Other branch
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
{!useCurrentBranch && (
|
||||
<div className="ml-6 space-y-1">
|
||||
<BranchAutocomplete
|
||||
value={branchName}
|
||||
onChange={onBranchNameChange}
|
||||
branches={branchSuggestions}
|
||||
placeholder="Select or create branch..."
|
||||
data-testid={`${testIdPrefix}-input`}
|
||||
disabled={disabled}
|
||||
error={hasError}
|
||||
/>
|
||||
{hasError && (
|
||||
<p className="text-xs text-destructive">
|
||||
Branch name is required when "Other branch" is selected.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{disabled ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Branch cannot be changed after work has started.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{useCurrentBranch
|
||||
? "Work will be done in the currently selected branch. A worktree will be created if needed."
|
||||
: "Work will be done in this branch. A worktree will be created if needed."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,3 +4,5 @@ export * from "./thinking-level-selector";
|
||||
export * from "./profile-quick-select";
|
||||
export * from "./testing-tab-content";
|
||||
export * from "./priority-selector";
|
||||
export * from "./branch-selector";
|
||||
export * from "./planning-mode-selector";
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Zap, ClipboardList, FileText, ScrollText,
|
||||
Loader2, Check, Eye, RefreshCw, Sparkles
|
||||
} from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PlanSpec } from "@/store/app-store";
|
||||
|
||||
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type { ParsedTask, PlanSpec } from "@/store/app-store";
|
||||
|
||||
interface PlanningModeSelectorProps {
|
||||
mode: PlanningMode;
|
||||
onModeChange: (mode: PlanningMode) => void;
|
||||
requireApproval?: boolean;
|
||||
onRequireApprovalChange?: (require: boolean) => void;
|
||||
planSpec?: PlanSpec;
|
||||
onGenerateSpec?: () => void;
|
||||
onApproveSpec?: () => void;
|
||||
onRejectSpec?: () => void;
|
||||
onViewSpec?: () => void;
|
||||
isGenerating?: boolean;
|
||||
featureDescription?: string; // For auto-generation context
|
||||
testIdPrefix?: string;
|
||||
compact?: boolean; // For use in dialogs vs settings
|
||||
}
|
||||
|
||||
const modes = [
|
||||
{
|
||||
value: 'skip' as const,
|
||||
label: 'Skip',
|
||||
description: 'Direct implementation, no upfront planning',
|
||||
icon: Zap,
|
||||
color: 'text-emerald-500',
|
||||
bgColor: 'bg-emerald-500/10',
|
||||
borderColor: 'border-emerald-500/30',
|
||||
badge: 'Default',
|
||||
},
|
||||
{
|
||||
value: 'lite' as const,
|
||||
label: 'Lite',
|
||||
description: 'Think through approach, create task list',
|
||||
icon: ClipboardList,
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-500/10',
|
||||
borderColor: 'border-blue-500/30',
|
||||
},
|
||||
{
|
||||
value: 'spec' as const,
|
||||
label: 'Spec',
|
||||
description: 'Generate spec with acceptance criteria',
|
||||
icon: FileText,
|
||||
color: 'text-purple-500',
|
||||
bgColor: 'bg-purple-500/10',
|
||||
borderColor: 'border-purple-500/30',
|
||||
badge: 'Approval Required',
|
||||
},
|
||||
{
|
||||
value: 'full' as const,
|
||||
label: 'Full',
|
||||
description: 'Comprehensive spec with phased plan',
|
||||
icon: ScrollText,
|
||||
color: 'text-amber-500',
|
||||
bgColor: 'bg-amber-500/10',
|
||||
borderColor: 'border-amber-500/30',
|
||||
badge: 'Approval Required',
|
||||
},
|
||||
];
|
||||
|
||||
export function PlanningModeSelector({
|
||||
mode,
|
||||
onModeChange,
|
||||
requireApproval,
|
||||
onRequireApprovalChange,
|
||||
planSpec,
|
||||
onGenerateSpec,
|
||||
onApproveSpec,
|
||||
onRejectSpec,
|
||||
onViewSpec,
|
||||
isGenerating = false,
|
||||
featureDescription,
|
||||
testIdPrefix = 'planning',
|
||||
compact = false,
|
||||
}: PlanningModeSelectorProps) {
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const selectedMode = modes.find(m => m.value === mode);
|
||||
const requiresApproval = mode === 'spec' || mode === 'full';
|
||||
const canGenerate = requiresApproval && featureDescription?.trim() && !isGenerating;
|
||||
const hasSpec = planSpec && planSpec.content;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header with icon */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"w-8 h-8 rounded-lg flex items-center justify-center",
|
||||
selectedMode?.bgColor || "bg-muted"
|
||||
)}>
|
||||
{selectedMode && <selectedMode.icon className={cn("h-4 w-4", selectedMode.color)} />}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Planning Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose how much upfront planning before implementation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick action buttons when spec/full mode */}
|
||||
{requiresApproval && hasSpec && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onViewSpec}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mode Selection Cards */}
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2",
|
||||
compact ? "grid-cols-2" : "grid-cols-2 sm:grid-cols-4"
|
||||
)}
|
||||
>
|
||||
{modes.map((m) => {
|
||||
const isSelected = mode === m.value;
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
onClick={() => onModeChange(m.value)}
|
||||
data-testid={`${testIdPrefix}-mode-${m.value}`}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 p-3 rounded-xl cursor-pointer transition-all duration-200",
|
||||
"border-2 hover:border-primary/50",
|
||||
isSelected
|
||||
? cn("border-primary", m.bgColor)
|
||||
: "border-border/50 bg-card/50 hover:bg-accent/30"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center transition-colors",
|
||||
isSelected ? m.bgColor : "bg-muted"
|
||||
)}>
|
||||
<Icon className={cn(
|
||||
"h-5 w-5 transition-colors",
|
||||
isSelected ? m.color : "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className={cn(
|
||||
"font-medium text-sm",
|
||||
isSelected ? "text-foreground" : "text-muted-foreground"
|
||||
)}>
|
||||
{m.label}
|
||||
</span>
|
||||
{m.badge && (
|
||||
<span className={cn(
|
||||
"text-[9px] px-1 py-0.5 rounded font-medium",
|
||||
m.badge === 'Default'
|
||||
? "bg-emerald-500/15 text-emerald-500"
|
||||
: "bg-amber-500/15 text-amber-500"
|
||||
)}>
|
||||
{m.badge === 'Default' ? 'Default' : 'Review'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!compact && (
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{m.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Require Approval Checkbox - Only show when mode !== 'skip' */}
|
||||
{mode !== 'skip' && onRequireApprovalChange && (
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
|
||||
<Checkbox
|
||||
id="require-approval"
|
||||
checked={requireApproval}
|
||||
onCheckedChange={(checked) => onRequireApprovalChange(checked === true)}
|
||||
data-testid={`${testIdPrefix}-require-approval-checkbox`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="require-approval"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
Manually approve plan before implementation
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spec Preview/Actions Panel - Only for spec/full modes */}
|
||||
{requiresApproval && (
|
||||
<div className={cn(
|
||||
"rounded-xl border transition-all duration-300",
|
||||
planSpec?.status === 'approved'
|
||||
? "border-emerald-500/30 bg-emerald-500/5"
|
||||
: planSpec?.status === 'generated'
|
||||
? "border-amber-500/30 bg-amber-500/5"
|
||||
: "border-border/50 bg-muted/30"
|
||||
)}>
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="text-sm text-muted-foreground">Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...</span>
|
||||
</>
|
||||
) : planSpec?.status === 'approved' ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-sm text-emerald-500 font-medium">Spec Approved</span>
|
||||
</>
|
||||
) : planSpec?.status === 'generated' ? (
|
||||
<>
|
||||
<Eye className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-sm text-amber-500 font-medium">Spec Ready for Review</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Spec will be generated when feature starts
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-generate toggle area */}
|
||||
{!planSpec?.status && canGenerate && onGenerateSpec && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onGenerateSpec}
|
||||
disabled={isGenerating}
|
||||
className="h-7"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5 mr-1" />
|
||||
Pre-generate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Spec content preview */}
|
||||
{hasSpec && (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="w-full justify-between h-8 px-2"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{showPreview ? 'Hide Preview' : 'Show Preview'}
|
||||
</span>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{showPreview && (
|
||||
<div className="rounded-lg bg-background/80 border border-border/50 p-3 max-h-48 overflow-y-auto">
|
||||
<pre className="text-xs text-muted-foreground whitespace-pre-wrap font-mono">
|
||||
{planSpec.content}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons when spec is generated */}
|
||||
{planSpec?.status === 'generated' && (
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/30">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRejectSpec}
|
||||
className="flex-1"
|
||||
>
|
||||
Request Changes
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onApproveSpec}
|
||||
className="flex-1 bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 mr-1" />
|
||||
Approve Spec
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Regenerate option when approved */}
|
||||
{planSpec?.status === 'approved' && onGenerateSpec && (
|
||||
<div className="flex items-center justify-end pt-2 border-t border-border/30">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onGenerateSpec}
|
||||
className="h-7"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1" />
|
||||
Regenerate
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info text for non-approval modes */}
|
||||
{!requiresApproval && (
|
||||
<p className="text-xs text-muted-foreground bg-muted/30 rounded-lg p-3">
|
||||
{mode === 'skip'
|
||||
? "The agent will start implementing immediately without creating a plan or spec."
|
||||
: "The agent will create a planning outline before implementing, but won't wait for approval."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -169,7 +169,8 @@ export function WorktreeActionsDropdown({
|
||||
Commit Changes
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(worktree.branch !== "main" || worktree.hasChanges) && (
|
||||
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
|
||||
{(!worktree.isMain || worktree.hasChanges) && (
|
||||
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
|
||||
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
||||
Create Pull Request
|
||||
|
||||
@@ -1,46 +1,34 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { pathsEqual } from "@/lib/utils";
|
||||
import type { WorktreeInfo, FeatureInfo } from "../types";
|
||||
|
||||
interface UseRunningFeaturesOptions {
|
||||
projectPath: string;
|
||||
runningFeatureIds: string[];
|
||||
features: FeatureInfo[];
|
||||
getWorktreeKey: (worktree: WorktreeInfo) => string;
|
||||
}
|
||||
|
||||
export function useRunningFeatures({
|
||||
projectPath,
|
||||
runningFeatureIds,
|
||||
features,
|
||||
getWorktreeKey,
|
||||
}: UseRunningFeaturesOptions) {
|
||||
const hasRunningFeatures = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
if (runningFeatureIds.length === 0) return false;
|
||||
|
||||
const worktreeKey = getWorktreeKey(worktree);
|
||||
|
||||
return runningFeatureIds.some((featureId) => {
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
if (!feature) return false;
|
||||
|
||||
if (feature.worktreePath) {
|
||||
if (worktree.isMain) {
|
||||
return pathsEqual(feature.worktreePath, projectPath);
|
||||
}
|
||||
return pathsEqual(feature.worktreePath, worktreeKey);
|
||||
}
|
||||
|
||||
// Match by branchName only (worktreePath is no longer stored)
|
||||
if (feature.branchName) {
|
||||
return worktree.branch === feature.branchName;
|
||||
}
|
||||
|
||||
// No branch assigned - belongs to main worktree
|
||||
return worktree.isMain;
|
||||
});
|
||||
},
|
||||
[runningFeatureIds, features, projectPath, getWorktreeKey]
|
||||
[runningFeatureIds, features]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { toast } from "sonner";
|
||||
import type { WorktreeInfo } from "../types";
|
||||
|
||||
interface UseWorktreeActionsOptions {
|
||||
fetchWorktrees: () => Promise<void>;
|
||||
fetchWorktrees: () => Promise<Array<{ path: string; branch: string }> | undefined>;
|
||||
fetchBranches: (worktreePath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,10 @@ import type { WorktreeInfo } from "../types";
|
||||
interface UseWorktreesOptions {
|
||||
projectPath: string;
|
||||
refreshTrigger?: number;
|
||||
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||
}
|
||||
|
||||
export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOptions) {
|
||||
export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktrees }: UseWorktreesOptions) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
|
||||
|
||||
@@ -33,8 +34,11 @@ export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOp
|
||||
setWorktrees(result.worktrees);
|
||||
setWorktreesInStore(projectPath, result.worktrees);
|
||||
}
|
||||
// Return removed worktrees so they can be handled by the caller
|
||||
return result.removedWorktrees;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch worktrees:", error);
|
||||
return undefined;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -46,9 +50,13 @@ export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOp
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshTrigger > 0) {
|
||||
fetchWorktrees();
|
||||
fetchWorktrees().then((removedWorktrees) => {
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [refreshTrigger, fetchWorktrees]);
|
||||
}, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]);
|
||||
|
||||
useEffect(() => {
|
||||
if (worktrees.length > 0) {
|
||||
@@ -58,6 +66,8 @@ export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOp
|
||||
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
|
||||
|
||||
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
|
||||
// Find the primary worktree and get its branch name
|
||||
// Fallback to "main" only if worktrees haven't loaded yet
|
||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||
const mainBranch = mainWorktree?.branch || "main";
|
||||
setCurrentWorktree(projectPath, null, mainBranch);
|
||||
|
||||
@@ -22,7 +22,6 @@ export interface DevServerInfo {
|
||||
|
||||
export interface FeatureInfo {
|
||||
id: string;
|
||||
worktreePath?: string;
|
||||
branchName?: string;
|
||||
}
|
||||
|
||||
@@ -33,6 +32,7 @@ export interface WorktreePanelProps {
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||
runningFeatureIds?: string[];
|
||||
features?: FeatureInfo[];
|
||||
refreshTrigger?: number;
|
||||
|
||||
@@ -20,6 +20,7 @@ export function WorktreePanel({
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onCreateBranch,
|
||||
onRemovedWorktrees,
|
||||
runningFeatureIds = [],
|
||||
features = [],
|
||||
refreshTrigger = 0,
|
||||
@@ -32,7 +33,7 @@ export function WorktreePanel({
|
||||
useWorktreesEnabled,
|
||||
fetchWorktrees,
|
||||
handleSelectWorktree,
|
||||
} = useWorktrees({ projectPath, refreshTrigger });
|
||||
} = useWorktrees({ projectPath, refreshTrigger, onRemovedWorktrees });
|
||||
|
||||
const {
|
||||
isStartingDevServer,
|
||||
@@ -73,10 +74,8 @@ export function WorktreePanel({
|
||||
const { defaultEditorName } = useDefaultEditor();
|
||||
|
||||
const { hasRunningFeatures } = useRunningFeatures({
|
||||
projectPath,
|
||||
runningFeatureIds,
|
||||
features,
|
||||
getWorktreeKey,
|
||||
});
|
||||
|
||||
const isWorktreeSelected = (worktree: WorktreeInfo) => {
|
||||
@@ -162,7 +161,12 @@ export function WorktreePanel({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={fetchWorktrees}
|
||||
onClick={async () => {
|
||||
const removedWorktrees = await fetchWorktrees();
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
|
||||
@@ -211,16 +211,20 @@ export function ContextView() {
|
||||
// Write text file with content (or empty if no content)
|
||||
await api.writeFile(filePath, newFileContent);
|
||||
}
|
||||
|
||||
|
||||
// Only reload files on success
|
||||
await loadContextFiles();
|
||||
} catch (error) {
|
||||
console.error("Failed to add file:", error);
|
||||
// Optionally show error toast to user here
|
||||
} finally {
|
||||
// Close dialog and reset state
|
||||
setIsAddDialogOpen(false);
|
||||
setNewFileName("");
|
||||
setNewFileType("text");
|
||||
setUploadedImageData(null);
|
||||
setNewFileContent("");
|
||||
setIsDropHovering(false);
|
||||
await loadContextFiles();
|
||||
} catch (error) {
|
||||
console.error("Failed to add file:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@ export function SettingsView() {
|
||||
setMuteDoneSound,
|
||||
currentProject,
|
||||
moveProjectToTrash,
|
||||
defaultPlanningMode,
|
||||
setDefaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
setDefaultRequirePlanApproval,
|
||||
} = useAppStore();
|
||||
|
||||
// Convert electron Project to settings-view Project type
|
||||
@@ -121,10 +125,14 @@ export function SettingsView() {
|
||||
defaultSkipTests={defaultSkipTests}
|
||||
enableDependencyBlocking={enableDependencyBlocking}
|
||||
useWorktrees={useWorktrees}
|
||||
defaultPlanningMode={defaultPlanningMode}
|
||||
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||
onShowProfilesOnlyChange={setShowProfilesOnly}
|
||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||
onUseWorktreesChange={setUseWorktrees}
|
||||
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
||||
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||
/>
|
||||
);
|
||||
case "danger":
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { FlaskConical, Settings2, TestTube, GitBranch, AlertCircle } from "lucide-react";
|
||||
import {
|
||||
FlaskConical, Settings2, TestTube, GitBranch, AlertCircle,
|
||||
Zap, ClipboardList, FileText, ScrollText, ShieldCheck
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||
|
||||
interface FeatureDefaultsSectionProps {
|
||||
showProfilesOnly: boolean;
|
||||
defaultSkipTests: boolean;
|
||||
enableDependencyBlocking: boolean;
|
||||
useWorktrees: boolean;
|
||||
defaultPlanningMode: PlanningMode;
|
||||
defaultRequirePlanApproval: boolean;
|
||||
onShowProfilesOnlyChange: (value: boolean) => void;
|
||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||
onUseWorktreesChange: (value: boolean) => void;
|
||||
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
||||
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function FeatureDefaultsSection({
|
||||
@@ -19,10 +35,14 @@ export function FeatureDefaultsSection({
|
||||
defaultSkipTests,
|
||||
enableDependencyBlocking,
|
||||
useWorktrees,
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
onShowProfilesOnlyChange,
|
||||
onDefaultSkipTestsChange,
|
||||
onEnableDependencyBlockingChange,
|
||||
onUseWorktreesChange,
|
||||
onDefaultPlanningModeChange,
|
||||
onDefaultRequirePlanApprovalChange,
|
||||
}: FeatureDefaultsSectionProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -47,6 +67,108 @@ export function FeatureDefaultsSection({
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Planning Mode Default */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<div className={cn(
|
||||
"w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0",
|
||||
defaultPlanningMode === 'skip' ? "bg-emerald-500/10" :
|
||||
defaultPlanningMode === 'lite' ? "bg-blue-500/10" :
|
||||
defaultPlanningMode === 'spec' ? "bg-purple-500/10" :
|
||||
"bg-amber-500/10"
|
||||
)}>
|
||||
{defaultPlanningMode === 'skip' && <Zap className="w-5 h-5 text-emerald-500" />}
|
||||
{defaultPlanningMode === 'lite' && <ClipboardList className="w-5 h-5 text-blue-500" />}
|
||||
{defaultPlanningMode === 'spec' && <FileText className="w-5 h-5 text-purple-500" />}
|
||||
{defaultPlanningMode === 'full' && <ScrollText className="w-5 h-5 text-amber-500" />}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">
|
||||
Default Planning Mode
|
||||
</Label>
|
||||
<Select
|
||||
value={defaultPlanningMode}
|
||||
onValueChange={(v: string) => onDefaultPlanningModeChange(v as PlanningMode)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-[160px] h-8"
|
||||
data-testid="default-planning-mode-select"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skip">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-3.5 w-3.5 text-emerald-500" />
|
||||
<span>Skip</span>
|
||||
<span className="text-[10px] text-muted-foreground">(Default)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="lite">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-3.5 w-3.5 text-blue-500" />
|
||||
<span>Lite Planning</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="spec">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-3.5 w-3.5 text-purple-500" />
|
||||
<span>Spec (Lite SDD)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="full">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="h-3.5 w-3.5 text-amber-500" />
|
||||
<span>Full (SDD)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
{defaultPlanningMode === 'skip' && "Jump straight to implementation without upfront planning."}
|
||||
{defaultPlanningMode === 'lite' && "Create a quick planning outline with tasks before building."}
|
||||
{defaultPlanningMode === 'spec' && "Generate a specification with acceptance criteria for approval."}
|
||||
{defaultPlanningMode === 'full' && "Create comprehensive spec with phased implementation plan."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Require Plan Approval Setting - only show when not skip */}
|
||||
{defaultPlanningMode !== 'skip' && (
|
||||
<>
|
||||
<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-require-plan-approval"
|
||||
checked={defaultRequirePlanApproval}
|
||||
onCheckedChange={(checked) =>
|
||||
onDefaultRequirePlanApprovalChange(checked === true)
|
||||
}
|
||||
className="mt-1"
|
||||
data-testid="default-require-plan-approval-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="default-require-plan-approval"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4 text-brand-500" />
|
||||
Require manual plan approval by default
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
When enabled, the agent will pause after generating a plan and wait for you to
|
||||
review, edit, and approve before starting implementation. You can also view the
|
||||
plan from the feature card.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border/30" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Separator */}
|
||||
{defaultPlanningMode === 'skip' && <div className="border-t border-border/30" />}
|
||||
|
||||
{/* Profiles Only Setting */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
|
||||
@@ -4,6 +4,11 @@ import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import type { AutoModeEvent } from "@/types/electron";
|
||||
|
||||
// Type guard for plan_approval_required event
|
||||
function isPlanApprovalEvent(event: AutoModeEvent): event is Extract<AutoModeEvent, { type: "plan_approval_required" }> {
|
||||
return event.type === "plan_approval_required";
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing auto mode (scoped per project)
|
||||
*/
|
||||
@@ -13,22 +18,22 @@ export function useAutoMode() {
|
||||
setAutoModeRunning,
|
||||
addRunningTask,
|
||||
removeRunningTask,
|
||||
clearRunningTasks,
|
||||
currentProject,
|
||||
addAutoModeActivity,
|
||||
maxConcurrency,
|
||||
projects,
|
||||
setPendingPlanApproval,
|
||||
} = useAppStore(
|
||||
useShallow((state) => ({
|
||||
autoModeByProject: state.autoModeByProject,
|
||||
setAutoModeRunning: state.setAutoModeRunning,
|
||||
addRunningTask: state.addRunningTask,
|
||||
removeRunningTask: state.removeRunningTask,
|
||||
clearRunningTasks: state.clearRunningTasks,
|
||||
currentProject: state.currentProject,
|
||||
addAutoModeActivity: state.addAutoModeActivity,
|
||||
maxConcurrency: state.maxConcurrency,
|
||||
projects: state.projects,
|
||||
setPendingPlanApproval: state.setPendingPlanApproval,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -119,36 +124,22 @@ export function useAutoMode() {
|
||||
}
|
||||
break;
|
||||
|
||||
case "auto_mode_stopped":
|
||||
// Auto mode was explicitly stopped (by user or error)
|
||||
setAutoModeRunning(eventProjectId, false);
|
||||
clearRunningTasks(eventProjectId);
|
||||
console.log("[AutoMode] Auto mode stopped");
|
||||
break;
|
||||
|
||||
case "auto_mode_started":
|
||||
// Auto mode started - ensure UI reflects running state
|
||||
console.log("[AutoMode] Auto mode started:", event.message);
|
||||
break;
|
||||
|
||||
case "auto_mode_idle":
|
||||
// Auto mode is running but has no pending features to pick up
|
||||
// This is NOT a stop - auto mode keeps running and will pick up new features
|
||||
console.log("[AutoMode] Auto mode idle - waiting for new features");
|
||||
break;
|
||||
|
||||
case "auto_mode_complete":
|
||||
// Legacy event - only handle if it looks like a stop (for backwards compatibility)
|
||||
if (event.message === "Auto mode stopped") {
|
||||
setAutoModeRunning(eventProjectId, false);
|
||||
clearRunningTasks(eventProjectId);
|
||||
console.log("[AutoMode] Auto mode stopped (legacy event)");
|
||||
}
|
||||
break;
|
||||
|
||||
case "auto_mode_error":
|
||||
console.error("[AutoMode Error]", event.error);
|
||||
if (event.featureId && event.error) {
|
||||
// Check if this is a user-initiated cancellation or abort (not a real error)
|
||||
if (event.errorType === "cancellation" || event.errorType === "abort") {
|
||||
// User cancelled/aborted the feature - just log as info, not an error
|
||||
console.log("[AutoMode] Feature cancelled/aborted:", event.error);
|
||||
// Remove from running tasks
|
||||
if (eventProjectId) {
|
||||
removeRunningTask(eventProjectId, event.featureId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Real error - log and show to user
|
||||
console.error("[AutoMode Error]", event.error);
|
||||
|
||||
// Check for authentication errors and provide a more helpful message
|
||||
const isAuthError =
|
||||
event.errorType === "authentication" ||
|
||||
@@ -210,6 +201,124 @@ export function useAutoMode() {
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "plan_approval_required":
|
||||
// Plan requires user approval before proceeding
|
||||
if (isPlanApprovalEvent(event)) {
|
||||
console.log(
|
||||
`[AutoMode] Plan approval required for ${event.featureId}`
|
||||
);
|
||||
setPendingPlanApproval({
|
||||
featureId: event.featureId,
|
||||
projectPath: event.projectPath || currentProject?.path || "",
|
||||
planContent: event.planContent,
|
||||
planningMode: event.planningMode,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "planning_started":
|
||||
// Log when planning phase begins
|
||||
if (event.featureId && event.mode && event.message) {
|
||||
console.log(
|
||||
`[AutoMode] Planning started (${event.mode}) for ${event.featureId}`
|
||||
);
|
||||
addAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: "planning",
|
||||
message: event.message,
|
||||
phase: "planning",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "plan_approved":
|
||||
// Log when plan is approved by user
|
||||
if (event.featureId) {
|
||||
console.log(`[AutoMode] Plan approved for ${event.featureId}`);
|
||||
addAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: "action",
|
||||
message: event.hasEdits
|
||||
? "Plan approved with edits, starting implementation..."
|
||||
: "Plan approved, starting implementation...",
|
||||
phase: "action",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "plan_auto_approved":
|
||||
// Log when plan is auto-approved (requirePlanApproval=false)
|
||||
if (event.featureId) {
|
||||
console.log(`[AutoMode] Plan auto-approved for ${event.featureId}`);
|
||||
addAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: "action",
|
||||
message: "Plan auto-approved, starting implementation...",
|
||||
phase: "action",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "plan_revision_requested":
|
||||
// Log when user requests plan revision with feedback
|
||||
if (event.featureId) {
|
||||
const revisionEvent = event as Extract<AutoModeEvent, { type: "plan_revision_requested" }>;
|
||||
console.log(`[AutoMode] Plan revision requested for ${event.featureId} (v${revisionEvent.planVersion})`);
|
||||
addAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: "planning",
|
||||
message: `Revising plan based on feedback (v${revisionEvent.planVersion})...`,
|
||||
phase: "planning",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "auto_mode_task_started":
|
||||
// Task started - show which task is being worked on
|
||||
if (event.featureId && "taskId" in event && "taskDescription" in event) {
|
||||
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
|
||||
console.log(
|
||||
`[AutoMode] Task ${taskEvent.taskId} started for ${event.featureId}: ${taskEvent.taskDescription}`
|
||||
);
|
||||
addAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: "progress",
|
||||
message: `▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "auto_mode_task_complete":
|
||||
// Task completed - show progress
|
||||
if (event.featureId && "taskId" in event) {
|
||||
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
|
||||
console.log(
|
||||
`[AutoMode] Task ${taskEvent.taskId} completed for ${event.featureId} (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})`
|
||||
);
|
||||
addAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: "progress",
|
||||
message: `✓ ${taskEvent.taskId} done (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "auto_mode_phase_complete":
|
||||
// Phase completed (for full mode with phased tasks)
|
||||
if (event.featureId && "phaseNumber" in event) {
|
||||
const phaseEvent = event as Extract<AutoModeEvent, { type: "auto_mode_phase_complete" }>;
|
||||
console.log(
|
||||
`[AutoMode] Phase ${phaseEvent.phaseNumber} completed for ${event.featureId}`
|
||||
);
|
||||
addAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: "action",
|
||||
message: `Phase ${phaseEvent.phaseNumber} completed`,
|
||||
phase: "action",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -218,128 +327,36 @@ export function useAutoMode() {
|
||||
projectId,
|
||||
addRunningTask,
|
||||
removeRunningTask,
|
||||
clearRunningTasks,
|
||||
setAutoModeRunning,
|
||||
addAutoModeActivity,
|
||||
getProjectIdFromPath,
|
||||
setPendingPlanApproval,
|
||||
currentProject?.path,
|
||||
]);
|
||||
|
||||
// Restore auto mode for all projects that were running when app was closed
|
||||
// This runs once on mount to restart auto loops for persisted running states
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
// Find all projects that have auto mode marked as running
|
||||
const projectsToRestart: Array<{ projectId: string; projectPath: string }> =
|
||||
[];
|
||||
for (const [projectId, state] of Object.entries(autoModeByProject)) {
|
||||
if (state.isRunning) {
|
||||
// Find the project path for this project ID
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
if (project) {
|
||||
projectsToRestart.push({ projectId, projectPath: project.path });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restart auto mode for each project
|
||||
for (const { projectId, projectPath } of projectsToRestart) {
|
||||
console.log(`[AutoMode] Restoring auto mode for project: ${projectPath}`);
|
||||
api.autoMode
|
||||
.start(projectPath, maxConcurrency)
|
||||
.then((result) => {
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
`[AutoMode] Failed to restore auto mode for ${projectPath}:`,
|
||||
result.error
|
||||
);
|
||||
// Mark as not running if we couldn't restart
|
||||
setAutoModeRunning(projectId, false);
|
||||
} else {
|
||||
console.log(`[AutoMode] Restored auto mode for ${projectPath}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`[AutoMode] Error restoring auto mode for ${projectPath}:`,
|
||||
error
|
||||
);
|
||||
setAutoModeRunning(projectId, false);
|
||||
});
|
||||
}
|
||||
// Only run once on mount - intentionally empty dependency array
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Start auto mode
|
||||
const start = useCallback(async () => {
|
||||
// Start auto mode - UI only, feature pickup is handled in board-view.tsx
|
||||
const start = useCallback(() => {
|
||||
if (!currentProject) {
|
||||
console.error("No project selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) {
|
||||
throw new Error("Auto mode API not available");
|
||||
}
|
||||
|
||||
const result = await api.autoMode.start(
|
||||
currentProject.path,
|
||||
maxConcurrency
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setAutoModeRunning(currentProject.id, true);
|
||||
console.log(
|
||||
`[AutoMode] Started successfully with maxConcurrency: ${maxConcurrency}`
|
||||
);
|
||||
} else {
|
||||
console.error("[AutoMode] Failed to start:", result.error);
|
||||
throw new Error(result.error || "Failed to start auto mode");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[AutoMode] Error starting:", error);
|
||||
if (currentProject) {
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
setAutoModeRunning(currentProject.id, true);
|
||||
console.log(`[AutoMode] Started with maxConcurrency: ${maxConcurrency}`);
|
||||
}, [currentProject, setAutoModeRunning, maxConcurrency]);
|
||||
|
||||
// Stop auto mode - only turns off the toggle, running tasks continue
|
||||
const stop = useCallback(async () => {
|
||||
// Stop auto mode - UI only, running tasks continue until natural completion
|
||||
const stop = useCallback(() => {
|
||||
if (!currentProject) {
|
||||
console.error("No project selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) {
|
||||
throw new Error("Auto mode API not available");
|
||||
}
|
||||
|
||||
const result = await api.autoMode.stop(currentProject.path);
|
||||
|
||||
if (result.success) {
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
// NOTE: We intentionally do NOT clear running tasks here.
|
||||
// Stopping auto mode only turns off the toggle to prevent new features
|
||||
// from being picked up. Running tasks will complete naturally and be
|
||||
// removed via the auto_mode_feature_complete event.
|
||||
console.log(
|
||||
"[AutoMode] Stopped successfully - running tasks will continue"
|
||||
);
|
||||
} else {
|
||||
console.error("[AutoMode] Failed to stop:", result.error);
|
||||
throw new Error(result.error || "Failed to stop auto mode");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[AutoMode] Error stopping:", error);
|
||||
throw error;
|
||||
}
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
// NOTE: We intentionally do NOT clear running tasks here.
|
||||
// Stopping auto mode only turns off the toggle to prevent new features
|
||||
// from being picked up. Running tasks will complete naturally and be
|
||||
// removed via the auto_mode_feature_complete event.
|
||||
console.log("[AutoMode] Stopped - running tasks will continue");
|
||||
}, [currentProject, setAutoModeRunning]);
|
||||
|
||||
// Stop a specific feature
|
||||
|
||||
@@ -83,7 +83,6 @@ export interface RunningAgentsResult {
|
||||
success: boolean;
|
||||
runningAgents?: RunningAgent[];
|
||||
totalCount?: number;
|
||||
autoLoopRunning?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -220,7 +219,6 @@ export interface AutoModeAPI {
|
||||
status: (projectPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
isRunning?: boolean;
|
||||
autoLoopRunning?: boolean; // Backend uses this name instead of isRunning
|
||||
currentFeatureId?: string | null;
|
||||
runningFeatures?: string[];
|
||||
runningProjects?: string[];
|
||||
@@ -261,6 +259,13 @@ export interface AutoModeAPI {
|
||||
featureId: string,
|
||||
worktreePath?: string
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
approvePlan: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
approved: boolean,
|
||||
editedPlan?: string,
|
||||
feedback?: string
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
onEvent: (callback: (event: AutoModeEvent) => void) => () => void;
|
||||
}
|
||||
|
||||
@@ -1444,7 +1449,6 @@ function createMockAutoModeAPI(): AutoModeAPI {
|
||||
return {
|
||||
success: true,
|
||||
isRunning: mockAutoModeRunning,
|
||||
autoLoopRunning: mockAutoModeRunning,
|
||||
currentFeatureId: mockAutoModeRunning ? "feature-0" : null,
|
||||
runningFeatures: Array.from(mockRunningFeatures),
|
||||
runningCount: mockRunningFeatures.size,
|
||||
@@ -1698,6 +1702,23 @@ function createMockAutoModeAPI(): AutoModeAPI {
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
approvePlan: async (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
approved: boolean,
|
||||
editedPlan?: string,
|
||||
feedback?: string
|
||||
) => {
|
||||
console.log("[Mock] Plan approval:", {
|
||||
projectPath,
|
||||
featureId,
|
||||
approved,
|
||||
editedPlan: editedPlan ? "[edited]" : undefined,
|
||||
feedback,
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
||||
mockAutoModeCallbacks.push(callback);
|
||||
return () => {
|
||||
@@ -2595,7 +2616,6 @@ function createMockRunningAgentsAPI(): RunningAgentsAPI {
|
||||
success: true,
|
||||
runningAgents,
|
||||
totalCount: runningAgents.length,
|
||||
autoLoopRunning: mockAutoModeRunning,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -576,6 +576,20 @@ export class HttpApiClient implements ElectronAPI {
|
||||
featureId,
|
||||
worktreePath,
|
||||
}),
|
||||
approvePlan: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
approved: boolean,
|
||||
editedPlan?: string,
|
||||
feedback?: string
|
||||
) =>
|
||||
this.post("/api/auto-mode/approve-plan", {
|
||||
projectPath,
|
||||
featureId,
|
||||
approved,
|
||||
editedPlan,
|
||||
feedback,
|
||||
}),
|
||||
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
||||
return this.subscribeToEvent(
|
||||
"auto-mode:event",
|
||||
@@ -738,7 +752,6 @@ export class HttpApiClient implements ElectronAPI {
|
||||
isAutoMode: boolean;
|
||||
}>;
|
||||
totalCount?: number;
|
||||
autoLoopRunning?: boolean;
|
||||
error?: string;
|
||||
}> => this.get("/api/running-agents"),
|
||||
};
|
||||
|
||||
@@ -263,6 +263,9 @@ export type ModelProvider = "claude";
|
||||
// Thinking level (budget_tokens) options
|
||||
export type ThinkingLevel = "none" | "low" | "medium" | "high" | "ultrathink";
|
||||
|
||||
// Planning mode for feature specifications
|
||||
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||
|
||||
// AI Provider Profile - user-defined presets for model configurations
|
||||
export interface AIProfile {
|
||||
id: string;
|
||||
@@ -296,10 +299,35 @@ export interface Feature {
|
||||
error?: string; // Error message if the agent errored during processing
|
||||
priority?: number; // Priority: 1 = high, 2 = medium, 3 = low
|
||||
dependencies?: string[]; // Array of feature IDs this feature depends on
|
||||
// Worktree info - set when a feature is being worked on in an isolated git worktree
|
||||
worktreePath?: string; // Path to the worktree directory
|
||||
branchName?: string; // Name of the feature branch
|
||||
// Branch info - worktree path is derived at runtime from branchName
|
||||
branchName?: string; // Name of the feature branch (undefined = use current worktree)
|
||||
justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes)
|
||||
planningMode?: PlanningMode; // Planning mode for this feature
|
||||
planSpec?: PlanSpec; // Generated spec/plan data
|
||||
requirePlanApproval?: boolean; // Whether to pause and require manual approval before implementation
|
||||
}
|
||||
|
||||
// Parsed task from spec (for spec and full planning modes)
|
||||
export interface ParsedTask {
|
||||
id: string; // e.g., "T001"
|
||||
description: string; // e.g., "Create user model"
|
||||
filePath?: string; // e.g., "src/models/user.ts"
|
||||
phase?: string; // e.g., "Phase 1: Foundation" (for full mode)
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
// PlanSpec status for feature planning/specification
|
||||
export interface PlanSpec {
|
||||
status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected';
|
||||
content?: string; // The actual spec/plan markdown content
|
||||
version: number;
|
||||
generatedAt?: string; // ISO timestamp
|
||||
approvedAt?: string; // ISO timestamp
|
||||
reviewedByUser: boolean; // True if user has seen the spec
|
||||
tasksCompleted?: number;
|
||||
tasksTotal?: number;
|
||||
currentTaskId?: string; // ID of the task currently being worked on
|
||||
tasks?: ParsedTask[]; // Parsed tasks from the spec
|
||||
}
|
||||
|
||||
// File tree node for project analysis
|
||||
@@ -404,7 +432,10 @@ export interface AppState {
|
||||
|
||||
// User-managed Worktrees (per-project)
|
||||
// projectPath -> { path: worktreePath or null for main, branch: branch name }
|
||||
currentWorktreeByProject: Record<string, { path: string | null; branch: string }>;
|
||||
currentWorktreeByProject: Record<
|
||||
string,
|
||||
{ path: string | null; branch: string }
|
||||
>;
|
||||
worktreesByProject: Record<
|
||||
string,
|
||||
Array<{
|
||||
@@ -460,6 +491,18 @@ export interface AppState {
|
||||
// Spec Creation State (per-project, keyed by project path)
|
||||
// Tracks which project is currently having its spec generated
|
||||
specCreatingForProject: string | null;
|
||||
|
||||
defaultPlanningMode: PlanningMode;
|
||||
defaultRequirePlanApproval: boolean;
|
||||
|
||||
// Plan Approval State
|
||||
// When a plan requires user approval, this holds the pending approval details
|
||||
pendingPlanApproval: {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
planContent: string;
|
||||
planningMode: "lite" | "spec" | "full";
|
||||
} | null;
|
||||
}
|
||||
|
||||
// Default background settings for board backgrounds
|
||||
@@ -588,7 +631,11 @@ export interface AppActions {
|
||||
|
||||
// Worktree Settings actions
|
||||
setUseWorktrees: (enabled: boolean) => void;
|
||||
setCurrentWorktree: (projectPath: string, worktreePath: string | null, branch: string) => void;
|
||||
setCurrentWorktree: (
|
||||
projectPath: string,
|
||||
worktreePath: string | null,
|
||||
branch: string
|
||||
) => void;
|
||||
setWorktrees: (
|
||||
projectPath: string,
|
||||
worktrees: Array<{
|
||||
@@ -599,7 +646,9 @@ export interface AppActions {
|
||||
changedFilesCount?: number;
|
||||
}>
|
||||
) => void;
|
||||
getCurrentWorktree: (projectPath: string) => { path: string | null; branch: string } | null;
|
||||
getCurrentWorktree: (
|
||||
projectPath: string
|
||||
) => { path: string | null; branch: string } | null;
|
||||
getWorktrees: (projectPath: string) => Array<{
|
||||
path: string;
|
||||
branch: string;
|
||||
@@ -607,6 +656,8 @@ export interface AppActions {
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}>;
|
||||
isPrimaryWorktreeBranch: (projectPath: string, branchName: string) => boolean;
|
||||
getPrimaryWorktreeBranch: (projectPath: string) => string | null;
|
||||
|
||||
// Profile Display Settings actions
|
||||
setShowProfilesOnly: (enabled: boolean) => void;
|
||||
@@ -689,6 +740,17 @@ export interface AppActions {
|
||||
setSpecCreatingForProject: (projectPath: string | null) => void;
|
||||
isSpecCreatingForProject: (projectPath: string) => boolean;
|
||||
|
||||
setDefaultPlanningMode: (mode: PlanningMode) => void;
|
||||
setDefaultRequirePlanApproval: (require: boolean) => void;
|
||||
|
||||
// Plan Approval actions
|
||||
setPendingPlanApproval: (approval: {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
planContent: string;
|
||||
planningMode: "lite" | "spec" | "full";
|
||||
} | null) => void;
|
||||
|
||||
// Reset
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -777,6 +839,9 @@ const initialState: AppState = {
|
||||
defaultFontSize: 14,
|
||||
},
|
||||
specCreatingForProject: null,
|
||||
defaultPlanningMode: 'skip' as PlanningMode,
|
||||
defaultRequirePlanApproval: false,
|
||||
pendingPlanApproval: null,
|
||||
};
|
||||
|
||||
export const useAppStore = create<AppState & AppActions>()(
|
||||
@@ -1347,7 +1412,8 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
|
||||
// Feature Default Settings actions
|
||||
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
|
||||
setEnableDependencyBlocking: (enabled) => set({ enableDependencyBlocking: enabled }),
|
||||
setEnableDependencyBlocking: (enabled) =>
|
||||
set({ enableDependencyBlocking: enabled }),
|
||||
|
||||
// Worktree Settings actions
|
||||
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
|
||||
@@ -1380,6 +1446,18 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
return get().worktreesByProject[projectPath] ?? [];
|
||||
},
|
||||
|
||||
isPrimaryWorktreeBranch: (projectPath, branchName) => {
|
||||
const worktrees = get().worktreesByProject[projectPath] ?? [];
|
||||
const primary = worktrees.find((w) => w.isMain);
|
||||
return primary?.branch === branchName;
|
||||
},
|
||||
|
||||
getPrimaryWorktreeBranch: (projectPath) => {
|
||||
const worktrees = get().worktreesByProject[projectPath] ?? [];
|
||||
const primary = worktrees.find((w) => w.isMain);
|
||||
return primary?.branch ?? null;
|
||||
},
|
||||
|
||||
// Profile Display Settings actions
|
||||
setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }),
|
||||
|
||||
@@ -2185,6 +2263,12 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
return get().specCreatingForProject === projectPath;
|
||||
},
|
||||
|
||||
setDefaultPlanningMode: (mode) => set({ defaultPlanningMode: mode }),
|
||||
setDefaultRequirePlanApproval: (require) => set({ defaultRequirePlanApproval: require }),
|
||||
|
||||
// Plan Approval actions
|
||||
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),
|
||||
|
||||
// Reset
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
@@ -2237,7 +2321,8 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
// Settings
|
||||
apiKeys: state.apiKeys,
|
||||
maxConcurrency: state.maxConcurrency,
|
||||
autoModeByProject: state.autoModeByProject,
|
||||
// Note: autoModeByProject is intentionally NOT persisted
|
||||
// Auto-mode should always default to OFF on app refresh
|
||||
defaultSkipTests: state.defaultSkipTests,
|
||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||
useWorktrees: state.useWorktrees,
|
||||
@@ -2253,6 +2338,8 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
|
||||
// Board background settings
|
||||
boardBackgroundByProject: state.boardBackgroundByProject,
|
||||
defaultPlanningMode: state.defaultPlanningMode,
|
||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
126
apps/ui/src/types/electron.d.ts
vendored
126
apps/ui/src/types/electron.d.ts
vendored
@@ -193,35 +193,11 @@ export type AutoModeEvent =
|
||||
| {
|
||||
type: "auto_mode_error";
|
||||
error: string;
|
||||
errorType?: "authentication" | "execution";
|
||||
errorType?: "authentication" | "cancellation" | "abort" | "execution";
|
||||
featureId?: string;
|
||||
projectId?: string;
|
||||
projectPath?: string;
|
||||
}
|
||||
| {
|
||||
type: "auto_mode_complete";
|
||||
message: string;
|
||||
projectId?: string;
|
||||
projectPath?: string;
|
||||
}
|
||||
| {
|
||||
type: "auto_mode_stopped";
|
||||
message: string;
|
||||
projectId?: string;
|
||||
projectPath?: string;
|
||||
}
|
||||
| {
|
||||
type: "auto_mode_started";
|
||||
message: string;
|
||||
projectId?: string;
|
||||
projectPath?: string;
|
||||
}
|
||||
| {
|
||||
type: "auto_mode_idle";
|
||||
message: string;
|
||||
projectId?: string;
|
||||
projectPath?: string;
|
||||
}
|
||||
| {
|
||||
type: "auto_mode_phase";
|
||||
featureId: string;
|
||||
@@ -238,6 +214,71 @@ export type AutoModeEvent =
|
||||
recommendations: string[];
|
||||
estimatedCost?: number;
|
||||
estimatedTime?: string;
|
||||
}
|
||||
| {
|
||||
type: "plan_approval_required";
|
||||
featureId: string;
|
||||
projectPath?: string;
|
||||
planContent: string;
|
||||
planningMode: "lite" | "spec" | "full";
|
||||
planVersion?: number;
|
||||
}
|
||||
| {
|
||||
type: "plan_auto_approved";
|
||||
featureId: string;
|
||||
projectPath?: string;
|
||||
planContent: string;
|
||||
planningMode: "lite" | "spec" | "full";
|
||||
}
|
||||
| {
|
||||
type: "plan_approved";
|
||||
featureId: string;
|
||||
projectPath?: string;
|
||||
hasEdits: boolean;
|
||||
planVersion?: number;
|
||||
}
|
||||
| {
|
||||
type: "plan_rejected";
|
||||
featureId: string;
|
||||
projectPath?: string;
|
||||
feedback?: string;
|
||||
}
|
||||
| {
|
||||
type: "plan_revision_requested";
|
||||
featureId: string;
|
||||
projectPath?: string;
|
||||
feedback?: string;
|
||||
hasEdits?: boolean;
|
||||
planVersion?: number;
|
||||
}
|
||||
| {
|
||||
type: "planning_started";
|
||||
featureId: string;
|
||||
mode: "lite" | "spec" | "full";
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: "auto_mode_task_started";
|
||||
featureId: string;
|
||||
projectPath?: string;
|
||||
taskId: string;
|
||||
taskDescription: string;
|
||||
taskIndex: number;
|
||||
tasksTotal: number;
|
||||
}
|
||||
| {
|
||||
type: "auto_mode_task_complete";
|
||||
featureId: string;
|
||||
projectPath?: string;
|
||||
taskId: string;
|
||||
tasksCompleted: number;
|
||||
tasksTotal: number;
|
||||
}
|
||||
| {
|
||||
type: "auto_mode_phase_complete";
|
||||
featureId: string;
|
||||
projectPath?: string;
|
||||
phaseNumber: number;
|
||||
};
|
||||
|
||||
export type SpecRegenerationEvent =
|
||||
@@ -310,20 +351,6 @@ export interface SpecRegenerationAPI {
|
||||
}
|
||||
|
||||
export interface AutoModeAPI {
|
||||
start: (
|
||||
projectPath: string,
|
||||
maxConcurrency?: number
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
stop: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
runningFeatures?: number;
|
||||
}>;
|
||||
|
||||
stopFeature: (featureId: string) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@@ -331,7 +358,6 @@ export interface AutoModeAPI {
|
||||
|
||||
status: (projectPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
autoLoopRunning?: boolean;
|
||||
isRunning?: boolean;
|
||||
currentFeatureId?: string | null;
|
||||
runningFeatures?: string[];
|
||||
@@ -343,8 +369,7 @@ export interface AutoModeAPI {
|
||||
runFeature: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees?: boolean,
|
||||
worktreePath?: string
|
||||
useWorktrees?: boolean
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
passes?: boolean;
|
||||
@@ -390,7 +415,7 @@ export interface AutoModeAPI {
|
||||
featureId: string,
|
||||
prompt: string,
|
||||
imagePaths?: string[],
|
||||
worktreePath?: string
|
||||
useWorktrees?: boolean
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
passes?: boolean;
|
||||
@@ -405,6 +430,17 @@ export interface AutoModeAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
approvePlan: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
approved: boolean,
|
||||
editedPlan?: string,
|
||||
feedback?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
onEvent: (callback: (event: AutoModeEvent) => void) => () => void;
|
||||
}
|
||||
|
||||
@@ -632,6 +668,10 @@ export interface WorktreeAPI {
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}>;
|
||||
removedWorktrees?: Array<{
|
||||
path: string;
|
||||
branch: string;
|
||||
}>;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ export const TEST_IDS = {
|
||||
addFeatureButton: "add-feature-button",
|
||||
addFeatureDialog: "add-feature-dialog",
|
||||
confirmAddFeature: "confirm-add-feature",
|
||||
featureBranchInput: "feature-branch-input",
|
||||
featureBranchInput: "feature-input",
|
||||
featureCategoryInput: "feature-category-input",
|
||||
worktreeSelector: "worktree-selector",
|
||||
|
||||
|
||||
@@ -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,17 +134,30 @@ 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) {
|
||||
const branchButton = page.locator('[data-testid="feature-branch-input"]');
|
||||
await branchButton.click();
|
||||
// First, select "Other branch" radio option if not already selected
|
||||
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 });
|
||||
await branchInput.click();
|
||||
// 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");
|
||||
@@ -152,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);
|
||||
@@ -201,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
|
||||
}
|
||||
@@ -210,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;
|
||||
}
|
||||
@@ -220,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);
|
||||
}
|
||||
|
||||
@@ -741,14 +741,15 @@ test.describe("Worktree Integration Tests", () => {
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Create a worktree first
|
||||
// Note: Worktrees are created at execution time (when feature starts),
|
||||
// not when adding to backlog. We can specify a branch name without
|
||||
// creating a worktree first.
|
||||
const branchName = "feature/test-branch";
|
||||
await apiCreateWorktree(page, testRepo.path, branchName);
|
||||
|
||||
// Click add feature button
|
||||
await clickAddFeature(page);
|
||||
|
||||
// Fill in the feature details
|
||||
// Fill in the feature details with a branch name
|
||||
await fillAddFeatureDialog(page, "Test feature for worktree", {
|
||||
branch: branchName,
|
||||
category: "Testing",
|
||||
@@ -773,9 +774,12 @@ test.describe("Worktree Integration Tests", () => {
|
||||
expect(featureData.description).toBe("Test feature for worktree");
|
||||
expect(featureData.branchName).toBe(branchName);
|
||||
expect(featureData.status).toBe("backlog");
|
||||
// Verify worktreePath is not set when adding to backlog
|
||||
// (worktrees are created at execution time, not when adding to backlog)
|
||||
expect(featureData.worktreePath).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should create worktree automatically when adding feature with new branch", async ({
|
||||
test("should store branch name when adding feature with new branch (worktree created at execution)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupProjectWithPath(page, testRepo.path);
|
||||
@@ -783,12 +787,13 @@ test.describe("Worktree Integration Tests", () => {
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Use a branch name that doesn't exist yet - NO worktree is pre-created
|
||||
// Use a branch name that doesn't exist yet
|
||||
// Note: Worktrees are now created at execution time, not when adding to backlog
|
||||
const branchName = "feature/auto-create-worktree";
|
||||
const expectedWorktreePath = getWorktreePath(testRepo.path, branchName);
|
||||
|
||||
// Verify worktree does NOT exist before we create the feature
|
||||
expect(fs.existsSync(expectedWorktreePath)).toBe(false);
|
||||
// Verify branch does NOT exist before we create the feature
|
||||
const branchesBefore = await listBranches(testRepo.path);
|
||||
expect(branchesBefore).not.toContain(branchName);
|
||||
|
||||
// Click add feature button
|
||||
await clickAddFeature(page);
|
||||
@@ -802,17 +807,14 @@ test.describe("Worktree Integration Tests", () => {
|
||||
// Confirm
|
||||
await confirmAddFeature(page);
|
||||
|
||||
// Wait for the worktree to be created
|
||||
await page.waitForTimeout(2000);
|
||||
// Wait for feature to be saved
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify worktree was automatically created when feature was added
|
||||
expect(fs.existsSync(expectedWorktreePath)).toBe(true);
|
||||
// Verify branch was NOT created when adding feature (created at execution time)
|
||||
const branchesAfter = await listBranches(testRepo.path);
|
||||
expect(branchesAfter).not.toContain(branchName);
|
||||
|
||||
// Verify the branch was created
|
||||
const branches = await listBranches(testRepo.path);
|
||||
expect(branches).toContain(branchName);
|
||||
|
||||
// Verify feature was created with correct branch
|
||||
// Verify feature was created with correct branch name stored
|
||||
const featuresDir = path.join(testRepo.path, ".automaker", "features");
|
||||
const featureDirs = fs.readdirSync(featuresDir);
|
||||
expect(featureDirs.length).toBeGreaterThan(0);
|
||||
@@ -829,9 +831,15 @@ test.describe("Worktree Integration Tests", () => {
|
||||
|
||||
const featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
|
||||
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
|
||||
// Verify branch name is stored
|
||||
expect(featureData.branchName).toBe(branchName);
|
||||
// Normalize paths for comparison (server returns forward slashes, path.join returns native)
|
||||
expect(path.normalize(featureData.worktreePath)).toBe(path.normalize(expectedWorktreePath));
|
||||
|
||||
// Verify worktreePath is NOT set (worktrees are created at execution time)
|
||||
expect(featureData.worktreePath).toBeUndefined();
|
||||
|
||||
// Verify feature is in backlog status
|
||||
expect(featureData.status).toBe("backlog");
|
||||
});
|
||||
|
||||
test("should reset feature branch and worktree when worktree is deleted", async ({
|
||||
@@ -888,9 +896,11 @@ test.describe("Worktree Integration Tests", () => {
|
||||
|
||||
let featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
|
||||
let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
|
||||
// Verify feature was created with the branch name stored
|
||||
expect(featureData.branchName).toBe(branchName);
|
||||
// Normalize paths for comparison (server returns forward slashes, path.join returns native)
|
||||
expect(path.normalize(featureData.worktreePath)).toBe(path.normalize(worktreePath));
|
||||
// Verify worktreePath is NOT set (worktrees are created at execution time, not when adding)
|
||||
expect(featureData.worktreePath).toBeUndefined();
|
||||
|
||||
// Delete the worktree via UI
|
||||
// Open the worktree actions menu
|
||||
@@ -913,10 +923,11 @@ test.describe("Worktree Integration Tests", () => {
|
||||
// Verify worktree is deleted
|
||||
expect(fs.existsSync(worktreePath)).toBe(false);
|
||||
|
||||
// Verify feature's branchName and worktreePath are reset to null
|
||||
// Verify feature's branchName is reset to null/undefined when worktree is deleted
|
||||
// (worktreePath was never stored, so it remains undefined)
|
||||
featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
expect(featureData.branchName).toBeNull();
|
||||
expect(featureData.worktreePath).toBeNull();
|
||||
expect(featureData.worktreePath).toBeUndefined();
|
||||
|
||||
// Verify the feature appears in the backlog when main is selected
|
||||
const mainButton = page.getByRole("button", { name: "main" }).first();
|
||||
@@ -942,8 +953,9 @@ test.describe("Worktree Integration Tests", () => {
|
||||
await otherWorktreeButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Unassigned features should still be visible in the backlog
|
||||
await expect(featureText).toBeVisible({ timeout: 5000 });
|
||||
// Unassigned features should NOT be visible on non-primary worktrees
|
||||
// They should only show on the primary (main) worktree
|
||||
await expect(featureText).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("should filter features by selected worktree", async ({ page }) => {
|
||||
@@ -1064,9 +1076,11 @@ test.describe("Worktree Integration Tests", () => {
|
||||
// Open add feature dialog
|
||||
await clickAddFeature(page);
|
||||
|
||||
// Verify the branch input button shows the selected worktree's branch
|
||||
const branchButton = page.locator('[data-testid="feature-branch-input"]');
|
||||
await expect(branchButton).toContainText(branchName, { timeout: 5000 });
|
||||
// Verify the branch selector shows the selected worktree's branch
|
||||
// When a worktree is selected, "Use current selected branch" should be selected
|
||||
// and the branch name should be shown in the label
|
||||
const currentBranchLabel = page.locator('label[for="feature-current"]');
|
||||
await expect(currentBranchLabel).toContainText(branchName, { timeout: 5000 });
|
||||
|
||||
// Close dialog
|
||||
await page.keyboard.press("Escape");
|
||||
@@ -2339,7 +2353,7 @@ test.describe("Worktree Integration Tests", () => {
|
||||
// Edit Feature with Branch Change
|
||||
// ==========================================================================
|
||||
|
||||
test("should create worktree when editing a feature and selecting a new branch", async ({
|
||||
test("should update branchName when editing a feature and selecting a new branch", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupProjectWithPath(page, testRepo.path);
|
||||
@@ -2385,7 +2399,7 @@ test.describe("Worktree Integration Tests", () => {
|
||||
const newBranchName = "feature/edited-branch";
|
||||
const expectedWorktreePath = getWorktreePath(testRepo.path, newBranchName);
|
||||
|
||||
// Verify worktree does NOT exist before editing
|
||||
// Verify worktree does NOT exist before editing (worktrees are created at execution time)
|
||||
expect(fs.existsSync(expectedWorktreePath)).toBe(false);
|
||||
|
||||
// Find and click the edit button on the feature card
|
||||
@@ -2400,8 +2414,12 @@ test.describe("Worktree Integration Tests", () => {
|
||||
const editDialog = page.locator('[data-testid="edit-feature-dialog"]');
|
||||
await expect(editDialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Select "Other branch" to enable the branch input
|
||||
const otherBranchRadio = page.locator('label[for="edit-feature-other"]');
|
||||
await otherBranchRadio.click();
|
||||
|
||||
// Find and click on the branch input to open the autocomplete
|
||||
const branchInput = page.locator('[data-testid="edit-feature-branch"]');
|
||||
const branchInput = page.locator('[data-testid="edit-feature-input"]');
|
||||
await branchInput.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
@@ -2417,21 +2435,22 @@ test.describe("Worktree Integration Tests", () => {
|
||||
const saveButton = page.locator('[data-testid="confirm-edit-feature"]');
|
||||
await saveButton.click();
|
||||
|
||||
// Wait for the dialog to close and worktree to be created
|
||||
// Wait for the dialog to close
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify worktree was automatically created
|
||||
expect(fs.existsSync(expectedWorktreePath)).toBe(true);
|
||||
// Verify worktree was NOT created during editing (worktrees are created at execution time)
|
||||
expect(fs.existsSync(expectedWorktreePath)).toBe(false);
|
||||
|
||||
// Verify the branch was created
|
||||
// Verify branch was NOT created (created at execution time)
|
||||
const branches = await listBranches(testRepo.path);
|
||||
expect(branches).toContain(newBranchName);
|
||||
expect(branches).not.toContain(newBranchName);
|
||||
|
||||
// Verify feature was updated with correct branch and worktreePath
|
||||
// Verify feature was updated with correct branchName only
|
||||
// Note: worktreePath is no longer stored - worktrees are created server-side at execution time
|
||||
featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
expect(featureData.branchName).toBe(newBranchName);
|
||||
// Normalize paths for comparison (server returns forward slashes, path.join returns native)
|
||||
expect(path.normalize(featureData.worktreePath)).toBe(path.normalize(expectedWorktreePath));
|
||||
// worktreePath should not exist in the feature data
|
||||
expect(featureData.worktreePath).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should not create worktree when editing a feature and selecting main branch", async ({
|
||||
@@ -2494,7 +2513,7 @@ test.describe("Worktree Integration Tests", () => {
|
||||
await expect(editDialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Find and click on the branch input
|
||||
const branchInput = page.locator('[data-testid="edit-feature-branch"]');
|
||||
const branchInput = page.locator('[data-testid="edit-feature-input"]');
|
||||
await branchInput.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
@@ -2553,7 +2572,7 @@ test.describe("Worktree Integration Tests", () => {
|
||||
await expect(editDialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Change to the existing branch
|
||||
const branchInput = page.locator('[data-testid="edit-feature-branch"]');
|
||||
const branchInput = page.locator('[data-testid="edit-feature-input"]');
|
||||
await branchInput.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
@@ -2574,7 +2593,8 @@ test.describe("Worktree Integration Tests", () => {
|
||||
);
|
||||
expect(matchingWorktrees.length).toBe(1);
|
||||
|
||||
// Verify feature was updated with the correct worktreePath
|
||||
// Verify feature was updated with the correct branchName
|
||||
// Note: worktreePath is no longer stored - worktrees are created server-side at execution time
|
||||
const featuresDir = path.join(testRepo.path, ".automaker", "features");
|
||||
const featureDirs = fs.readdirSync(featuresDir);
|
||||
const featureDir = featureDirs.find((dir) => {
|
||||
@@ -2589,7 +2609,7 @@ test.describe("Worktree Integration Tests", () => {
|
||||
const featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
|
||||
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
expect(featureData.branchName).toBe(existingBranch);
|
||||
// Normalize paths for comparison (server returns forward slashes, path.join returns native)
|
||||
expect(path.normalize(featureData.worktreePath)).toBe(path.normalize(existingWorktreePath));
|
||||
// worktreePath should not exist in the feature data (worktrees are created at execution time)
|
||||
expect(featureData.worktreePath).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -582,3 +582,4 @@ The route organization pattern provides:
|
||||
Apply this pattern to all route modules for consistency and improved code quality.
|
||||
|
||||
|
||||
|
||||
|
||||
5179
package-lock.json
generated
5179
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user