Merge main into refactor/frontend

- Resolved conflicts from apps/app to apps/ui migration
- Moved worktree-panel component to apps/ui
- Moved dependency-resolver.ts to apps/ui
- Removed worktree-selector.tsx (replaced by worktree-panel)
- Merged theme updates, file browser improvements, and Gemini fixes
- Merged server dependency resolver and auto-mode-service updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-17 20:14:19 +01:00
36 changed files with 2978 additions and 1059 deletions

View File

@@ -0,0 +1,221 @@
/**
* Dependency Resolution Utility (Server-side)
*
* Provides topological sorting and dependency analysis for features.
* Uses a modified Kahn's algorithm that respects both dependencies and priorities.
*/
import type { Feature } from "../services/feature-loader.js";
export interface DependencyResolutionResult {
orderedFeatures: Feature[]; // Features in dependency-aware order
circularDependencies: string[][]; // Groups of IDs forming cycles
missingDependencies: Map<string, string[]>; // featureId -> missing dep IDs
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
}
/**
* Resolves feature dependencies using topological sort with priority-aware ordering.
*
* Algorithm:
* 1. Build dependency graph and detect missing/blocked dependencies
* 2. Apply Kahn's algorithm for topological sort
* 3. Within same dependency level, sort by priority (1=high, 2=medium, 3=low)
* 4. Detect circular dependencies for features that can't be ordered
*
* @param features - Array of features to order
* @returns Resolution result with ordered features and dependency metadata
*/
export function resolveDependencies(features: Feature[]): DependencyResolutionResult {
const featureMap = new Map<string, Feature>(features.map(f => [f.id, f]));
const inDegree = new Map<string, number>();
const adjacencyList = new Map<string, string[]>(); // dependencyId -> [dependentIds]
const missingDependencies = new Map<string, string[]>();
const blockedFeatures = new Map<string, string[]>();
// Initialize graph structures
for (const feature of features) {
inDegree.set(feature.id, 0);
adjacencyList.set(feature.id, []);
}
// Build dependency graph and detect missing/blocked dependencies
for (const feature of features) {
const deps = feature.dependencies || [];
for (const depId of deps) {
if (!featureMap.has(depId)) {
// Missing dependency - track it
if (!missingDependencies.has(feature.id)) {
missingDependencies.set(feature.id, []);
}
missingDependencies.get(feature.id)!.push(depId);
} else {
// Valid dependency - add edge to graph
adjacencyList.get(depId)!.push(feature.id);
inDegree.set(feature.id, (inDegree.get(feature.id) || 0) + 1);
// Check if dependency is incomplete (blocking)
const depFeature = featureMap.get(depId)!;
if (depFeature.status !== 'completed' && depFeature.status !== 'verified') {
if (!blockedFeatures.has(feature.id)) {
blockedFeatures.set(feature.id, []);
}
blockedFeatures.get(feature.id)!.push(depId);
}
}
}
}
// Kahn's algorithm with priority-aware selection
const queue: Feature[] = [];
const orderedFeatures: Feature[] = [];
// Helper to sort features by priority (lower number = higher priority)
const sortByPriority = (a: Feature, b: Feature) =>
(a.priority ?? 2) - (b.priority ?? 2);
// Start with features that have no dependencies (in-degree 0)
for (const [id, degree] of inDegree) {
if (degree === 0) {
queue.push(featureMap.get(id)!);
}
}
// Sort initial queue by priority
queue.sort(sortByPriority);
// Process features in topological order
while (queue.length > 0) {
// Take highest priority feature from queue
const current = queue.shift()!;
orderedFeatures.push(current);
// Process features that depend on this one
for (const dependentId of adjacencyList.get(current.id) || []) {
const currentDegree = inDegree.get(dependentId);
if (currentDegree === undefined) {
throw new Error(`In-degree not initialized for feature ${dependentId}`);
}
const newDegree = currentDegree - 1;
inDegree.set(dependentId, newDegree);
if (newDegree === 0) {
queue.push(featureMap.get(dependentId)!);
// Re-sort queue to maintain priority order
queue.sort(sortByPriority);
}
}
}
// Detect circular dependencies (features not in output = part of cycle)
const circularDependencies: string[][] = [];
const processedIds = new Set(orderedFeatures.map(f => f.id));
if (orderedFeatures.length < features.length) {
// Find cycles using DFS
const remaining = features.filter(f => !processedIds.has(f.id));
const cycles = detectCycles(remaining, featureMap);
circularDependencies.push(...cycles);
// Add remaining features at end (part of cycles)
orderedFeatures.push(...remaining);
}
return {
orderedFeatures,
circularDependencies,
missingDependencies,
blockedFeatures
};
}
/**
* Detects circular dependencies using depth-first search
*
* @param features - Features that couldn't be topologically sorted (potential cycles)
* @param featureMap - Map of all features by ID
* @returns Array of cycles, where each cycle is an array of feature IDs
*/
function detectCycles(
features: Feature[],
featureMap: Map<string, Feature>
): string[][] {
const cycles: string[][] = [];
const visited = new Set<string>();
const recursionStack = new Set<string>();
const currentPath: string[] = [];
function dfs(featureId: string): boolean {
visited.add(featureId);
recursionStack.add(featureId);
currentPath.push(featureId);
const feature = featureMap.get(featureId);
if (feature) {
for (const depId of feature.dependencies || []) {
if (!visited.has(depId)) {
if (dfs(depId)) return true;
} else if (recursionStack.has(depId)) {
// Found cycle - extract it
const cycleStart = currentPath.indexOf(depId);
cycles.push(currentPath.slice(cycleStart));
return true;
}
}
}
currentPath.pop();
recursionStack.delete(featureId);
return false;
}
for (const feature of features) {
if (!visited.has(feature.id)) {
dfs(feature.id);
}
}
return cycles;
}
/**
* Checks if a feature's dependencies are satisfied (all complete or verified)
*
* @param feature - Feature to check
* @param allFeatures - All features in the project
* @returns true if all dependencies are satisfied, false otherwise
*/
export function areDependenciesSatisfied(
feature: Feature,
allFeatures: Feature[]
): boolean {
if (!feature.dependencies || feature.dependencies.length === 0) {
return true; // No dependencies = always ready
}
return feature.dependencies.every((depId: string) => {
const dep = allFeatures.find(f => f.id === depId);
return dep && (dep.status === 'completed' || dep.status === 'verified');
});
}
/**
* Gets the blocking dependencies for a feature (dependencies that are incomplete)
*
* @param feature - Feature to check
* @param allFeatures - All features in the project
* @returns Array of feature IDs that are blocking this feature
*/
export function getBlockingDependencies(
feature: Feature,
allFeatures: Feature[]
): string[] {
if (!feature.dependencies || feature.dependencies.length === 0) {
return [];
}
return feature.dependencies.filter((depId: string) => {
const dep = allFeatures.find(f => f.id === depId);
return dep && dep.status !== 'completed' && dep.status !== 'verified';
});
}

View File

@@ -20,6 +20,8 @@ import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
import { createAutoModeOptions } from "../lib/sdk-options.js";
import { isAbortError, classifyError } from "../lib/error-handler.js";
import { resolveDependencies, areDependenciesSatisfied } from "../lib/dependency-resolver.js";
import type { Feature } from "./feature-loader.js";
import {
getFeatureDir,
getFeaturesDir,
@@ -29,26 +31,6 @@ import {
const execAsync = promisify(exec);
interface Feature {
id: string;
category: string;
description: string;
steps?: string[];
status: string;
priority?: number;
spec?: string;
model?: string; // Model to use for this feature
imagePaths?: Array<
| string
| {
path: string;
filename?: string;
mimeType?: string;
[key: string]: unknown;
}
>;
}
interface RunningFeature {
featureId: string;
projectPath: string;
@@ -1016,8 +998,10 @@ Format your response as a structured markdown document.`;
try {
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
const features: Feature[] = [];
const allFeatures: Feature[] = [];
const pendingFeatures: Feature[] = [];
// Load all features (for dependency checking)
for (const entry of entries) {
if (entry.isDirectory()) {
const featurePath = path.join(
@@ -1028,12 +1012,15 @@ Format your response as a structured markdown document.`;
try {
const data = await fs.readFile(featurePath, "utf-8");
const feature = JSON.parse(data);
allFeatures.push(feature);
// Track pending features separately
if (
feature.status === "pending" ||
feature.status === "ready" ||
feature.status === "backlog"
) {
features.push(feature);
pendingFeatures.push(feature);
}
} catch {
// Skip invalid features
@@ -1041,8 +1028,15 @@ Format your response as a structured markdown document.`;
}
}
// Sort by priority
return features.sort((a, b) => (a.priority || 999) - (b.priority || 999));
// Apply dependency-aware ordering
const { orderedFeatures } = resolveDependencies(pendingFeatures);
// Filter to only features with satisfied dependencies
const readyFeatures = orderedFeatures.filter(feature =>
areDependenciesSatisfied(feature, allFeatures)
);
return readyFeatures;
} catch {
return [];
}
@@ -1286,17 +1280,13 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
const outputPath = path.join(featureDirForOutput, "agent-output.md");
// Incremental file writing state
let directoryCreated = false;
let writeTimeout: ReturnType<typeof setTimeout> | null = null;
const WRITE_DEBOUNCE_MS = 500; // Batch writes every 500ms
// Helper to write current responseText to file
const writeToFile = async (): Promise<void> => {
try {
if (!directoryCreated) {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
directoryCreated = true;
}
await fs.writeFile(outputPath, responseText);
} catch (error) {
// Log but don't crash - file write errors shouldn't stop execution
@@ -1310,9 +1300,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
clearTimeout(writeTimeout);
}
writeTimeout = setTimeout(() => {
writeToFile().catch((err) => {
console.error(`[AutoMode] Debounced write error:`, err);
});
writeToFile();
}, WRITE_DEBOUNCE_MS);
};

View File

@@ -19,6 +19,10 @@ export interface Feature {
steps?: string[];
passes?: boolean;
priority?: number;
status?: string;
dependencies?: string[];
spec?: string;
model?: string;
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
[key: string]: unknown;
}

View File

@@ -0,0 +1,438 @@
import { describe, it, expect } from "vitest";
import {
resolveDependencies,
areDependenciesSatisfied,
getBlockingDependencies,
type DependencyResolutionResult,
} from "@/lib/dependency-resolver.js";
import type { Feature } from "@/services/feature-loader.js";
// Helper to create test features
function createFeature(
id: string,
options: {
status?: string;
priority?: number;
dependencies?: string[];
category?: string;
description?: string;
} = {}
): Feature {
return {
id,
category: options.category || "test",
description: options.description || `Feature ${id}`,
status: options.status || "backlog",
priority: options.priority,
dependencies: options.dependencies,
};
}
describe("dependency-resolver.ts", () => {
describe("resolveDependencies", () => {
it("should handle empty feature list", () => {
const result = resolveDependencies([]);
expect(result.orderedFeatures).toEqual([]);
expect(result.circularDependencies).toEqual([]);
expect(result.missingDependencies.size).toBe(0);
expect(result.blockedFeatures.size).toBe(0);
});
it("should handle features with no dependencies", () => {
const features = [
createFeature("f1", { priority: 1 }),
createFeature("f2", { priority: 2 }),
createFeature("f3", { priority: 3 }),
];
const result = resolveDependencies(features);
expect(result.orderedFeatures).toHaveLength(3);
expect(result.orderedFeatures[0].id).toBe("f1"); // Highest priority first
expect(result.orderedFeatures[1].id).toBe("f2");
expect(result.orderedFeatures[2].id).toBe("f3");
expect(result.circularDependencies).toEqual([]);
expect(result.missingDependencies.size).toBe(0);
expect(result.blockedFeatures.size).toBe(0);
});
it("should order features by dependencies (simple chain)", () => {
const features = [
createFeature("f3", { dependencies: ["f2"] }),
createFeature("f1"),
createFeature("f2", { dependencies: ["f1"] }),
];
const result = resolveDependencies(features);
expect(result.orderedFeatures).toHaveLength(3);
expect(result.orderedFeatures[0].id).toBe("f1");
expect(result.orderedFeatures[1].id).toBe("f2");
expect(result.orderedFeatures[2].id).toBe("f3");
expect(result.circularDependencies).toEqual([]);
});
it("should respect priority within same dependency level", () => {
const features = [
createFeature("f1", { priority: 3, dependencies: ["base"] }),
createFeature("f2", { priority: 1, dependencies: ["base"] }),
createFeature("f3", { priority: 2, dependencies: ["base"] }),
createFeature("base"),
];
const result = resolveDependencies(features);
expect(result.orderedFeatures[0].id).toBe("base");
expect(result.orderedFeatures[1].id).toBe("f2"); // Priority 1
expect(result.orderedFeatures[2].id).toBe("f3"); // Priority 2
expect(result.orderedFeatures[3].id).toBe("f1"); // Priority 3
});
it("should use default priority of 2 when not specified", () => {
const features = [
createFeature("f1", { priority: 1 }),
createFeature("f2"), // No priority = default 2
createFeature("f3", { priority: 3 }),
];
const result = resolveDependencies(features);
expect(result.orderedFeatures[0].id).toBe("f1");
expect(result.orderedFeatures[1].id).toBe("f2");
expect(result.orderedFeatures[2].id).toBe("f3");
});
it("should detect missing dependencies", () => {
const features = [
createFeature("f1", { dependencies: ["missing1", "missing2"] }),
createFeature("f2", { dependencies: ["f1", "missing3"] }),
];
const result = resolveDependencies(features);
expect(result.missingDependencies.size).toBe(2);
expect(result.missingDependencies.get("f1")).toEqual(["missing1", "missing2"]);
expect(result.missingDependencies.get("f2")).toEqual(["missing3"]);
expect(result.orderedFeatures).toHaveLength(2);
});
it("should detect blocked features (incomplete dependencies)", () => {
const features = [
createFeature("f1", { status: "in_progress" }),
createFeature("f2", { status: "backlog", dependencies: ["f1"] }),
createFeature("f3", { status: "completed" }),
createFeature("f4", { status: "backlog", dependencies: ["f3"] }),
];
const result = resolveDependencies(features);
expect(result.blockedFeatures.size).toBe(1);
expect(result.blockedFeatures.get("f2")).toEqual(["f1"]);
expect(result.blockedFeatures.has("f4")).toBe(false); // f3 is completed
});
it("should not block features whose dependencies are verified", () => {
const features = [
createFeature("f1", { status: "verified" }),
createFeature("f2", { status: "backlog", dependencies: ["f1"] }),
];
const result = resolveDependencies(features);
expect(result.blockedFeatures.size).toBe(0);
});
it("should detect circular dependencies (simple cycle)", () => {
const features = [
createFeature("f1", { dependencies: ["f2"] }),
createFeature("f2", { dependencies: ["f1"] }),
];
const result = resolveDependencies(features);
expect(result.circularDependencies).toHaveLength(1);
expect(result.circularDependencies[0]).toContain("f1");
expect(result.circularDependencies[0]).toContain("f2");
expect(result.orderedFeatures).toHaveLength(2); // Features still included
});
it("should detect circular dependencies (multi-node cycle)", () => {
const features = [
createFeature("f1", { dependencies: ["f3"] }),
createFeature("f2", { dependencies: ["f1"] }),
createFeature("f3", { dependencies: ["f2"] }),
];
const result = resolveDependencies(features);
expect(result.circularDependencies.length).toBeGreaterThan(0);
expect(result.orderedFeatures).toHaveLength(3);
});
it("should handle mixed valid and circular dependencies", () => {
const features = [
createFeature("base"),
createFeature("f1", { dependencies: ["base", "f2"] }),
createFeature("f2", { dependencies: ["f1"] }), // Circular with f1
createFeature("f3", { dependencies: ["base"] }),
];
const result = resolveDependencies(features);
expect(result.circularDependencies.length).toBeGreaterThan(0);
expect(result.orderedFeatures[0].id).toBe("base");
expect(result.orderedFeatures).toHaveLength(4);
});
it("should handle complex dependency graph", () => {
const features = [
createFeature("ui", { dependencies: ["api", "auth"], priority: 1 }),
createFeature("api", { dependencies: ["db"], priority: 2 }),
createFeature("auth", { dependencies: ["db"], priority: 1 }),
createFeature("db", { priority: 1 }),
createFeature("tests", { dependencies: ["ui"], priority: 3 }),
];
const result = resolveDependencies(features);
const order = result.orderedFeatures.map(f => f.id);
expect(order[0]).toBe("db");
expect(order.indexOf("db")).toBeLessThan(order.indexOf("api"));
expect(order.indexOf("db")).toBeLessThan(order.indexOf("auth"));
expect(order.indexOf("api")).toBeLessThan(order.indexOf("ui"));
expect(order.indexOf("auth")).toBeLessThan(order.indexOf("ui"));
expect(order.indexOf("ui")).toBeLessThan(order.indexOf("tests"));
expect(result.circularDependencies).toEqual([]);
});
it("should handle features with empty dependencies array", () => {
const features = [
createFeature("f1", { dependencies: [] }),
createFeature("f2", { dependencies: [] }),
];
const result = resolveDependencies(features);
expect(result.orderedFeatures).toHaveLength(2);
expect(result.circularDependencies).toEqual([]);
expect(result.blockedFeatures.size).toBe(0);
});
it("should track multiple blocking dependencies", () => {
const features = [
createFeature("f1", { status: "in_progress" }),
createFeature("f2", { status: "backlog" }),
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
];
const result = resolveDependencies(features);
expect(result.blockedFeatures.get("f3")).toEqual(["f1", "f2"]);
});
it("should handle self-referencing dependency", () => {
const features = [
createFeature("f1", { dependencies: ["f1"] }),
];
const result = resolveDependencies(features);
expect(result.circularDependencies.length).toBeGreaterThan(0);
expect(result.orderedFeatures).toHaveLength(1);
});
});
describe("areDependenciesSatisfied", () => {
it("should return true for feature with no dependencies", () => {
const feature = createFeature("f1");
const allFeatures = [feature];
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true);
});
it("should return true for feature with empty dependencies array", () => {
const feature = createFeature("f1", { dependencies: [] });
const allFeatures = [feature];
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true);
});
it("should return true when all dependencies are completed", () => {
const allFeatures = [
createFeature("f1", { status: "completed" }),
createFeature("f2", { status: "completed" }),
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
];
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true);
});
it("should return true when all dependencies are verified", () => {
const allFeatures = [
createFeature("f1", { status: "verified" }),
createFeature("f2", { status: "verified" }),
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
];
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true);
});
it("should return true when dependencies are mix of completed and verified", () => {
const allFeatures = [
createFeature("f1", { status: "completed" }),
createFeature("f2", { status: "verified" }),
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
];
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true);
});
it("should return false when any dependency is in_progress", () => {
const allFeatures = [
createFeature("f1", { status: "completed" }),
createFeature("f2", { status: "in_progress" }),
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
];
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(false);
});
it("should return false when any dependency is in backlog", () => {
const allFeatures = [
createFeature("f1", { status: "completed" }),
createFeature("f2", { status: "backlog" }),
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
];
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(false);
});
it("should return false when dependency is missing", () => {
const allFeatures = [
createFeature("f1", { status: "backlog", dependencies: ["missing"] }),
];
expect(areDependenciesSatisfied(allFeatures[0], allFeatures)).toBe(false);
});
it("should return false when multiple dependencies are incomplete", () => {
const allFeatures = [
createFeature("f1", { status: "backlog" }),
createFeature("f2", { status: "in_progress" }),
createFeature("f3", { status: "waiting_approval" }),
createFeature("f4", { status: "backlog", dependencies: ["f1", "f2", "f3"] }),
];
expect(areDependenciesSatisfied(allFeatures[3], allFeatures)).toBe(false);
});
});
describe("getBlockingDependencies", () => {
it("should return empty array for feature with no dependencies", () => {
const feature = createFeature("f1");
const allFeatures = [feature];
expect(getBlockingDependencies(feature, allFeatures)).toEqual([]);
});
it("should return empty array for feature with empty dependencies array", () => {
const feature = createFeature("f1", { dependencies: [] });
const allFeatures = [feature];
expect(getBlockingDependencies(feature, allFeatures)).toEqual([]);
});
it("should return empty array when all dependencies are completed", () => {
const allFeatures = [
createFeature("f1", { status: "completed" }),
createFeature("f2", { status: "completed" }),
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
];
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual([]);
});
it("should return empty array when all dependencies are verified", () => {
const allFeatures = [
createFeature("f1", { status: "verified" }),
createFeature("f2", { status: "verified" }),
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
];
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual([]);
});
it("should return blocking dependencies in backlog status", () => {
const allFeatures = [
createFeature("f1", { status: "backlog" }),
createFeature("f2", { status: "completed" }),
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
];
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]);
});
it("should return blocking dependencies in in_progress status", () => {
const allFeatures = [
createFeature("f1", { status: "in_progress" }),
createFeature("f2", { status: "verified" }),
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
];
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]);
});
it("should return blocking dependencies in waiting_approval status", () => {
const allFeatures = [
createFeature("f1", { status: "waiting_approval" }),
createFeature("f2", { status: "completed" }),
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
];
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]);
});
it("should return all blocking dependencies", () => {
const allFeatures = [
createFeature("f1", { status: "backlog" }),
createFeature("f2", { status: "in_progress" }),
createFeature("f3", { status: "waiting_approval" }),
createFeature("f4", { status: "completed" }),
createFeature("f5", { status: "backlog", dependencies: ["f1", "f2", "f3", "f4"] }),
];
const blocking = getBlockingDependencies(allFeatures[4], allFeatures);
expect(blocking).toHaveLength(3);
expect(blocking).toContain("f1");
expect(blocking).toContain("f2");
expect(blocking).toContain("f3");
expect(blocking).not.toContain("f4");
});
it("should handle missing dependencies", () => {
const allFeatures = [
createFeature("f1", { status: "backlog", dependencies: ["missing"] }),
];
// Missing dependencies won't be in the blocking list since they don't exist
expect(getBlockingDependencies(allFeatures[0], allFeatures)).toEqual([]);
});
it("should handle mix of completed, verified, and incomplete dependencies", () => {
const allFeatures = [
createFeature("f1", { status: "completed" }),
createFeature("f2", { status: "verified" }),
createFeature("f3", { status: "in_progress" }),
createFeature("f4", { status: "backlog" }),
createFeature("f5", { status: "backlog", dependencies: ["f1", "f2", "f3", "f4"] }),
];
const blocking = getBlockingDependencies(allFeatures[4], allFeatures);
expect(blocking).toHaveLength(2);
expect(blocking).toContain("f3");
expect(blocking).toContain("f4");
});
});
});

Binary file not shown.

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import {
FolderOpen,
Folder,
@@ -8,6 +8,8 @@ import {
ArrowLeft,
HardDrive,
CornerDownLeft,
Clock,
X,
} from "lucide-react";
import {
Dialog,
@@ -44,6 +46,44 @@ interface FileBrowserDialogProps {
initialPath?: string;
}
const RECENT_FOLDERS_KEY = "file-browser-recent-folders";
const MAX_RECENT_FOLDERS = 5;
function getRecentFolders(): string[] {
if (typeof window === "undefined") return [];
try {
const stored = localStorage.getItem(RECENT_FOLDERS_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
function addRecentFolder(path: string): void {
if (typeof window === "undefined") return;
try {
const recent = getRecentFolders();
// Remove if already exists, then add to front
const filtered = recent.filter((p) => p !== path);
const updated = [path, ...filtered].slice(0, MAX_RECENT_FOLDERS);
localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated));
} catch {
// Ignore localStorage errors
}
}
function removeRecentFolder(path: string): string[] {
if (typeof window === "undefined") return [];
try {
const recent = getRecentFolders();
const updated = recent.filter((p) => p !== path);
localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated));
return updated;
} catch {
return [];
}
}
export function FileBrowserDialog({
open,
onOpenChange,
@@ -60,8 +100,26 @@ export function FileBrowserDialog({
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [warning, setWarning] = useState("");
const [recentFolders, setRecentFolders] = useState<string[]>([]);
const pathInputRef = useRef<HTMLInputElement>(null);
// Load recent folders when dialog opens
useEffect(() => {
if (open) {
setRecentFolders(getRecentFolders());
}
}, [open]);
const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => {
e.stopPropagation();
const updated = removeRecentFolder(path);
setRecentFolders(updated);
}, []);
const handleSelectRecent = useCallback((path: string) => {
browseDirectory(path);
}, []);
const browseDirectory = async (dirPath?: string) => {
setLoading(true);
setError("");
@@ -152,27 +210,34 @@ export function FileBrowserDialog({
const handleSelect = () => {
if (currentPath) {
addRecentFolder(currentPath);
onSelect(currentPath);
onOpenChange(false);
}
};
// Helper to get folder name from path
const getFolderName = (path: string) => {
const parts = path.split(/[/\\]/).filter(Boolean);
return parts[parts.length - 1] || path;
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader className="pb-2">
<DialogTitle className="flex items-center gap-2">
<FolderOpen className="w-5 h-5 text-brand-500" />
<DialogContent className="bg-popover border-border max-w-3xl max-h-[85vh] overflow-hidden flex flex-col p-4">
<DialogHeader className="pb-1">
<DialogTitle className="flex items-center gap-2 text-base">
<FolderOpen className="w-4 h-4 text-brand-500" />
{title}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
<DialogDescription className="text-muted-foreground text-xs">
{description}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 min-h-[400px] flex-1 overflow-hidden py-2">
<div className="flex flex-col gap-2 min-h-[350px] flex-1 overflow-hidden py-1">
{/* Direct path input */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Input
ref={pathInputRef}
type="text"
@@ -180,7 +245,7 @@ export function FileBrowserDialog({
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathInputKeyDown}
className="flex-1 font-mono text-sm"
className="flex-1 font-mono text-xs h-8"
data-testid="path-input"
disabled={loading}
/>
@@ -190,16 +255,46 @@ export function FileBrowserDialog({
onClick={handleGoToPath}
disabled={loading || !pathInput.trim()}
data-testid="go-to-path-button"
className="h-8 px-2"
>
<CornerDownLeft className="w-4 h-4 mr-1" />
<CornerDownLeft className="w-3.5 h-3.5 mr-1" />
Go
</Button>
</div>
{/* Recent folders */}
{recentFolders.length > 0 && (
<div className="flex flex-wrap gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-1">
<Clock className="w-3 h-3" />
<span>Recent:</span>
</div>
{recentFolders.map((folder) => (
<button
key={folder}
onClick={() => handleSelectRecent(folder)}
className="group flex items-center gap-1 h-6 px-2 text-xs bg-sidebar-accent/20 hover:bg-sidebar-accent/40 rounded border border-sidebar-border transition-colors"
disabled={loading}
title={folder}
>
<Folder className="w-3 h-3 text-brand-500 shrink-0" />
<span className="truncate max-w-[120px]">{getFolderName(folder)}</span>
<button
onClick={(e) => handleRemoveRecent(e, folder)}
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
title="Remove from recent"
>
<X className="w-3 h-3" />
</button>
</button>
))}
</div>
)}
{/* Drives selector (Windows only) */}
{drives.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-2">
<div className="flex flex-wrap gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-1">
<HardDrive className="w-3 h-3" />
<span>Drives:</span>
</div>
@@ -211,7 +306,7 @@ export function FileBrowserDialog({
}
size="sm"
onClick={() => handleSelectDrive(drive)}
className="h-7 px-3 text-xs"
className="h-6 px-2 text-xs"
disabled={loading}
>
{drive.replace("\\", "")}
@@ -221,57 +316,57 @@ export function FileBrowserDialog({
)}
{/* Current path breadcrumb */}
<div className="flex items-center gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
<div className="flex items-center gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
<Button
variant="ghost"
size="sm"
onClick={handleGoHome}
className="h-7 px-2"
className="h-6 px-1.5"
disabled={loading}
>
<Home className="w-4 h-4" />
<Home className="w-3.5 h-3.5" />
</Button>
{parentPath && (
<Button
variant="ghost"
size="sm"
onClick={handleGoToParent}
className="h-7 px-2"
className="h-6 px-1.5"
disabled={loading}
>
<ArrowLeft className="w-4 h-4" />
<ArrowLeft className="w-3.5 h-3.5" />
</Button>
)}
<div className="flex-1 font-mono text-sm truncate text-muted-foreground">
<div className="flex-1 font-mono text-xs truncate text-muted-foreground">
{currentPath || "Loading..."}
</div>
</div>
{/* Directory list */}
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-lg">
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md">
{loading && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-muted-foreground">
<div className="flex items-center justify-center h-full p-4">
<div className="text-xs text-muted-foreground">
Loading directories...
</div>
</div>
)}
{error && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-destructive">{error}</div>
<div className="flex items-center justify-center h-full p-4">
<div className="text-xs text-destructive">{error}</div>
</div>
)}
{warning && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg mb-2">
<div className="text-sm text-yellow-500">{warning}</div>
<div className="p-2 bg-yellow-500/10 border border-yellow-500/30 rounded-md mb-1">
<div className="text-xs text-yellow-500">{warning}</div>
</div>
)}
{!loading && !error && !warning && directories.length === 0 && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-muted-foreground">
<div className="flex items-center justify-center h-full p-4">
<div className="text-xs text-muted-foreground">
No subdirectories found
</div>
</div>
@@ -283,29 +378,29 @@ export function FileBrowserDialog({
<button
key={dir.path}
onClick={() => handleSelectDirectory(dir)}
className="w-full flex items-center gap-3 p-3 hover:bg-sidebar-accent/10 transition-colors text-left group"
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-sidebar-accent/10 transition-colors text-left group"
>
<Folder className="w-5 h-5 text-brand-500 shrink-0" />
<span className="flex-1 truncate text-sm">{dir.name}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
<Folder className="w-4 h-4 text-brand-500 shrink-0" />
<span className="flex-1 truncate text-xs">{dir.name}</span>
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</button>
))}
</div>
)}
</div>
<div className="text-xs text-muted-foreground">
<div className="text-[10px] text-muted-foreground">
Paste a full path above, or click on folders to navigate. Press
Enter or click Go to jump to a path.
</div>
</div>
<DialogFooter className="border-t border-border pt-4 gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
<DialogFooter className="border-t border-border pt-3 gap-2 mt-1">
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSelect} disabled={!currentPath || loading}>
<FolderOpen className="w-4 h-4 mr-2" />
<Button size="sm" onClick={handleSelect} disabled={!currentPath || loading}>
<FolderOpen className="w-3.5 h-3.5 mr-1.5" />
Select Current Folder
</Button>
</DialogFooter>

View File

@@ -35,7 +35,7 @@ import { DeleteWorktreeDialog } from "./board-view/dialogs/delete-worktree-dialo
import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialog";
import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog";
import { CreateBranchDialog } from "./board-view/dialogs/create-branch-dialog";
import { WorktreeSelector } from "./board-view/components";
import { WorktreePanel } from "./board-view/worktree-panel";
import { COLUMNS } from "./board-view/constants";
import {
useBoardFeatures,
@@ -432,8 +432,8 @@ export function BoardView() {
isMounted={isMounted}
/>
{/* Worktree Selector */}
<WorktreeSelector
{/* Worktree Panel */}
<WorktreePanel
refreshTrigger={worktreeRefreshKey}
projectPath={currentProject.path}
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}

View File

@@ -1,3 +1,2 @@
export { KanbanCard } from "./kanban-card";
export { KanbanColumn } from "./kanban-column";
export { WorktreeSelector } from "./worktree-selector";

View File

@@ -56,9 +56,11 @@ import {
Brain,
Wand2,
Archive,
Lock,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { getElectronAPI } from "@/lib/electron";
import { getBlockingDependencies } from "@/lib/dependency-resolver";
import {
parseAgentContext,
AgentTaskInfo,
@@ -142,7 +144,15 @@ export const KanbanCard = memo(function KanbanCard({
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [currentTime, setCurrentTime] = useState(() => Date.now());
const { kanbanCardDetailLevel, useWorktrees } = useAppStore();
const { kanbanCardDetailLevel, enableDependencyBlocking, features, useWorktrees } = useAppStore();
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
const blockingDependencies = useMemo(() => {
if (!enableDependencyBlocking || feature.status !== "backlog") {
return [];
}
return getBlockingDependencies(feature, features);
}, [enableDependencyBlocking, feature, features]);
const showSteps =
kanbanCardDetailLevel === "standard" ||
@@ -327,7 +337,7 @@ export const KanbanCard = memo(function KanbanCard({
<TooltipTrigger asChild>
<div
className={cn(
"absolute px-2 py-1 text-sm font-bold rounded-md flex items-center justify-center z-10",
"absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
"top-2 left-2 min-w-[36px]",
feature.priority === 1 &&
"bg-red-500/20 text-red-500 border-2 border-red-500/50",
@@ -338,7 +348,7 @@ export const KanbanCard = memo(function KanbanCard({
)}
data-testid={`priority-badge-${feature.id}`}
>
P{feature.priority}
{feature.priority === 1 ? "H" : feature.priority === 2 ? "M" : "L"}
</div>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
@@ -363,27 +373,24 @@ export const KanbanCard = memo(function KanbanCard({
</div>
)}
{/* Status badges row */}
{(feature.skipTests || feature.error || isJustFinished) && (
<div
className={cn(
"absolute left-2 z-10 flex items-center gap-1",
feature.priority ? "top-11" : "top-2"
)}
>
{/* Skip Tests (Manual) indicator badge */}
{feature.skipTests && !feature.error && (
{/* Skip Tests (Manual) indicator badge - positioned at top right */}
{feature.skipTests && !feature.error && feature.status === "backlog" && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className="px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 bg-[var(--status-warning-bg)] border border-[var(--status-warning)]/40 text-[var(--status-warning)]"
className={cn(
"absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
"min-w-[36px]",
"top-2 right-2",
"bg-[var(--status-warning-bg)] border-2 border-[var(--status-warning)]/50 text-[var(--status-warning)]"
)}
data-testid={`skip-tests-badge-${feature.id}`}
>
<Hand className="w-3 h-3" />
<Hand className="w-4 h-4" />
</div>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
<TooltipContent side="left" className="text-xs">
<p>Manual verification required</p>
</TooltipContent>
</Tooltip>
@@ -396,10 +403,15 @@ export const KanbanCard = memo(function KanbanCard({
<Tooltip>
<TooltipTrigger asChild>
<div
className="px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 bg-[var(--status-error-bg)] border border-[var(--status-error)]/40 text-[var(--status-error)]"
className={cn(
"absolute px-2 py-1 text-[11px] font-medium rounded-md flex items-center justify-center z-10",
"min-w-[36px]",
feature.priority ? "top-11 left-2" : "top-2 left-2",
"bg-[var(--status-error-bg)] border border-[var(--status-error)]/40 text-[var(--status-error)]"
)}
data-testid={`error-badge-${feature.id}`}
>
<AlertCircle className="w-3 h-3" />
<AlertCircle className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs max-w-[250px]">
@@ -409,18 +421,51 @@ export const KanbanCard = memo(function KanbanCard({
</TooltipProvider>
)}
{/* Blocked by dependencies badge - positioned at top right */}
{blockingDependencies.length > 0 && !feature.error && !feature.skipTests && feature.status === "backlog" && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
"min-w-[36px]",
"top-2 right-2",
"bg-orange-500/20 border-2 border-orange-500/50 text-orange-500"
)}
data-testid={`blocked-badge-${feature.id}`}
>
<Lock className="w-4 h-4" />
</div>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs max-w-[250px]">
<p className="font-medium mb-1">Blocked by {blockingDependencies.length} incomplete {blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}</p>
<p className="text-muted-foreground">
{blockingDependencies.map(depId => {
const dep = features.find(f => f.id === depId);
return dep?.description || depId;
}).join(', ')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Just Finished indicator badge */}
{isJustFinished && (
<div
className="px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 bg-[var(--status-success-bg)] border border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse"
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
feature.priority ? "top-11 left-2" : "top-2 left-2",
"bg-[var(--status-success-bg)] border border-[var(--status-success)]/40 text-[var(--status-success)]",
"animate-pulse"
)}
data-testid={`just-finished-badge-${feature.id}`}
title="Agent just finished working on this feature"
>
<Sparkles className="w-3 h-3" />
</div>
)}
</div>
)}
<CardHeader
className={cn(
@@ -446,7 +491,7 @@ export const KanbanCard = memo(function KanbanCard({
</div>
)}
{!isCurrentAutoTask && feature.status === "backlog" && (
<div className="absolute top-2 right-2">
<div className="absolute bottom-1 right-2">
<Button
variant="ghost"
size="sm"
@@ -465,6 +510,7 @@ export const KanbanCard = memo(function KanbanCard({
{!isCurrentAutoTask &&
(feature.status === "waiting_approval" ||
feature.status === "verified") && (
<>
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
variant="ghost"
@@ -502,6 +548,8 @@ export const KanbanCard = memo(function KanbanCard({
<FileText className="w-4 h-4" />
</Button>
)}
</div>
<div className="absolute bottom-1 right-2">
<Button
variant="ghost"
size="sm"
@@ -519,8 +567,10 @@ export const KanbanCard = memo(function KanbanCard({
<Trash2 className="w-4 h-4" />
</Button>
</div>
</>
)}
{!isCurrentAutoTask && feature.status === "in_progress" && (
<>
<div className="absolute top-2 right-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -560,20 +610,26 @@ export const KanbanCard = memo(function KanbanCard({
View Logs
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-xs text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(e as unknown as React.MouseEvent);
}}
data-testid={`delete-feature-${feature.id}`}
>
<Trash2 className="w-3 h-3 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="absolute bottom-1 right-2">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(e);
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-feature-${feature.id}`}
title="Delete"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</>
)}
<div className="flex items-start gap-2">
{isDraggable && (

View File

@@ -1,832 +0,0 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
GitBranch,
Plus,
Trash2,
MoreHorizontal,
RefreshCw,
GitCommit,
GitPullRequest,
ExternalLink,
ChevronDown,
Download,
Upload,
GitBranchPlus,
Check,
Search,
Play,
Square,
Globe,
Loader2,
} from "lucide-react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { cn, pathsEqual, normalizePath } from "@/lib/utils";
import { toast } from "sonner";
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
isCurrent: boolean; // Is this the currently checked out branch?
hasWorktree: boolean; // Does this branch have an active worktree?
hasChanges?: boolean;
changedFilesCount?: number;
}
interface BranchInfo {
name: string;
isCurrent: boolean;
isRemote: boolean;
}
interface DevServerInfo {
worktreePath: string;
port: number;
url: string;
}
interface FeatureInfo {
id: string;
worktreePath?: string;
branchName?: string; // Used as fallback to determine which worktree the spinner should show on
}
interface WorktreeSelectorProps {
projectPath: string;
onCreateWorktree: () => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
runningFeatureIds?: string[];
features?: FeatureInfo[];
/** Increment this to trigger a refresh without unmounting the component */
refreshTrigger?: number;
}
export function WorktreeSelector({
projectPath,
onCreateWorktree,
onDeleteWorktree,
onCommit,
onCreatePR,
onCreateBranch,
runningFeatureIds = [],
features = [],
refreshTrigger = 0,
}: WorktreeSelectorProps) {
const [isLoading, setIsLoading] = useState(false);
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isSwitching, setIsSwitching] = useState(false);
const [isActivating, setIsActivating] = useState(false);
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
const [branches, setBranches] = useState<BranchInfo[]>([]);
const [aheadCount, setAheadCount] = useState(0);
const [behindCount, setBehindCount] = useState(0);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [branchFilter, setBranchFilter] = useState("");
const [runningDevServers, setRunningDevServers] = useState<
Map<string, DevServerInfo>
>(new Map());
const [defaultEditorName, setDefaultEditorName] = useState<string>("Editor");
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
const fetchWorktrees = useCallback(async () => {
if (!projectPath) return;
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listAll) {
console.warn("Worktree API not available");
return;
}
const result = await api.worktree.listAll(projectPath, true);
if (result.success && result.worktrees) {
setWorktrees(result.worktrees);
setWorktreesInStore(projectPath, result.worktrees);
}
} catch (error) {
console.error("Failed to fetch worktrees:", error);
} finally {
setIsLoading(false);
}
}, [projectPath, setWorktreesInStore]);
const fetchDevServers = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.listDevServers) {
return;
}
const result = await api.worktree.listDevServers();
if (result.success && result.result?.servers) {
const serversMap = new Map<string, DevServerInfo>();
for (const server of result.result.servers) {
serversMap.set(server.worktreePath, server);
}
setRunningDevServers(serversMap);
}
} catch (error) {
console.error("Failed to fetch dev servers:", error);
}
}, []);
const fetchDefaultEditor = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.getDefaultEditor) {
return;
}
const result = await api.worktree.getDefaultEditor();
if (result.success && result.result?.editorName) {
setDefaultEditorName(result.result.editorName);
}
} catch (error) {
console.error("Failed to fetch default editor:", error);
}
}, []);
const fetchBranches = useCallback(async (worktreePath: string) => {
setIsLoadingBranches(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
console.warn("List branches API not available");
return;
}
const result = await api.worktree.listBranches(worktreePath);
if (result.success && result.result) {
setBranches(result.result.branches);
setAheadCount(result.result.aheadCount || 0);
setBehindCount(result.result.behindCount || 0);
}
} catch (error) {
console.error("Failed to fetch branches:", error);
} finally {
setIsLoadingBranches(false);
}
}, []);
useEffect(() => {
fetchWorktrees();
fetchDevServers();
fetchDefaultEditor();
}, [fetchWorktrees, fetchDevServers, fetchDefaultEditor]);
// Refresh when refreshTrigger changes (but skip the initial render)
useEffect(() => {
if (refreshTrigger > 0) {
fetchWorktrees();
}
}, [refreshTrigger, fetchWorktrees]);
// Initialize selection to main if not set OR if the stored worktree no longer exists
// This handles stale data (e.g., a worktree that was deleted)
useEffect(() => {
if (worktrees.length > 0) {
const currentPath = currentWorktree?.path;
// Check if the currently selected worktree still exists
// null path means main (which always exists if worktrees has items)
// Non-null path means we need to verify it exists in the worktrees list
const currentWorktreeExists = currentPath === null
? true
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
// Reset to main if:
// 1. No worktree is set (currentWorktree is null/undefined)
// 2. Current worktree has a path that doesn't exist in the list (stale data)
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
const mainWorktree = worktrees.find((w) => w.isMain);
const mainBranch = mainWorktree?.branch || "main";
setCurrentWorktree(projectPath, null, mainBranch); // null = main worktree
}
}
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
const handleSelectWorktree = async (worktree: WorktreeInfo) => {
// Simply select the worktree in the UI with both path and branch
setCurrentWorktree(
projectPath,
worktree.isMain ? null : worktree.path,
worktree.branch
);
};
const handleStartDevServer = async (worktree: WorktreeInfo) => {
if (isStartingDevServer) return;
setIsStartingDevServer(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.startDevServer) {
toast.error("Start dev server API not available");
return;
}
// Use projectPath for main, worktree.path for others
const targetPath = worktree.isMain ? projectPath : worktree.path;
const result = await api.worktree.startDevServer(projectPath, targetPath);
if (result.success && result.result) {
// Update running servers map (normalize path for cross-platform compatibility)
setRunningDevServers((prev) => {
const next = new Map(prev);
next.set(normalizePath(targetPath), {
worktreePath: result.result!.worktreePath,
port: result.result!.port,
url: result.result!.url,
});
return next;
});
toast.success(`Dev server started on port ${result.result.port}`);
} else {
toast.error(result.error || "Failed to start dev server");
}
} catch (error) {
console.error("Start dev server failed:", error);
toast.error("Failed to start dev server");
} finally {
setIsStartingDevServer(false);
}
};
const handleStopDevServer = async (worktree: WorktreeInfo) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.stopDevServer) {
toast.error("Stop dev server API not available");
return;
}
// Use projectPath for main, worktree.path for others
const targetPath = worktree.isMain ? projectPath : worktree.path;
const result = await api.worktree.stopDevServer(targetPath);
if (result.success) {
// Update running servers map (normalize path for cross-platform compatibility)
setRunningDevServers((prev) => {
const next = new Map(prev);
next.delete(normalizePath(targetPath));
return next;
});
toast.success(result.result?.message || "Dev server stopped");
} else {
toast.error(result.error || "Failed to stop dev server");
}
} catch (error) {
console.error("Stop dev server failed:", error);
toast.error("Failed to stop dev server");
}
};
const handleOpenDevServerUrl = (worktree: WorktreeInfo) => {
const targetPath = worktree.isMain ? projectPath : worktree.path;
const serverInfo = runningDevServers.get(targetPath);
if (serverInfo) {
window.open(serverInfo.url, "_blank");
}
};
// Helper to get the path key for a worktree (for looking up in runningDevServers)
// Normalizes path for cross-platform compatibility
const getWorktreeKey = (worktree: WorktreeInfo) => {
const path = worktree.isMain ? projectPath : worktree.path;
return path ? normalizePath(path) : path;
};
// Helper to check if a worktree has running features
const hasRunningFeatures = (worktree: WorktreeInfo) => {
if (runningFeatureIds.length === 0) return false;
const worktreeKey = getWorktreeKey(worktree);
// Check if any running feature belongs to this worktree
return runningFeatureIds.some((featureId) => {
const feature = features.find((f) => f.id === featureId);
if (!feature) return false;
// First, check if worktreePath is set and matches
// Use pathsEqual for cross-platform compatibility (Windows uses backslashes)
if (feature.worktreePath) {
if (worktree.isMain) {
// Feature has worktreePath - show on main only if it matches projectPath
return pathsEqual(feature.worktreePath, projectPath);
}
// For non-main worktrees, check if worktreePath matches
return pathsEqual(feature.worktreePath, worktreeKey);
}
// If worktreePath is not set, use branchName as fallback
if (feature.branchName) {
// Feature has a branchName - show spinner on the worktree with matching branch
return worktree.branch === feature.branchName;
}
// No worktreePath and no branchName - default to main
return worktree.isMain;
});
};
const handleOpenInEditor = async (worktree: WorktreeInfo) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.openInEditor) {
console.warn("Open in editor API not available");
return;
}
const result = await api.worktree.openInEditor(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
} else if (result.error) {
toast.error(result.error);
}
} catch (error) {
console.error("Open in editor failed:", error);
}
};
const handleSwitchBranch = async (
worktree: WorktreeInfo,
branchName: string
) => {
if (isSwitching || branchName === worktree.branch) return;
setIsSwitching(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.switchBranch) {
toast.error("Switch branch API not available");
return;
}
const result = await api.worktree.switchBranch(worktree.path, branchName);
if (result.success && result.result) {
toast.success(result.result.message);
// Refresh worktrees to get updated branch info
fetchWorktrees();
} else {
toast.error(result.error || "Failed to switch branch");
}
} catch (error) {
console.error("Switch branch failed:", error);
toast.error("Failed to switch branch");
} finally {
setIsSwitching(false);
}
};
const handlePull = async (worktree: WorktreeInfo) => {
if (isPulling) return;
setIsPulling(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.pull) {
toast.error("Pull API not available");
return;
}
const result = await api.worktree.pull(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
// Refresh worktrees to get updated status
fetchWorktrees();
} else {
toast.error(result.error || "Failed to pull latest changes");
}
} catch (error) {
console.error("Pull failed:", error);
toast.error("Failed to pull latest changes");
} finally {
setIsPulling(false);
}
};
const handlePush = async (worktree: WorktreeInfo) => {
if (isPushing) return;
setIsPushing(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.push) {
toast.error("Push API not available");
return;
}
const result = await api.worktree.push(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
// Refresh to update ahead/behind counts
fetchBranches(worktree.path);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to push changes");
}
} catch (error) {
console.error("Push failed:", error);
toast.error("Failed to push changes");
} finally {
setIsPushing(false);
}
};
// The "selected" worktree is based on UI state, not git's current branch
// currentWorktree.path is null for main, or the worktree path for others
const currentWorktreePath = currentWorktree?.path ?? null;
const selectedWorktree = currentWorktreePath
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
: worktrees.find((w) => w.isMain);
// Render a worktree tab with branch selector (for main) and actions dropdown
const renderWorktreeTab = (worktree: WorktreeInfo) => {
// Selection is based on UI state, not git's current branch
// Default to main selected if currentWorktree is null/undefined or path is null
const isSelected = worktree.isMain
? currentWorktree === null ||
currentWorktree === undefined ||
currentWorktree.path === null
: pathsEqual(worktree.path, currentWorktreePath);
const isRunning = hasRunningFeatures(worktree);
return (
<div key={worktree.path} className="flex items-center">
{/* Main branch: clickable button + separate branch switch dropdown */}
{worktree.isMain ? (
<>
{/* Clickable button to select/preview main */}
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
onClick={() => handleSelectWorktree(worktree)}
disabled={isActivating}
title="Click to preview main"
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && (
<RefreshCw className="w-3 h-3 animate-spin" />
)}
{worktree.branch}
{worktree.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{worktree.changedFilesCount}
</span>
)}
</Button>
{/* Branch switch dropdown button */}
<DropdownMenu
onOpenChange={(open) => {
if (open) {
fetchBranches(worktree.path);
setBranchFilter("");
}
}}
>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
title="Switch branch"
>
<GitBranch className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-xs">
Switch Branch
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Search input */}
<div className="px-2 py-1.5">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder="Filter branches..."
value={branchFilter}
onChange={(e) => setBranchFilter(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
onKeyUp={(e) => e.stopPropagation()}
onKeyPress={(e) => e.stopPropagation()}
className="h-7 pl-7 text-xs"
autoFocus
/>
</div>
</div>
<DropdownMenuSeparator />
<div className="max-h-[250px] overflow-y-auto">
{isLoadingBranches ? (
<DropdownMenuItem disabled className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
Loading branches...
</DropdownMenuItem>
) : (
(() => {
const filteredBranches = branches.filter((b) =>
b.name
.toLowerCase()
.includes(branchFilter.toLowerCase())
);
if (filteredBranches.length === 0) {
return (
<DropdownMenuItem disabled className="text-xs">
{branchFilter
? "No matching branches"
: "No branches found"}
</DropdownMenuItem>
);
}
return filteredBranches.map((branch) => (
<DropdownMenuItem
key={branch.name}
onClick={() =>
handleSwitchBranch(worktree, branch.name)
}
disabled={
isSwitching || branch.name === worktree.branch
}
className="text-xs font-mono"
>
{branch.name === worktree.branch ? (
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
) : (
<span className="w-3.5 mr-2 flex-shrink-0" />
)}
<span className="truncate">{branch.name}</span>
</DropdownMenuItem>
));
})()
)}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onCreateBranch(worktree)}
className="text-xs"
>
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
Create New Branch...
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
// Non-main branches - click to switch to this branch
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary",
!worktree.hasWorktree && !isSelected && "opacity-70" // Dim if no active worktree
)}
onClick={() => handleSelectWorktree(worktree)}
disabled={isActivating}
title={
worktree.hasWorktree
? "Click to switch to this worktree's branch"
: "Click to switch to this branch"
}
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && (
<RefreshCw className="w-3 h-3 animate-spin" />
)}
{worktree.branch}
{worktree.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{worktree.changedFilesCount}
</span>
)}
</Button>
)}
{/* Dev server indicator */}
{runningDevServers.has(getWorktreeKey(worktree)) && (
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary",
"text-green-500"
)}
onClick={() => handleOpenDevServerUrl(worktree)}
title={`Open dev server (port ${
runningDevServers.get(getWorktreeKey(worktree))?.port
})`}
>
<Globe className="w-3 h-3" />
</Button>
)}
{/* Actions dropdown */}
<DropdownMenu
onOpenChange={(open) => {
if (open) {
fetchBranches(worktree.path);
}
}}
>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-l-none",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
>
<MoreHorizontal className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{/* Dev server controls */}
{runningDevServers.has(getWorktreeKey(worktree)) ? (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Dev Server Running (:
{runningDevServers.get(getWorktreeKey(worktree))?.port})
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleOpenDevServerUrl(worktree)}
className="text-xs"
>
<Globe className="w-3.5 h-3.5 mr-2" />
Open in Browser
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleStopDevServer(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<Square className="w-3.5 h-3.5 mr-2" />
Stop Dev Server
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : (
<>
<DropdownMenuItem
onClick={() => handleStartDevServer(worktree)}
disabled={isStartingDevServer}
className="text-xs"
>
<Play
className={cn(
"w-3.5 h-3.5 mr-2",
isStartingDevServer && "animate-pulse"
)}
/>
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{/* Pull option */}
<DropdownMenuItem
onClick={() => handlePull(worktree)}
disabled={isPulling}
className="text-xs"
>
<Download
className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")}
/>
{isPulling ? "Pulling..." : "Pull"}
{behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind
</span>
)}
</DropdownMenuItem>
{/* Push option */}
<DropdownMenuItem
onClick={() => handlePush(worktree)}
disabled={isPushing || aheadCount === 0}
className="text-xs"
>
<Upload
className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")}
/>
{isPushing ? "Pushing..." : "Push"}
{aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Open in editor */}
<DropdownMenuItem
onClick={() => handleOpenInEditor(worktree)}
className="text-xs"
>
<ExternalLink className="w-3.5 h-3.5 mr-2" />
Open in {defaultEditorName}
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Commit changes */}
{worktree.hasChanges && (
<DropdownMenuItem
onClick={() => onCommit(worktree)}
className="text-xs"
>
<GitCommit className="w-3.5 h-3.5 mr-2" />
Commit Changes
</DropdownMenuItem>
)}
{/* Show PR option if not on main branch, or if on main with changes */}
{(worktree.branch !== "main" || worktree.hasChanges) && (
<DropdownMenuItem
onClick={() => onCreatePR(worktree)}
className="text-xs"
>
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
Create Pull Request
</DropdownMenuItem>
)}
{/* Only show delete for non-main worktrees */}
{!worktree.isMain && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDeleteWorktree(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<Trash2 className="w-3.5 h-3.5 mr-2" />
Delete Worktree
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
// Don't render the worktree selector if the feature is disabled
if (!useWorktreesEnabled) {
return null;
}
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
{/* Worktree Tabs */}
<div className="flex items-center gap-1 flex-wrap">
{worktrees.map((worktree) => renderWorktreeTab(worktree))}
{/* Add Worktree Button */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
{/* Refresh Button */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={fetchWorktrees}
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
/>
</Button>
</div>
</div>
);
}

View File

@@ -11,6 +11,7 @@ import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import { useAutoMode } from "@/hooks/use-auto-mode";
import { truncateDescription } from "@/lib/utils";
import { getBlockingDependencies } from "@/lib/dependency-resolver";
interface UseBoardActionsProps {
currentProject: { path: string; id: string } | null;
@@ -74,6 +75,7 @@ export function useBoardActions({
removeFeature,
moveFeature,
useWorktrees,
enableDependencyBlocking,
} = useAppStore();
const autoMode = useAutoMode();
@@ -344,6 +346,21 @@ export function useBoardActions({
return false;
}
// Check for blocking dependencies and show warning if enabled
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(", ");
toast.warning("Starting feature with incomplete dependencies", {
description: `This feature depends on: ${depDescriptions}`,
});
}
}
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
@@ -355,7 +372,7 @@ export function useBoardActions({
await handleRunFeature(feature);
return true;
},
[autoMode, updateFeature, persistFeatureUpdate, handleRunFeature]
[autoMode, enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
);
const handleVerifyFeature = useCallback(

View File

@@ -1,5 +1,6 @@
import { useMemo, useCallback } from "react";
import { Feature } from "@/store/app-store";
import { resolveDependencies } from "@/lib/dependency-resolver";
import { pathsEqual } from "@/lib/utils";
type ColumnId = Feature["status"];
@@ -105,12 +106,13 @@ export function useBoardColumnFeatures({
}
});
// Sort backlog by priority: 1 (high) -> 2 (medium) -> 3 (low) -> no priority
map.backlog.sort((a, b) => {
const aPriority = a.priority ?? 999; // Features without priority go last
const bPriority = b.priority ?? 999;
return aPriority - bPriority;
});
// Apply dependency-aware sorting to backlog
// This ensures features appear in dependency order (dependencies before dependents)
// Within the same dependency level, features are sorted by priority
if (map.backlog.length > 0) {
const { orderedFeatures } = resolveDependencies(map.backlog);
map.backlog = orderedFeatures;
}
return map;
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);

View File

@@ -0,0 +1,122 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
GitBranch,
RefreshCw,
GitBranchPlus,
Check,
Search,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { WorktreeInfo, BranchInfo } from "../types";
interface BranchSwitchDropdownProps {
worktree: WorktreeInfo;
isSelected: boolean;
branches: BranchInfo[];
filteredBranches: BranchInfo[];
branchFilter: string;
isLoadingBranches: boolean;
isSwitching: boolean;
onOpenChange: (open: boolean) => void;
onFilterChange: (value: string) => void;
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
}
export function BranchSwitchDropdown({
worktree,
isSelected,
filteredBranches,
branchFilter,
isLoadingBranches,
isSwitching,
onOpenChange,
onFilterChange,
onSwitchBranch,
onCreateBranch,
}: BranchSwitchDropdownProps) {
return (
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
title="Switch branch"
>
<GitBranch className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-xs">Switch Branch</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-2 py-1.5">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder="Filter branches..."
value={branchFilter}
onChange={(e) => onFilterChange(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
onKeyUp={(e) => e.stopPropagation()}
onKeyPress={(e) => e.stopPropagation()}
className="h-7 pl-7 text-xs"
autoFocus
/>
</div>
</div>
<DropdownMenuSeparator />
<div className="max-h-[250px] overflow-y-auto">
{isLoadingBranches ? (
<DropdownMenuItem disabled className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
Loading branches...
</DropdownMenuItem>
) : filteredBranches.length === 0 ? (
<DropdownMenuItem disabled className="text-xs">
{branchFilter ? "No matching branches" : "No branches found"}
</DropdownMenuItem>
) : (
filteredBranches.map((branch) => (
<DropdownMenuItem
key={branch.name}
onClick={() => onSwitchBranch(worktree, branch.name)}
disabled={isSwitching || branch.name === worktree.branch}
className="text-xs font-mono"
>
{branch.name === worktree.branch ? (
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
) : (
<span className="w-3.5 mr-2 flex-shrink-0" />
)}
<span className="truncate">{branch.name}</span>
</DropdownMenuItem>
))
)}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onCreateBranch(worktree)}
className="text-xs"
>
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
Create New Branch...
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,3 @@
export { BranchSwitchDropdown } from "./branch-switch-dropdown";
export { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
export { WorktreeTab } from "./worktree-tab";

View File

@@ -0,0 +1,193 @@
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
Trash2,
MoreHorizontal,
GitCommit,
GitPullRequest,
ExternalLink,
Download,
Upload,
Play,
Square,
Globe,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { WorktreeInfo, DevServerInfo } from "../types";
interface WorktreeActionsDropdownProps {
worktree: WorktreeInfo;
isSelected: boolean;
defaultEditorName: string;
aheadCount: number;
behindCount: number;
isPulling: boolean;
isPushing: boolean;
isStartingDevServer: boolean;
isDevServerRunning: boolean;
devServerInfo?: DevServerInfo;
onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
}
export function WorktreeActionsDropdown({
worktree,
isSelected,
defaultEditorName,
aheadCount,
behindCount,
isPulling,
isPushing,
isStartingDevServer,
isDevServerRunning,
devServerInfo,
onOpenChange,
onPull,
onPush,
onOpenInEditor,
onCommit,
onCreatePR,
onDeleteWorktree,
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
}: WorktreeActionsDropdownProps) {
return (
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-l-none",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
>
<MoreHorizontal className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{isDevServerRunning ? (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Dev Server Running (:{devServerInfo?.port})
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => onOpenDevServerUrl(worktree)}
className="text-xs"
>
<Globe className="w-3.5 h-3.5 mr-2" />
Open in Browser
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onStopDevServer(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<Square className="w-3.5 h-3.5 mr-2" />
Stop Dev Server
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : (
<>
<DropdownMenuItem
onClick={() => onStartDevServer(worktree)}
disabled={isStartingDevServer}
className="text-xs"
>
<Play
className={cn(
"w-3.5 h-3.5 mr-2",
isStartingDevServer && "animate-pulse"
)}
/>
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => onPull(worktree)}
disabled={isPulling}
className="text-xs"
>
<Download
className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")}
/>
{isPulling ? "Pulling..." : "Pull"}
{behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onPush(worktree)}
disabled={isPushing || aheadCount === 0}
className="text-xs"
>
<Upload
className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")}
/>
{isPushing ? "Pushing..." : "Push"}
{aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onOpenInEditor(worktree)}
className="text-xs"
>
<ExternalLink className="w-3.5 h-3.5 mr-2" />
Open in {defaultEditorName}
</DropdownMenuItem>
<DropdownMenuSeparator />
{worktree.hasChanges && (
<DropdownMenuItem onClick={() => onCommit(worktree)} className="text-xs">
<GitCommit className="w-3.5 h-3.5 mr-2" />
Commit Changes
</DropdownMenuItem>
)}
{(worktree.branch !== "main" || worktree.hasChanges) && (
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
Create Pull Request
</DropdownMenuItem>
)}
{!worktree.isMain && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDeleteWorktree(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<Trash2 className="w-3.5 h-3.5 mr-2" />
Delete Worktree
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,191 @@
import { Button } from "@/components/ui/button";
import { RefreshCw, Globe, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { WorktreeInfo, BranchInfo, DevServerInfo } from "../types";
import { BranchSwitchDropdown } from "./branch-switch-dropdown";
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
interface WorktreeTabProps {
worktree: WorktreeInfo;
isSelected: boolean;
isRunning: boolean;
isActivating: boolean;
isDevServerRunning: boolean;
devServerInfo?: DevServerInfo;
defaultEditorName: string;
branches: BranchInfo[];
filteredBranches: BranchInfo[];
branchFilter: string;
isLoadingBranches: boolean;
isSwitching: boolean;
isPulling: boolean;
isPushing: boolean;
isStartingDevServer: boolean;
aheadCount: number;
behindCount: number;
onSelectWorktree: (worktree: WorktreeInfo) => void;
onBranchDropdownOpenChange: (open: boolean) => void;
onActionsDropdownOpenChange: (open: boolean) => void;
onBranchFilterChange: (value: string) => void;
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
}
export function WorktreeTab({
worktree,
isSelected,
isRunning,
isActivating,
isDevServerRunning,
devServerInfo,
defaultEditorName,
branches,
filteredBranches,
branchFilter,
isLoadingBranches,
isSwitching,
isPulling,
isPushing,
isStartingDevServer,
aheadCount,
behindCount,
onSelectWorktree,
onBranchDropdownOpenChange,
onActionsDropdownOpenChange,
onBranchFilterChange,
onSwitchBranch,
onCreateBranch,
onPull,
onPush,
onOpenInEditor,
onCommit,
onCreatePR,
onDeleteWorktree,
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
}: WorktreeTabProps) {
return (
<div className="flex items-center">
{worktree.isMain ? (
<>
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
onClick={() => onSelectWorktree(worktree)}
disabled={isActivating}
title="Click to preview main"
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && (
<RefreshCw className="w-3 h-3 animate-spin" />
)}
{worktree.branch}
{worktree.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{worktree.changedFilesCount}
</span>
)}
</Button>
<BranchSwitchDropdown
worktree={worktree}
isSelected={isSelected}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
onOpenChange={onBranchDropdownOpenChange}
onFilterChange={onBranchFilterChange}
onSwitchBranch={onSwitchBranch}
onCreateBranch={onCreateBranch}
/>
</>
) : (
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary",
!worktree.hasWorktree && !isSelected && "opacity-70"
)}
onClick={() => onSelectWorktree(worktree)}
disabled={isActivating}
title={
worktree.hasWorktree
? "Click to switch to this worktree's branch"
: "Click to switch to this branch"
}
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && (
<RefreshCw className="w-3 h-3 animate-spin" />
)}
{worktree.branch}
{worktree.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{worktree.changedFilesCount}
</span>
)}
</Button>
)}
{isDevServerRunning && (
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary",
"text-green-500"
)}
onClick={() => onOpenDevServerUrl(worktree)}
title={`Open dev server (port ${devServerInfo?.port})`}
>
<Globe className="w-3 h-3" />
</Button>
)}
<WorktreeActionsDropdown
worktree={worktree}
isSelected={isSelected}
defaultEditorName={defaultEditorName}
aheadCount={aheadCount}
behindCount={behindCount}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
isDevServerRunning={isDevServerRunning}
devServerInfo={devServerInfo}
onOpenChange={onActionsDropdownOpenChange}
onPull={onPull}
onPush={onPush}
onOpenInEditor={onOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={onStartDevServer}
onStopDevServer={onStopDevServer}
onOpenDevServerUrl={onOpenDevServerUrl}
/>
</div>
);
}

View File

@@ -0,0 +1,6 @@
export { useWorktrees } from "./use-worktrees";
export { useDevServers } from "./use-dev-servers";
export { useBranches } from "./use-branches";
export { useWorktreeActions } from "./use-worktree-actions";
export { useDefaultEditor } from "./use-default-editor";
export { useRunningFeatures } from "./use-running-features";

View File

@@ -0,0 +1,53 @@
import { useState, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import type { BranchInfo } from "../types";
export function useBranches() {
const [branches, setBranches] = useState<BranchInfo[]>([]);
const [aheadCount, setAheadCount] = useState(0);
const [behindCount, setBehindCount] = useState(0);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [branchFilter, setBranchFilter] = useState("");
const fetchBranches = useCallback(async (worktreePath: string) => {
setIsLoadingBranches(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
console.warn("List branches API not available");
return;
}
const result = await api.worktree.listBranches(worktreePath);
if (result.success && result.result) {
setBranches(result.result.branches);
setAheadCount(result.result.aheadCount || 0);
setBehindCount(result.result.behindCount || 0);
}
} catch (error) {
console.error("Failed to fetch branches:", error);
} finally {
setIsLoadingBranches(false);
}
}, []);
const resetBranchFilter = useCallback(() => {
setBranchFilter("");
}, []);
const filteredBranches = branches.filter((b) =>
b.name.toLowerCase().includes(branchFilter.toLowerCase())
);
return {
branches,
filteredBranches,
aheadCount,
behindCount,
isLoadingBranches,
branchFilter,
setBranchFilter,
resetBranchFilter,
fetchBranches,
};
}

View File

@@ -0,0 +1,30 @@
import { useState, useEffect, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
export function useDefaultEditor() {
const [defaultEditorName, setDefaultEditorName] = useState<string>("Editor");
const fetchDefaultEditor = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.getDefaultEditor) {
return;
}
const result = await api.worktree.getDefaultEditor();
if (result.success && result.result?.editorName) {
setDefaultEditorName(result.result.editorName);
}
} catch (error) {
console.error("Failed to fetch default editor:", error);
}
}, []);
useEffect(() => {
fetchDefaultEditor();
}, [fetchDefaultEditor]);
return {
defaultEditorName,
};
}

View File

@@ -0,0 +1,153 @@
import { useState, useEffect, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { normalizePath } from "@/lib/utils";
import { toast } from "sonner";
import type { DevServerInfo, WorktreeInfo } from "../types";
interface UseDevServersOptions {
projectPath: string;
}
export function useDevServers({ projectPath }: UseDevServersOptions) {
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(
new Map()
);
const fetchDevServers = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.listDevServers) {
return;
}
const result = await api.worktree.listDevServers();
if (result.success && result.result?.servers) {
const serversMap = new Map<string, DevServerInfo>();
for (const server of result.result.servers) {
serversMap.set(server.worktreePath, server);
}
setRunningDevServers(serversMap);
}
} catch (error) {
console.error("Failed to fetch dev servers:", error);
}
}, []);
useEffect(() => {
fetchDevServers();
}, [fetchDevServers]);
const getWorktreeKey = useCallback(
(worktree: WorktreeInfo) => {
const path = worktree.isMain ? projectPath : worktree.path;
return path ? normalizePath(path) : path;
},
[projectPath]
);
const handleStartDevServer = useCallback(
async (worktree: WorktreeInfo) => {
if (isStartingDevServer) return;
setIsStartingDevServer(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.startDevServer) {
toast.error("Start dev server API not available");
return;
}
const targetPath = worktree.isMain ? projectPath : worktree.path;
const result = await api.worktree.startDevServer(projectPath, targetPath);
if (result.success && result.result) {
setRunningDevServers((prev) => {
const next = new Map(prev);
next.set(normalizePath(targetPath), {
worktreePath: result.result!.worktreePath,
port: result.result!.port,
url: result.result!.url,
});
return next;
});
toast.success(`Dev server started on port ${result.result.port}`);
} else {
toast.error(result.error || "Failed to start dev server");
}
} catch (error) {
console.error("Start dev server failed:", error);
toast.error("Failed to start dev server");
} finally {
setIsStartingDevServer(false);
}
},
[isStartingDevServer, projectPath]
);
const handleStopDevServer = useCallback(
async (worktree: WorktreeInfo) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.stopDevServer) {
toast.error("Stop dev server API not available");
return;
}
const targetPath = worktree.isMain ? projectPath : worktree.path;
const result = await api.worktree.stopDevServer(targetPath);
if (result.success) {
setRunningDevServers((prev) => {
const next = new Map(prev);
next.delete(normalizePath(targetPath));
return next;
});
toast.success(result.result?.message || "Dev server stopped");
} else {
toast.error(result.error || "Failed to stop dev server");
}
} catch (error) {
console.error("Stop dev server failed:", error);
toast.error("Failed to stop dev server");
}
},
[projectPath]
);
const handleOpenDevServerUrl = useCallback(
(worktree: WorktreeInfo) => {
const targetPath = worktree.isMain ? projectPath : worktree.path;
const serverInfo = runningDevServers.get(targetPath);
if (serverInfo) {
window.open(serverInfo.url, "_blank");
}
},
[projectPath, runningDevServers]
);
const isDevServerRunning = useCallback(
(worktree: WorktreeInfo) => {
return runningDevServers.has(getWorktreeKey(worktree));
},
[runningDevServers, getWorktreeKey]
);
const getDevServerInfo = useCallback(
(worktree: WorktreeInfo) => {
return runningDevServers.get(getWorktreeKey(worktree));
},
[runningDevServers, getWorktreeKey]
);
return {
isStartingDevServer,
runningDevServers,
getWorktreeKey,
isDevServerRunning,
getDevServerInfo,
handleStartDevServer,
handleStopDevServer,
handleOpenDevServerUrl,
};
}

View File

@@ -0,0 +1,49 @@
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);
}
if (feature.branchName) {
return worktree.branch === feature.branchName;
}
return worktree.isMain;
});
},
[runningFeatureIds, features, projectPath, getWorktreeKey]
);
return {
hasRunningFeatures,
};
}

View File

@@ -0,0 +1,132 @@
import { useState, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import type { WorktreeInfo } from "../types";
interface UseWorktreeActionsOptions {
fetchWorktrees: () => Promise<void>;
fetchBranches: (worktreePath: string) => Promise<void>;
}
export function useWorktreeActions({
fetchWorktrees,
fetchBranches,
}: UseWorktreeActionsOptions) {
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isSwitching, setIsSwitching] = useState(false);
const [isActivating, setIsActivating] = useState(false);
const handleSwitchBranch = useCallback(
async (worktree: WorktreeInfo, branchName: string) => {
if (isSwitching || branchName === worktree.branch) return;
setIsSwitching(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.switchBranch) {
toast.error("Switch branch API not available");
return;
}
const result = await api.worktree.switchBranch(worktree.path, branchName);
if (result.success && result.result) {
toast.success(result.result.message);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to switch branch");
}
} catch (error) {
console.error("Switch branch failed:", error);
toast.error("Failed to switch branch");
} finally {
setIsSwitching(false);
}
},
[isSwitching, fetchWorktrees]
);
const handlePull = useCallback(
async (worktree: WorktreeInfo) => {
if (isPulling) return;
setIsPulling(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.pull) {
toast.error("Pull API not available");
return;
}
const result = await api.worktree.pull(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to pull latest changes");
}
} catch (error) {
console.error("Pull failed:", error);
toast.error("Failed to pull latest changes");
} finally {
setIsPulling(false);
}
},
[isPulling, fetchWorktrees]
);
const handlePush = useCallback(
async (worktree: WorktreeInfo) => {
if (isPushing) return;
setIsPushing(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.push) {
toast.error("Push API not available");
return;
}
const result = await api.worktree.push(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
fetchBranches(worktree.path);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to push changes");
}
} catch (error) {
console.error("Push failed:", error);
toast.error("Failed to push changes");
} finally {
setIsPushing(false);
}
},
[isPushing, fetchBranches, fetchWorktrees]
);
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.openInEditor) {
console.warn("Open in editor API not available");
return;
}
const result = await api.worktree.openInEditor(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
} else if (result.error) {
toast.error(result.error);
}
} catch (error) {
console.error("Open in editor failed:", error);
}
}, []);
return {
isPulling,
isPushing,
isSwitching,
isActivating,
setIsActivating,
handleSwitchBranch,
handlePull,
handlePush,
handleOpenInEditor,
};
}

View File

@@ -0,0 +1,94 @@
import { useState, useEffect, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { pathsEqual } from "@/lib/utils";
import type { WorktreeInfo } from "../types";
interface UseWorktreesOptions {
projectPath: string;
refreshTrigger?: number;
}
export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOptions) {
const [isLoading, setIsLoading] = useState(false);
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
const fetchWorktrees = useCallback(async () => {
if (!projectPath) return;
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listAll) {
console.warn("Worktree API not available");
return;
}
const result = await api.worktree.listAll(projectPath, true);
if (result.success && result.worktrees) {
setWorktrees(result.worktrees);
setWorktreesInStore(projectPath, result.worktrees);
}
} catch (error) {
console.error("Failed to fetch worktrees:", error);
} finally {
setIsLoading(false);
}
}, [projectPath, setWorktreesInStore]);
useEffect(() => {
fetchWorktrees();
}, [fetchWorktrees]);
useEffect(() => {
if (refreshTrigger > 0) {
fetchWorktrees();
}
}, [refreshTrigger, fetchWorktrees]);
useEffect(() => {
if (worktrees.length > 0) {
const currentPath = currentWorktree?.path;
const currentWorktreeExists = currentPath === null
? true
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
const mainWorktree = worktrees.find((w) => w.isMain);
const mainBranch = mainWorktree?.branch || "main";
setCurrentWorktree(projectPath, null, mainBranch);
}
}
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
const handleSelectWorktree = useCallback(
(worktree: WorktreeInfo) => {
setCurrentWorktree(
projectPath,
worktree.isMain ? null : worktree.path,
worktree.branch
);
},
[projectPath, setCurrentWorktree]
);
const currentWorktreePath = currentWorktree?.path ?? null;
const selectedWorktree = currentWorktreePath
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
: worktrees.find((w) => w.isMain);
return {
isLoading,
worktrees,
currentWorktree,
currentWorktreePath,
selectedWorktree,
useWorktreesEnabled,
fetchWorktrees,
handleSelectWorktree,
};
}

View File

@@ -0,0 +1,8 @@
export { WorktreePanel } from "./worktree-panel";
export type {
WorktreeInfo,
BranchInfo,
DevServerInfo,
FeatureInfo,
WorktreePanelProps,
} from "./types";

View File

@@ -0,0 +1,39 @@
export interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
isCurrent: boolean;
hasWorktree: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
export interface BranchInfo {
name: string;
isCurrent: boolean;
isRemote: boolean;
}
export interface DevServerInfo {
worktreePath: string;
port: number;
url: string;
}
export interface FeatureInfo {
id: string;
worktreePath?: string;
branchName?: string;
}
export interface WorktreePanelProps {
projectPath: string;
onCreateWorktree: () => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
runningFeatureIds?: string[];
features?: FeatureInfo[];
refreshTrigger?: number;
}

View File

@@ -0,0 +1,176 @@
import { Button } from "@/components/ui/button";
import { GitBranch, Plus, RefreshCw } from "lucide-react";
import { cn, pathsEqual } from "@/lib/utils";
import type { WorktreePanelProps, WorktreeInfo } from "./types";
import {
useWorktrees,
useDevServers,
useBranches,
useWorktreeActions,
useDefaultEditor,
useRunningFeatures,
} from "./hooks";
import { WorktreeTab } from "./components";
export function WorktreePanel({
projectPath,
onCreateWorktree,
onDeleteWorktree,
onCommit,
onCreatePR,
onCreateBranch,
runningFeatureIds = [],
features = [],
refreshTrigger = 0,
}: WorktreePanelProps) {
const {
isLoading,
worktrees,
currentWorktree,
currentWorktreePath,
useWorktreesEnabled,
fetchWorktrees,
handleSelectWorktree,
} = useWorktrees({ projectPath, refreshTrigger });
const {
isStartingDevServer,
getWorktreeKey,
isDevServerRunning,
getDevServerInfo,
handleStartDevServer,
handleStopDevServer,
handleOpenDevServerUrl,
} = useDevServers({ projectPath });
const {
branches,
filteredBranches,
aheadCount,
behindCount,
isLoadingBranches,
branchFilter,
setBranchFilter,
resetBranchFilter,
fetchBranches,
} = useBranches();
const {
isPulling,
isPushing,
isSwitching,
isActivating,
handleSwitchBranch,
handlePull,
handlePush,
handleOpenInEditor,
} = useWorktreeActions({
fetchWorktrees,
fetchBranches,
});
const { defaultEditorName } = useDefaultEditor();
const { hasRunningFeatures } = useRunningFeatures({
projectPath,
runningFeatureIds,
features,
getWorktreeKey,
});
const isWorktreeSelected = (worktree: WorktreeInfo) => {
return worktree.isMain
? currentWorktree === null ||
currentWorktree === undefined ||
currentWorktree.path === null
: pathsEqual(worktree.path, currentWorktreePath);
};
const handleBranchDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
resetBranchFilter();
}
};
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
}
};
if (!useWorktreesEnabled) {
return null;
}
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
<div className="flex items-center gap-1 flex-wrap">
{worktrees.map((worktree) => (
<WorktreeTab
key={worktree.path}
worktree={worktree}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
))}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={fetchWorktrees}
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
/>
</Button>
</div>
</div>
);
}

View File

@@ -29,6 +29,8 @@ export function SettingsView() {
setProjectTheme,
defaultSkipTests,
setDefaultSkipTests,
enableDependencyBlocking,
setEnableDependencyBlocking,
useWorktrees,
setUseWorktrees,
showProfilesOnly,
@@ -117,9 +119,11 @@ export function SettingsView() {
<FeatureDefaultsSection
showProfilesOnly={showProfilesOnly}
defaultSkipTests={defaultSkipTests}
enableDependencyBlocking={enableDependencyBlocking}
useWorktrees={useWorktrees}
onShowProfilesOnlyChange={setShowProfilesOnly}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onUseWorktreesChange={setUseWorktrees}
/>
);

View File

@@ -1,23 +1,27 @@
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { FlaskConical, Settings2, TestTube, GitBranch } from "lucide-react";
import { FlaskConical, Settings2, TestTube, GitBranch, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
interface FeatureDefaultsSectionProps {
showProfilesOnly: boolean;
defaultSkipTests: boolean;
enableDependencyBlocking: boolean;
useWorktrees: boolean;
onShowProfilesOnlyChange: (value: boolean) => void;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
onUseWorktreesChange: (value: boolean) => void;
}
export function FeatureDefaultsSection({
showProfilesOnly,
defaultSkipTests,
enableDependencyBlocking,
useWorktrees,
onShowProfilesOnlyChange,
onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
onUseWorktreesChange,
}: FeatureDefaultsSectionProps) {
return (
@@ -102,6 +106,36 @@ export function FeatureDefaultsSection({
{/* Separator */}
<div className="border-t border-border/30" />
{/* Dependency Blocking 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
id="enable-dependency-blocking"
checked={enableDependencyBlocking}
onCheckedChange={(checked) =>
onEnableDependencyBlockingChange(checked === true)
}
className="mt-1"
data-testid="enable-dependency-blocking-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="enable-dependency-blocking"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<AlertCircle className="w-4 h-4 text-brand-500" />
Enable Dependency Blocking
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, features with incomplete dependencies will show blocked badges
and warnings. Auto mode and backlog ordering always respect dependencies
regardless of this setting.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Worktree Isolation 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

View File

@@ -30,7 +30,10 @@ export type Theme =
| "catppuccin"
| "onedark"
| "synthwave"
| "red";
| "red"
| "cream"
| "sunset"
| "gray";
export type KanbanDetailLevel = "minimal" | "standard" | "detailed";

View File

@@ -356,6 +356,81 @@ const redTheme: TerminalTheme = {
brightWhite: "#d0c0c0",
};
// Cream theme - Warm, soft, easy on the eyes
const creamTheme: TerminalTheme = {
background: "#f5f3ee",
foreground: "#5a4a3a",
cursor: "#9d6b53",
cursorAccent: "#f5f3ee",
selectionBackground: "#d4c4b0",
black: "#5a4a3a",
red: "#c85a4f",
green: "#7a9a6a",
yellow: "#c9a554",
blue: "#6b8aaa",
magenta: "#a66a8a",
cyan: "#5a9a8a",
white: "#b0a090",
brightBlack: "#8a7a6a",
brightRed: "#e07060",
brightGreen: "#90b080",
brightYellow: "#e0bb70",
brightBlue: "#80a0c0",
brightMagenta: "#c080a0",
brightCyan: "#70b0a0",
brightWhite: "#d0c0b0",
};
// Sunset theme - Mellow oranges and soft pastels
const sunsetTheme: TerminalTheme = {
background: "#1e1a24",
foreground: "#f2e8dd",
cursor: "#dd8855",
cursorAccent: "#1e1a24",
selectionBackground: "#3a2a40",
black: "#1e1a24",
red: "#dd6655",
green: "#88bb77",
yellow: "#ddaa66",
blue: "#6699cc",
magenta: "#cc7799",
cyan: "#66ccaa",
white: "#e8d8c8",
brightBlack: "#4a3a50",
brightRed: "#ee8866",
brightGreen: "#99cc88",
brightYellow: "#eebb77",
brightBlue: "#88aadd",
brightMagenta: "#dd88aa",
brightCyan: "#88ddbb",
brightWhite: "#f5e8dd",
};
// Gray theme - Modern, minimal gray scheme inspired by Cursor
const grayTheme: TerminalTheme = {
background: "#2a2d32",
foreground: "#d0d0d5",
cursor: "#8fa0c0",
cursorAccent: "#2a2d32",
selectionBackground: "#3a3f48",
black: "#2a2d32",
red: "#d87070",
green: "#78b088",
yellow: "#d0b060",
blue: "#7090c0",
magenta: "#a880b0",
cyan: "#60a0b0",
white: "#b0b0b8",
brightBlack: "#606068",
brightRed: "#e88888",
brightGreen: "#90c8a0",
brightYellow: "#e0c878",
brightBlue: "#90b0d8",
brightMagenta: "#c098c8",
brightCyan: "#80b8c8",
brightWhite: "#e0e0e8",
};
// Theme mapping
const terminalThemes: Record<ThemeMode, TerminalTheme> = {
light: lightTheme,
@@ -372,6 +447,9 @@ const terminalThemes: Record<ThemeMode, TerminalTheme> = {
onedark: onedarkTheme,
synthwave: synthwaveTheme,
red: redTheme,
cream: creamTheme,
sunset: sunsetTheme,
gray: grayTheme,
};
/**

View File

@@ -2,6 +2,8 @@ import {
type LucideIcon,
Atom,
Cat,
CloudSun,
Coffee,
Eclipse,
Flame,
Ghost,
@@ -10,6 +12,7 @@ import {
Radio,
Snowflake,
Sparkles,
Square,
Sun,
Terminal,
Trees,
@@ -92,4 +95,22 @@ export const themeOptions: ReadonlyArray<ThemeOption> = [
Icon: Heart,
testId: "red-mode-button",
},
{
value: "cream",
label: "Cream",
Icon: Coffee,
testId: "cream-mode-button",
},
{
value: "sunset",
label: "Sunset",
Icon: CloudSun,
testId: "sunset-mode-button",
},
{
value: "gray",
label: "Gray",
Icon: Square,
testId: "gray-mode-button",
},
];

View File

@@ -130,9 +130,16 @@ function getCurrentPhase(content: string): "planning" | "action" | "verification
/**
* Extracts a summary from completed feature context
* Looks for content between <summary> and </summary> tags
*/
function extractSummary(content: string): string | undefined {
// Look for summary sections - capture everything including subsections (###)
// Look for <summary> tags - capture everything between opening and closing tags
const summaryTagMatch = content.match(/<summary>([\s\S]*?)<\/summary>/i);
if (summaryTagMatch) {
return summaryTagMatch[1].trim();
}
// Fallback: Look for summary sections - capture everything including subsections (###)
// Stop at same-level ## sections (but not ###), or tool markers, or end
const summaryMatch = content.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i);
if (summaryMatch) {

View File

@@ -0,0 +1,221 @@
/**
* Dependency Resolution Utility
*
* Provides topological sorting and dependency analysis for features.
* Uses a modified Kahn's algorithm that respects both dependencies and priorities.
*/
import type { Feature } from "@/store/app-store";
export interface DependencyResolutionResult {
orderedFeatures: Feature[]; // Features in dependency-aware order
circularDependencies: string[][]; // Groups of IDs forming cycles
missingDependencies: Map<string, string[]>; // featureId -> missing dep IDs
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
}
/**
* Resolves feature dependencies using topological sort with priority-aware ordering.
*
* Algorithm:
* 1. Build dependency graph and detect missing/blocked dependencies
* 2. Apply Kahn's algorithm for topological sort
* 3. Within same dependency level, sort by priority (1=high, 2=medium, 3=low)
* 4. Detect circular dependencies for features that can't be ordered
*
* @param features - Array of features to order
* @returns Resolution result with ordered features and dependency metadata
*/
export function resolveDependencies(features: Feature[]): DependencyResolutionResult {
const featureMap = new Map<string, Feature>(features.map(f => [f.id, f]));
const inDegree = new Map<string, number>();
const adjacencyList = new Map<string, string[]>(); // dependencyId -> [dependentIds]
const missingDependencies = new Map<string, string[]>();
const blockedFeatures = new Map<string, string[]>();
// Initialize graph structures
for (const feature of features) {
inDegree.set(feature.id, 0);
adjacencyList.set(feature.id, []);
}
// Build dependency graph and detect missing/blocked dependencies
for (const feature of features) {
const deps = feature.dependencies || [];
for (const depId of deps) {
if (!featureMap.has(depId)) {
// Missing dependency - track it
if (!missingDependencies.has(feature.id)) {
missingDependencies.set(feature.id, []);
}
missingDependencies.get(feature.id)!.push(depId);
} else {
// Valid dependency - add edge to graph
adjacencyList.get(depId)!.push(feature.id);
inDegree.set(feature.id, (inDegree.get(feature.id) || 0) + 1);
// Check if dependency is incomplete (blocking)
const depFeature = featureMap.get(depId)!;
if (depFeature.status !== 'completed' && depFeature.status !== 'verified') {
if (!blockedFeatures.has(feature.id)) {
blockedFeatures.set(feature.id, []);
}
blockedFeatures.get(feature.id)!.push(depId);
}
}
}
}
// Kahn's algorithm with priority-aware selection
const queue: Feature[] = [];
const orderedFeatures: Feature[] = [];
// Helper to sort features by priority (lower number = higher priority)
const sortByPriority = (a: Feature, b: Feature) =>
(a.priority ?? 2) - (b.priority ?? 2);
// Start with features that have no dependencies (in-degree 0)
for (const [id, degree] of inDegree) {
if (degree === 0) {
queue.push(featureMap.get(id)!);
}
}
// Sort initial queue by priority
queue.sort(sortByPriority);
// Process features in topological order
while (queue.length > 0) {
// Take highest priority feature from queue
const current = queue.shift()!;
orderedFeatures.push(current);
// Process features that depend on this one
for (const dependentId of adjacencyList.get(current.id) || []) {
const currentDegree = inDegree.get(dependentId);
if (currentDegree === undefined) {
throw new Error(`In-degree not initialized for feature ${dependentId}`);
}
const newDegree = currentDegree - 1;
inDegree.set(dependentId, newDegree);
if (newDegree === 0) {
queue.push(featureMap.get(dependentId)!);
// Re-sort queue to maintain priority order
queue.sort(sortByPriority);
}
}
}
// Detect circular dependencies (features not in output = part of cycle)
const circularDependencies: string[][] = [];
const processedIds = new Set(orderedFeatures.map(f => f.id));
if (orderedFeatures.length < features.length) {
// Find cycles using DFS
const remaining = features.filter(f => !processedIds.has(f.id));
const cycles = detectCycles(remaining, featureMap);
circularDependencies.push(...cycles);
// Add remaining features at end (part of cycles)
orderedFeatures.push(...remaining);
}
return {
orderedFeatures,
circularDependencies,
missingDependencies,
blockedFeatures
};
}
/**
* Detects circular dependencies using depth-first search
*
* @param features - Features that couldn't be topologically sorted (potential cycles)
* @param featureMap - Map of all features by ID
* @returns Array of cycles, where each cycle is an array of feature IDs
*/
function detectCycles(
features: Feature[],
featureMap: Map<string, Feature>
): string[][] {
const cycles: string[][] = [];
const visited = new Set<string>();
const recursionStack = new Set<string>();
const currentPath: string[] = [];
function dfs(featureId: string): boolean {
visited.add(featureId);
recursionStack.add(featureId);
currentPath.push(featureId);
const feature = featureMap.get(featureId);
if (feature) {
for (const depId of feature.dependencies || []) {
if (!visited.has(depId)) {
if (dfs(depId)) return true;
} else if (recursionStack.has(depId)) {
// Found cycle - extract it
const cycleStart = currentPath.indexOf(depId);
cycles.push(currentPath.slice(cycleStart));
return true;
}
}
}
currentPath.pop();
recursionStack.delete(featureId);
return false;
}
for (const feature of features) {
if (!visited.has(feature.id)) {
dfs(feature.id);
}
}
return cycles;
}
/**
* Checks if a feature's dependencies are satisfied (all complete or verified)
*
* @param feature - Feature to check
* @param allFeatures - All features in the project
* @returns true if all dependencies are satisfied, false otherwise
*/
export function areDependenciesSatisfied(
feature: Feature,
allFeatures: Feature[]
): boolean {
if (!feature.dependencies || feature.dependencies.length === 0) {
return true; // No dependencies = always ready
}
return feature.dependencies.every(depId => {
const dep = allFeatures.find(f => f.id === depId);
return dep && (dep.status === 'completed' || dep.status === 'verified');
});
}
/**
* Gets the blocking dependencies for a feature (dependencies that are incomplete)
*
* @param feature - Feature to check
* @param allFeatures - All features in the project
* @returns Array of feature IDs that are blocking this feature
*/
export function getBlockingDependencies(
feature: Feature,
allFeatures: Feature[]
): string[] {
if (!feature.dependencies || feature.dependencies.length === 0) {
return [];
}
return feature.dependencies.filter(depId => {
const dep = allFeatures.find(f => f.id === depId);
return dep && dep.status !== 'completed' && dep.status !== 'verified';
});
}

View File

@@ -30,7 +30,10 @@ export type ThemeMode =
| "catppuccin"
| "onedark"
| "synthwave"
| "red";
| "red"
| "cream"
| "sunset"
| "gray";
export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
@@ -394,6 +397,7 @@ export interface AppState {
// Feature Default Settings
defaultSkipTests: boolean; // Default value for skip tests when creating new features
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
// Worktree Settings
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: false)
@@ -580,6 +584,7 @@ export interface AppActions {
// Feature Default Settings actions
setDefaultSkipTests: (skip: boolean) => void;
setEnableDependencyBlocking: (enabled: boolean) => void;
// Worktree Settings actions
setUseWorktrees: (enabled: boolean) => void;
@@ -750,6 +755,7 @@ const initialState: AppState = {
maxConcurrency: 3, // Default to 3 concurrent agents
kanbanCardDetailLevel: "standard", // Default to standard detail level
defaultSkipTests: true, // Default to manual verification (tests disabled)
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
useWorktrees: false, // Default to disabled (worktree feature is experimental)
currentWorktreeByProject: {},
worktreesByProject: {},
@@ -1341,6 +1347,7 @@ export const useAppStore = create<AppState & AppActions>()(
// Feature Default Settings actions
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
setEnableDependencyBlocking: (enabled) => set({ enableDependencyBlocking: enabled }),
// Worktree Settings actions
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
@@ -2232,6 +2239,7 @@ export const useAppStore = create<AppState & AppActions>()(
maxConcurrency: state.maxConcurrency,
autoModeByProject: state.autoModeByProject,
defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking,
useWorktrees: state.useWorktrees,
currentWorktreeByProject: state.currentWorktreeByProject,
worktreesByProject: state.worktreesByProject,

View File

@@ -13,6 +13,9 @@
@custom-variant onedark (&:is(.onedark *));
@custom-variant synthwave (&:is(.synthwave *));
@custom-variant red (&:is(.red *));
@custom-variant cream (&:is(.cream *));
@custom-variant sunset (&:is(.sunset *));
@custom-variant gray (&:is(.gray *));
@theme inline {
--color-background: var(--background);
@@ -1220,6 +1223,252 @@
--running-indicator-text: oklch(0.6 0.23 25);
}
.cream {
/* Cream Theme - Warm, soft, easy on the eyes */
--background: oklch(0.95 0.01 70); /* Warm cream background */
--background-50: oklch(0.95 0.01 70 / 0.5);
--background-80: oklch(0.95 0.01 70 / 0.8);
--foreground: oklch(0.25 0.02 60); /* Dark warm brown */
--foreground-secondary: oklch(0.45 0.02 60); /* Medium brown */
--foreground-muted: oklch(0.55 0.02 60); /* Light brown */
--card: oklch(0.98 0.005 70); /* Slightly lighter cream */
--card-foreground: oklch(0.25 0.02 60);
--popover: oklch(0.97 0.008 70);
--popover-foreground: oklch(0.25 0.02 60);
--primary: oklch(0.5 0.12 45); /* Warm terracotta/rust */
--primary-foreground: oklch(0.98 0.005 70);
--brand-400: oklch(0.55 0.12 45);
--brand-500: oklch(0.5 0.12 45); /* Terracotta */
--brand-600: oklch(0.45 0.13 45);
--secondary: oklch(0.88 0.02 70);
--secondary-foreground: oklch(0.25 0.02 60);
--muted: oklch(0.9 0.015 70);
--muted-foreground: oklch(0.45 0.02 60);
--accent: oklch(0.85 0.025 70);
--accent-foreground: oklch(0.25 0.02 60);
--destructive: oklch(0.55 0.22 25); /* Warm red */
--border: oklch(0.85 0.015 70);
--border-glass: oklch(0.5 0.12 45 / 0.2);
--input: oklch(0.98 0.005 70);
--ring: oklch(0.5 0.12 45);
--chart-1: oklch(0.5 0.12 45); /* Terracotta */
--chart-2: oklch(0.55 0.15 35); /* Burnt orange */
--chart-3: oklch(0.6 0.12 100); /* Olive */
--chart-4: oklch(0.5 0.15 20); /* Deep rust */
--chart-5: oklch(0.65 0.1 80); /* Golden */
--sidebar: oklch(0.93 0.012 70);
--sidebar-foreground: oklch(0.25 0.02 60);
--sidebar-primary: oklch(0.5 0.12 45);
--sidebar-primary-foreground: oklch(0.98 0.005 70);
--sidebar-accent: oklch(0.88 0.02 70);
--sidebar-accent-foreground: oklch(0.25 0.02 60);
--sidebar-border: oklch(0.85 0.015 70);
--sidebar-ring: oklch(0.5 0.12 45);
/* Action button colors - Warm earth tones */
--action-view: oklch(0.5 0.12 45); /* Terracotta */
--action-view-hover: oklch(0.45 0.13 45);
--action-followup: oklch(0.55 0.15 35); /* Burnt orange */
--action-followup-hover: oklch(0.5 0.16 35);
--action-commit: oklch(0.55 0.12 130); /* Sage green */
--action-commit-hover: oklch(0.5 0.13 130);
--action-verify: oklch(0.55 0.12 130); /* Sage green */
--action-verify-hover: oklch(0.5 0.13 130);
/* Running indicator - Terracotta */
--running-indicator: oklch(0.5 0.12 45);
--running-indicator-text: oklch(0.55 0.12 45);
/* Status colors - Cream theme */
--status-success: oklch(0.55 0.15 130);
--status-success-bg: oklch(0.55 0.15 130 / 0.15);
--status-warning: oklch(0.6 0.15 70);
--status-warning-bg: oklch(0.6 0.15 70 / 0.15);
--status-error: oklch(0.55 0.22 25);
--status-error-bg: oklch(0.55 0.22 25 / 0.15);
--status-info: oklch(0.5 0.15 230);
--status-info-bg: oklch(0.5 0.15 230 / 0.15);
--status-backlog: oklch(0.6 0.02 60);
--status-in-progress: oklch(0.6 0.15 70);
--status-waiting: oklch(0.58 0.13 50);
}
.sunset {
/* Sunset Theme - Mellow oranges and soft purples */
--background: oklch(0.15 0.02 280); /* Deep twilight blue-purple */
--background-50: oklch(0.15 0.02 280 / 0.5);
--background-80: oklch(0.15 0.02 280 / 0.8);
--foreground: oklch(0.95 0.01 80); /* Warm white */
--foreground-secondary: oklch(0.75 0.02 60);
--foreground-muted: oklch(0.6 0.02 60);
--card: oklch(0.2 0.025 280);
--card-foreground: oklch(0.95 0.01 80);
--popover: oklch(0.18 0.02 280);
--popover-foreground: oklch(0.95 0.01 80);
--primary: oklch(0.68 0.18 45); /* Mellow sunset orange */
--primary-foreground: oklch(0.15 0.02 280);
--brand-400: oklch(0.72 0.17 45);
--brand-500: oklch(0.68 0.18 45); /* Soft sunset orange */
--brand-600: oklch(0.64 0.19 42);
--secondary: oklch(0.25 0.03 280);
--secondary-foreground: oklch(0.95 0.01 80);
--muted: oklch(0.27 0.03 280);
--muted-foreground: oklch(0.6 0.02 60);
--accent: oklch(0.35 0.04 310);
--accent-foreground: oklch(0.95 0.01 80);
--destructive: oklch(0.6 0.2 25); /* Muted red */
--border: oklch(0.32 0.04 280);
--border-glass: oklch(0.68 0.18 45 / 0.3);
--input: oklch(0.2 0.025 280);
--ring: oklch(0.68 0.18 45);
--chart-1: oklch(0.68 0.18 45); /* Mellow orange */
--chart-2: oklch(0.75 0.16 340); /* Soft pink sunset */
--chart-3: oklch(0.78 0.18 70); /* Soft golden */
--chart-4: oklch(0.66 0.19 42); /* Subtle coral */
--chart-5: oklch(0.72 0.14 310); /* Pastel purple */
--sidebar: oklch(0.13 0.015 280);
--sidebar-foreground: oklch(0.95 0.01 80);
--sidebar-primary: oklch(0.68 0.18 45);
--sidebar-primary-foreground: oklch(0.15 0.02 280);
--sidebar-accent: oklch(0.25 0.03 280);
--sidebar-accent-foreground: oklch(0.95 0.01 80);
--sidebar-border: oklch(0.32 0.04 280);
--sidebar-ring: oklch(0.68 0.18 45);
/* Action button colors - Mellow sunset palette */
--action-view: oklch(0.68 0.18 45); /* Mellow orange */
--action-view-hover: oklch(0.64 0.19 42);
--action-followup: oklch(0.75 0.16 340); /* Soft pink */
--action-followup-hover: oklch(0.7 0.17 340);
--action-commit: oklch(0.65 0.16 140); /* Soft green */
--action-commit-hover: oklch(0.6 0.17 140);
--action-verify: oklch(0.65 0.16 140); /* Soft green */
--action-verify-hover: oklch(0.6 0.17 140);
/* Running indicator - Mellow orange */
--running-indicator: oklch(0.68 0.18 45);
--running-indicator-text: oklch(0.72 0.17 45);
/* Status colors - Sunset theme */
--status-success: oklch(0.65 0.16 140);
--status-success-bg: oklch(0.65 0.16 140 / 0.2);
--status-warning: oklch(0.78 0.18 70);
--status-warning-bg: oklch(0.78 0.18 70 / 0.2);
--status-error: oklch(0.65 0.2 25);
--status-error-bg: oklch(0.65 0.2 25 / 0.2);
--status-info: oklch(0.75 0.16 340);
--status-info-bg: oklch(0.75 0.16 340 / 0.2);
--status-backlog: oklch(0.65 0.02 280);
--status-in-progress: oklch(0.78 0.18 70);
--status-waiting: oklch(0.72 0.17 60);
}
.gray {
/* Gray Theme - Modern, minimal gray scheme inspired by Cursor */
--background: oklch(0.2 0.005 250); /* Medium-dark neutral gray */
--background-50: oklch(0.2 0.005 250 / 0.5);
--background-80: oklch(0.2 0.005 250 / 0.8);
--foreground: oklch(0.9 0.005 250); /* Light gray */
--foreground-secondary: oklch(0.65 0.005 250);
--foreground-muted: oklch(0.5 0.005 250);
--card: oklch(0.24 0.005 250);
--card-foreground: oklch(0.9 0.005 250);
--popover: oklch(0.22 0.005 250);
--popover-foreground: oklch(0.9 0.005 250);
--primary: oklch(0.6 0.08 250); /* Subtle blue-gray */
--primary-foreground: oklch(0.95 0.005 250);
--brand-400: oklch(0.65 0.08 250);
--brand-500: oklch(0.6 0.08 250); /* Blue-gray */
--brand-600: oklch(0.55 0.09 250);
--secondary: oklch(0.28 0.005 250);
--secondary-foreground: oklch(0.9 0.005 250);
--muted: oklch(0.3 0.005 250);
--muted-foreground: oklch(0.6 0.005 250);
--accent: oklch(0.35 0.01 250);
--accent-foreground: oklch(0.9 0.005 250);
--destructive: oklch(0.6 0.2 25); /* Muted red */
--border: oklch(0.32 0.005 250);
--border-glass: oklch(0.6 0.08 250 / 0.2);
--input: oklch(0.24 0.005 250);
--ring: oklch(0.6 0.08 250);
--chart-1: oklch(0.6 0.08 250); /* Blue-gray */
--chart-2: oklch(0.65 0.1 210); /* Cyan */
--chart-3: oklch(0.7 0.12 160); /* Teal */
--chart-4: oklch(0.65 0.1 280); /* Purple */
--chart-5: oklch(0.7 0.08 300); /* Violet */
--sidebar: oklch(0.18 0.005 250);
--sidebar-foreground: oklch(0.9 0.005 250);
--sidebar-primary: oklch(0.6 0.08 250);
--sidebar-primary-foreground: oklch(0.95 0.005 250);
--sidebar-accent: oklch(0.28 0.005 250);
--sidebar-accent-foreground: oklch(0.9 0.005 250);
--sidebar-border: oklch(0.32 0.005 250);
--sidebar-ring: oklch(0.6 0.08 250);
/* Action button colors - Subtle modern colors */
--action-view: oklch(0.6 0.08 250); /* Blue-gray */
--action-view-hover: oklch(0.55 0.09 250);
--action-followup: oklch(0.65 0.1 210); /* Cyan */
--action-followup-hover: oklch(0.6 0.11 210);
--action-commit: oklch(0.65 0.12 150); /* Teal-green */
--action-commit-hover: oklch(0.6 0.13 150);
--action-verify: oklch(0.65 0.12 150); /* Teal-green */
--action-verify-hover: oklch(0.6 0.13 150);
/* Running indicator - Blue-gray */
--running-indicator: oklch(0.6 0.08 250);
--running-indicator-text: oklch(0.65 0.08 250);
/* Status colors - Gray theme */
--status-success: oklch(0.65 0.12 150);
--status-success-bg: oklch(0.65 0.12 150 / 0.2);
--status-warning: oklch(0.7 0.15 70);
--status-warning-bg: oklch(0.7 0.15 70 / 0.2);
--status-error: oklch(0.6 0.2 25);
--status-error-bg: oklch(0.6 0.2 25 / 0.2);
--status-info: oklch(0.65 0.1 210);
--status-info-bg: oklch(0.65 0.1 210 / 0.2);
--status-backlog: oklch(0.6 0.005 250);
--status-in-progress: oklch(0.7 0.15 70);
--status-waiting: oklch(0.68 0.1 220);
}
@layer base {
* {
@apply border-border outline-ring/50;
@@ -1255,12 +1504,12 @@
}
/* Custom scrollbar for dark themes */
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red) ::-webkit-scrollbar {
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red, .sunset, .gray) ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red) ::-webkit-scrollbar-track {
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red, .sunset, .gray) ::-webkit-scrollbar-track {
background: var(--muted);
}
@@ -1296,6 +1545,62 @@
background: oklch(0.15 0.05 25);
}
/* Cream theme scrollbar */
.cream ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.cream ::-webkit-scrollbar-thumb,
.cream .scrollbar-visible::-webkit-scrollbar-thumb {
background: oklch(0.7 0.03 60);
border-radius: 4px;
}
.cream ::-webkit-scrollbar-thumb:hover,
.cream .scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: oklch(0.6 0.04 60);
}
.cream ::-webkit-scrollbar-track,
.cream .scrollbar-visible::-webkit-scrollbar-track {
background: oklch(0.9 0.015 70);
}
/* Sunset theme scrollbar */
.sunset ::-webkit-scrollbar-thumb,
.sunset .scrollbar-visible::-webkit-scrollbar-thumb {
background: oklch(0.5 0.14 45);
border-radius: 4px;
}
.sunset ::-webkit-scrollbar-thumb:hover,
.sunset .scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: oklch(0.58 0.16 45);
}
.sunset ::-webkit-scrollbar-track,
.sunset .scrollbar-visible::-webkit-scrollbar-track {
background: oklch(0.18 0.03 280);
}
/* Gray theme scrollbar */
.gray ::-webkit-scrollbar-thumb,
.gray .scrollbar-visible::-webkit-scrollbar-thumb {
background: oklch(0.4 0.01 250);
border-radius: 4px;
}
.gray ::-webkit-scrollbar-thumb:hover,
.gray .scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: oklch(0.5 0.02 250);
}
.gray ::-webkit-scrollbar-track,
.gray .scrollbar-visible::-webkit-scrollbar-track {
background: oklch(0.25 0.005 250);
}
/* Always visible scrollbar for file diffs and code blocks */
.scrollbar-visible {
overflow-y: auto !important;

View File

@@ -192,7 +192,8 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
resetFixtureSpec();
});
test("should open project via file browser, edit spec, and persist", async ({
// Skip in CI - file browser navigation is flaky in headless environments
test.skip("should open project via file browser, edit spec, and persist", async ({
page,
}) => {
// Navigate to app first