Merge origin/main into feat/cursor-cli

Merges latest main branch changes including:
- MCP server support and configuration
- Pipeline configuration system
- Prompt customization settings
- GitHub issue comments in validation
- Auth middleware improvements
- Various UI/UX improvements

All Cursor CLI features preserved:
- Multi-provider support (Claude + Cursor)
- Model override capabilities
- Phase model configuration
- Provider tabs in settings

🤖 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-31 01:22:18 +01:00
163 changed files with 15300 additions and 1045 deletions

View File

@@ -7,6 +7,7 @@ import { Plus, Bot, Wand2 } from 'lucide-react';
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
interface BoardHeaderProps {
projectName: string;
@@ -34,12 +35,18 @@ export function BoardHeader({
isMounted,
}: BoardHeaderProps) {
const apiKeys = useAppStore((state) => state.apiKeys);
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
// Hide usage tracking when using API key (only show for Claude Code CLI users)
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
// Also hide on Windows for now (CLI usage command not supported)
// Only show if CLI has been verified/authenticated
const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const showUsageTracking = !apiKeys.anthropic && !isWindows;
const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
const isCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
const showUsageTracking = !hasApiKey && !isWindows && isCliVerified;
return (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">

View File

@@ -1,6 +1,5 @@
import React, { memo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import React, { memo, useLayoutEffect, useState } from 'react';
import { useDraggable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { Feature, useAppStore } from '@/store/app-store';
@@ -10,6 +9,25 @@ import { CardContentSections } from './card-content-sections';
import { AgentInfoPanel } from './agent-info-panel';
import { CardActions } from './card-actions';
function getCardBorderStyle(enabled: boolean, opacity: number): React.CSSProperties {
if (!enabled) {
return { borderWidth: '0px', borderColor: 'transparent' };
}
if (opacity !== 100) {
return {
borderWidth: '1px',
borderColor: `color-mix(in oklch, var(--border) ${opacity}%, transparent)`,
};
}
return {};
}
function getCursorClass(isOverlay: boolean | undefined, isDraggable: boolean): string {
if (isOverlay) return 'cursor-grabbing';
if (isDraggable) return 'cursor-grab active:cursor-grabbing';
return 'cursor-default';
}
interface KanbanCardProps {
feature: Feature;
onEdit: () => void;
@@ -35,6 +53,7 @@ interface KanbanCardProps {
glassmorphism?: boolean;
cardBorderEnabled?: boolean;
cardBorderOpacity?: number;
isOverlay?: boolean;
}
export const KanbanCard = memo(function KanbanCard({
@@ -62,64 +81,63 @@ export const KanbanCard = memo(function KanbanCard({
glassmorphism = true,
cardBorderEnabled = true,
cardBorderOpacity = 100,
isOverlay,
}: KanbanCardProps) {
const { useWorktrees } = useAppStore();
const [isLifted, setIsLifted] = useState(false);
useLayoutEffect(() => {
if (isOverlay) {
requestAnimationFrame(() => {
setIsLifted(true);
});
}
}, [isOverlay]);
const isDraggable =
feature.status === 'backlog' ||
feature.status === 'waiting_approval' ||
feature.status === 'verified' ||
(feature.status === 'in_progress' && !isCurrentAutoTask);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: feature.id,
disabled: !isDraggable,
disabled: !isDraggable || isOverlay,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
const dndStyle = {
opacity: isDragging ? 0.5 : undefined,
};
const borderStyle: React.CSSProperties = { ...style };
if (!cardBorderEnabled) {
(borderStyle as Record<string, string>).borderWidth = '0px';
(borderStyle as Record<string, string>).borderColor = 'transparent';
} else if (cardBorderOpacity !== 100) {
(borderStyle as Record<string, string>).borderWidth = '1px';
(borderStyle as Record<string, string>).borderColor =
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
}
const cardStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity);
const cardElement = (
const wrapperClasses = cn(
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
getCursorClass(isOverlay, isDraggable),
isOverlay && isLifted && 'scale-105 rotate-1 z-50'
);
const isInteractive = !isDragging && !isOverlay;
const hasError = feature.error && !isCurrentAutoTask;
const innerCardClasses = cn(
'kanban-card-content h-full relative shadow-sm',
'transition-all duration-200 ease-out',
isInteractive && 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
!isCurrentAutoTask &&
cardBorderEnabled &&
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg'
);
const renderCardContent = () => (
<Card
ref={setNodeRef}
style={isCurrentAutoTask ? style : borderStyle}
className={cn(
'cursor-grab active:cursor-grabbing relative kanban-card-content select-none',
'transition-all duration-200 ease-out',
// Premium shadow system
'shadow-sm hover:shadow-md hover:shadow-black/10',
// Subtle lift on hover
'hover:-translate-y-0.5',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity === 100 && 'border-border/50',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity !== 100 && 'border',
!isDragging && 'bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
// Error state - using CSS variable
feature.error &&
!isCurrentAutoTask &&
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
!isDraggable && 'cursor-default'
)}
data-testid={`kanban-card-${feature.id}`}
style={isCurrentAutoTask ? undefined : cardStyle}
className={innerCardClasses}
onDoubleClick={onEdit}
{...attributes}
{...(isDraggable ? listeners : {})}
>
{/* Background overlay with opacity */}
{!isDragging && (
{(!isDragging || isOverlay) && (
<div
className={cn(
'absolute inset-0 rounded-xl bg-card -z-10',
@@ -185,10 +203,20 @@ export const KanbanCard = memo(function KanbanCard({
</Card>
);
// Wrap with animated border when in progress
if (isCurrentAutoTask) {
return <div className="animated-border-wrapper">{cardElement}</div>;
}
return cardElement;
return (
<div
ref={setNodeRef}
style={dndStyle}
{...attributes}
{...(isDraggable ? listeners : {})}
className={wrapperClasses}
data-testid={`kanban-card-${feature.id}`}
>
{isCurrentAutoTask ? (
<div className="animated-border-wrapper">{renderCardContent()}</div>
) : (
renderCardContent()
)}
</div>
);
});

View File

@@ -1,14 +1,28 @@
import { Feature } from '@/store/app-store';
import type { Feature } from '@/store/app-store';
import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types';
export type ColumnId = Feature['status'];
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
export interface Column {
id: FeatureStatusWithPipeline;
title: string;
colorClass: string;
isPipelineStep?: boolean;
pipelineStepId?: string;
}
// Base columns (start)
const BASE_COLUMNS: Column[] = [
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' },
{
id: 'in_progress',
title: 'In Progress',
colorClass: 'bg-[var(--status-in-progress)]',
},
];
// End columns (after pipeline)
const END_COLUMNS: Column[] = [
{
id: 'waiting_approval',
title: 'Waiting Approval',
@@ -20,3 +34,58 @@ export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
colorClass: 'bg-[var(--status-success)]',
},
];
// Static COLUMNS for backwards compatibility (no pipeline)
export const COLUMNS: Column[] = [...BASE_COLUMNS, ...END_COLUMNS];
/**
* Generate columns including pipeline steps
*/
export function getColumnsWithPipeline(pipelineConfig: PipelineConfig | null): Column[] {
const pipelineSteps = pipelineConfig?.steps || [];
if (pipelineSteps.length === 0) {
return COLUMNS;
}
// Sort steps by order
const sortedSteps = [...pipelineSteps].sort((a, b) => a.order - b.order);
// Convert pipeline steps to columns (filter out invalid steps)
const pipelineColumns: Column[] = sortedSteps
.filter((step) => step && step.id) // Only include valid steps with an id
.map((step) => ({
id: `pipeline_${step.id}` as FeatureStatusWithPipeline,
title: step.name || 'Pipeline Step',
colorClass: step.colorClass || 'bg-[var(--status-in-progress)]',
isPipelineStep: true,
pipelineStepId: step.id,
}));
return [...BASE_COLUMNS, ...pipelineColumns, ...END_COLUMNS];
}
/**
* Get the index where pipeline columns should be inserted
* (after in_progress, before waiting_approval)
*/
export function getPipelineInsertIndex(): number {
return BASE_COLUMNS.length;
}
/**
* Check if a status is a pipeline status
*/
export function isPipelineStatus(status: string): boolean {
return status.startsWith('pipeline_');
}
/**
* Extract step ID from a pipeline status
*/
export function getStepIdFromStatus(status: string): string | null {
if (!isPipelineStatus(status)) {
return null;
}
return status.replace('pipeline_', '');
}

View File

@@ -19,7 +19,14 @@ import {
FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import { MessageSquare, Settings2, SlidersHorizontal, Sparkles, ChevronDown } from 'lucide-react';
import {
MessageSquare,
Settings2,
SlidersHorizontal,
Sparkles,
ChevronDown,
Play,
} from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
@@ -57,25 +64,28 @@ import {
} from '@automaker/dependency-resolver';
import { isCursorModel, PROVIDER_PREFIXES } from '@automaker/types';
type FeatureData = {
title: string;
category: string;
description: string;
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
dependencies?: string[];
};
interface AddFeatureDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (feature: {
title: string;
category: string;
description: string;
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
skipTests: boolean;
model: ModelAlias;
thinkingLevel: ThinkingLevel;
branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
dependencies?: string[];
}) => void;
onAdd: (feature: FeatureData) => void;
onAddAndStart?: (feature: FeatureData) => void;
categorySuggestions: string[];
branchSuggestions: string[];
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
@@ -94,6 +104,7 @@ export function AddFeatureDialog({
open,
onOpenChange,
onAdd,
onAddAndStart,
categorySuggestions,
branchSuggestions,
branchCardCounts,
@@ -188,16 +199,16 @@ export function AddFeatureDialog({
allFeatures,
]);
const handleAdd = () => {
const buildFeatureData = (): FeatureData | null => {
if (!newFeature.description.trim()) {
setDescriptionError(true);
return;
return null;
}
// Validate branch selection when "other branch" is selected
if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
toast.error('Please select a branch name');
return;
return null;
}
const category = newFeature.category || 'Uncategorized';
@@ -235,7 +246,7 @@ export function AddFeatureDialog({
}
}
onAdd({
return {
title: newFeature.title,
category,
description: finalDescription,
@@ -251,9 +262,10 @@ export function AddFeatureDialog({
requirePlanApproval,
// In spawn mode, automatically add parent as dependency
dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined,
});
};
};
// Reset form
const resetForm = () => {
setNewFeature({
title: '',
category: '',
@@ -276,6 +288,20 @@ export function AddFeatureDialog({
onOpenChange(false);
};
const handleAction = (actionFn?: (data: FeatureData) => void) => {
if (!actionFn) return;
const featureData = buildFeatureData();
if (!featureData) return;
actionFn(featureData);
resetForm();
};
const handleAdd = () => handleAction(onAdd);
const handleAddAndStart = () => handleAction(onAddAndStart);
const handleDialogClose = (open: boolean) => {
onOpenChange(open);
if (!open) {
@@ -606,6 +632,17 @@ export function AddFeatureDialog({
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
{onAddAndStart && (
<Button
onClick={handleAddAndStart}
variant="secondary"
data-testid="confirm-add-and-start-feature"
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
>
<Play className="w-4 h-4 mr-2" />
Make
</Button>
)}
<HotkeyButton
onClick={handleAdd}
hotkey={{ key: 'Enter', cmdCtrl: true }}

View File

@@ -23,6 +23,8 @@ interface AgentOutputModalProps {
featureStatus?: string;
/** Called when a number key (0-9) is pressed while the modal is open */
onNumberKeyPress?: (key: string) => void;
/** Project path - if not provided, falls back to window.__currentProject for backward compatibility */
projectPath?: string;
}
type ViewMode = 'parsed' | 'raw' | 'changes';
@@ -34,6 +36,7 @@ export function AgentOutputModal({
featureId,
featureStatus,
onNumberKeyPress,
projectPath: projectPathProp,
}: AgentOutputModalProps) {
const [output, setOutput] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
@@ -62,19 +65,19 @@ export function AgentOutputModal({
setIsLoading(true);
try {
// Get current project path from store (we'll need to pass this)
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) {
// Use projectPath prop if provided, otherwise fall back to window.__currentProject for backward compatibility
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path;
if (!resolvedProjectPath) {
setIsLoading(false);
return;
}
projectPathRef.current = currentProject.path;
setProjectPath(currentProject.path);
projectPathRef.current = resolvedProjectPath;
setProjectPath(resolvedProjectPath);
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(currentProject.path, featureId);
const result = await api.features.getAgentOutput(resolvedProjectPath, featureId);
if (result.success) {
setOutput(result.content || '');
@@ -93,7 +96,7 @@ export function AgentOutputModal({
};
loadOutput();
}, [open, featureId]);
}, [open, featureId, projectPathProp]);
// Listen to auto mode events and update output
useEffect(() => {

View File

@@ -131,7 +131,7 @@ export function DependencyTreeDialog({
: 'bg-muted text-muted-foreground'
)}
>
{dep.status.replace(/_/g, ' ')}
{(dep.status || 'backlog').replace(/_/g, ' ')}
</span>
</div>
</div>
@@ -177,7 +177,7 @@ export function DependencyTreeDialog({
: 'bg-muted text-muted-foreground'
)}
>
{dependent.status.replace(/_/g, ' ')}
{(dependent.status || 'backlog').replace(/_/g, ' ')}
</span>
</div>
</div>

View File

@@ -0,0 +1,736 @@
import { useState, useRef, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Trash2, ChevronUp, ChevronDown, Upload, Pencil, X, FileText } from 'lucide-react';
import { toast } from 'sonner';
import type { PipelineConfig, PipelineStep } from '@automaker/types';
import { cn } from '@/lib/utils';
// Color options for pipeline columns
const COLOR_OPTIONS = [
{ value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' },
{ value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' },
{ value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' },
{ value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' },
{ value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' },
{ value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' },
{ value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' },
{ value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' },
{ value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' },
];
// Pre-built step templates with well-designed prompts
const STEP_TEMPLATES = [
{
id: 'code-review',
name: 'Code Review',
colorClass: 'bg-blue-500/20',
instructions: `## Code Review
Please perform a thorough code review of the changes made in this feature. Focus on:
### Code Quality
- **Readability**: Is the code easy to understand? Are variable/function names descriptive?
- **Maintainability**: Will this code be easy to modify in the future?
- **DRY Principle**: Is there any duplicated code that should be abstracted?
- **Single Responsibility**: Do functions and classes have a single, clear purpose?
### Best Practices
- Follow established patterns and conventions used in the codebase
- Ensure proper error handling is in place
- Check for appropriate logging where needed
- Verify that magic numbers/strings are replaced with named constants
### Performance
- Identify any potential performance bottlenecks
- Check for unnecessary re-renders (React) or redundant computations
- Ensure efficient data structures are used
### Testing
- Verify that new code has appropriate test coverage
- Check that edge cases are handled
### Action Required
After reviewing, make any necessary improvements directly. If you find issues:
1. Fix them immediately if they are straightforward
2. For complex issues, document them clearly with suggested solutions
Provide a brief summary of changes made or issues found.`,
},
{
id: 'security-review',
name: 'Security Review',
colorClass: 'bg-red-500/20',
instructions: `## Security Review
Perform a comprehensive security audit of the changes made in this feature. Check for vulnerabilities in the following areas:
### Input Validation & Sanitization
- Verify all user inputs are properly validated and sanitized
- Check for SQL injection vulnerabilities
- Check for XSS (Cross-Site Scripting) vulnerabilities
- Ensure proper encoding of output data
### Authentication & Authorization
- Verify authentication checks are in place where needed
- Ensure authorization logic correctly restricts access
- Check for privilege escalation vulnerabilities
- Verify session management is secure
### Data Protection
- Ensure sensitive data is not logged or exposed
- Check that secrets/credentials are not hardcoded
- Verify proper encryption is used for sensitive data
- Check for secure transmission of data (HTTPS, etc.)
### Common Vulnerabilities (OWASP Top 10)
- Injection flaws
- Broken authentication
- Sensitive data exposure
- XML External Entities (XXE)
- Broken access control
- Security misconfiguration
- Cross-Site Scripting (XSS)
- Insecure deserialization
- Using components with known vulnerabilities
- Insufficient logging & monitoring
### Action Required
1. Fix any security vulnerabilities immediately
2. For complex security issues, document them with severity levels
3. Add security-related comments where appropriate
Provide a security assessment summary with any issues found and fixes applied.`,
},
{
id: 'testing',
name: 'Testing',
colorClass: 'bg-green-500/20',
instructions: `## Testing Step
Please ensure comprehensive test coverage for the changes made in this feature.
### Unit Tests
- Write unit tests for all new functions and methods
- Ensure edge cases are covered
- Test error handling paths
- Aim for high code coverage on new code
### Integration Tests
- Test interactions between components/modules
- Verify API endpoints work correctly
- Test database operations if applicable
### Test Quality
- Tests should be readable and well-documented
- Each test should have a clear purpose
- Use descriptive test names that explain the scenario
- Follow the Arrange-Act-Assert pattern
### Run Tests
After writing tests, run the full test suite and ensure:
1. All new tests pass
2. No existing tests are broken
3. Test coverage meets project standards
Provide a summary of tests added and any issues found during testing.`,
},
{
id: 'documentation',
name: 'Documentation',
colorClass: 'bg-amber-500/20',
instructions: `## Documentation Step
Please ensure all changes are properly documented.
### Code Documentation
- Add/update JSDoc or docstrings for new functions and classes
- Document complex algorithms or business logic
- Add inline comments for non-obvious code
### API Documentation
- Document any new or modified API endpoints
- Include request/response examples
- Document error responses
### README Updates
- Update README if new setup steps are required
- Document any new environment variables
- Update architecture diagrams if applicable
### Changelog
- Document notable changes for the changelog
- Include breaking changes if any
Provide a summary of documentation added or updated.`,
},
{
id: 'optimization',
name: 'Performance Optimization',
colorClass: 'bg-cyan-500/20',
instructions: `## Performance Optimization Step
Review and optimize the performance of the changes made in this feature.
### Code Performance
- Identify and optimize slow algorithms (O(n²) → O(n log n), etc.)
- Remove unnecessary computations or redundant operations
- Optimize loops and iterations
- Use appropriate data structures
### Memory Usage
- Check for memory leaks
- Optimize memory-intensive operations
- Ensure proper cleanup of resources
### Database/API
- Optimize database queries (add indexes, reduce N+1 queries)
- Implement caching where appropriate
- Batch API calls when possible
### Frontend (if applicable)
- Minimize bundle size
- Optimize render performance
- Implement lazy loading where appropriate
- Use memoization for expensive computations
### Action Required
1. Profile the code to identify bottlenecks
2. Apply optimizations
3. Measure improvements
Provide a summary of optimizations applied and performance improvements achieved.`,
},
];
// Helper to get template color class
const getTemplateColorClass = (templateId: string): string => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
return template?.colorClass || COLOR_OPTIONS[0].value;
};
interface PipelineSettingsDialogProps {
open: boolean;
onClose: () => void;
projectPath: string;
pipelineConfig: PipelineConfig | null;
onSave: (config: PipelineConfig) => Promise<void>;
}
interface EditingStep {
id?: string;
name: string;
instructions: string;
colorClass: string;
order: number;
}
export function PipelineSettingsDialog({
open,
onClose,
projectPath,
pipelineConfig,
onSave,
}: PipelineSettingsDialogProps) {
// Filter and validate steps to ensure all required properties exist
const validateSteps = (steps: PipelineStep[] | undefined): PipelineStep[] => {
if (!Array.isArray(steps)) return [];
return steps.filter(
(step): step is PipelineStep =>
step != null &&
typeof step.id === 'string' &&
typeof step.name === 'string' &&
typeof step.instructions === 'string'
);
};
const [steps, setSteps] = useState<PipelineStep[]>(() => validateSteps(pipelineConfig?.steps));
const [editingStep, setEditingStep] = useState<EditingStep | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync steps when dialog opens or pipelineConfig changes
useEffect(() => {
if (open) {
setSteps(validateSteps(pipelineConfig?.steps));
}
}, [open, pipelineConfig]);
const sortedSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const handleAddStep = () => {
setEditingStep({
name: '',
instructions: '',
colorClass: COLOR_OPTIONS[steps.length % COLOR_OPTIONS.length].value,
order: steps.length,
});
};
const handleEditStep = (step: PipelineStep) => {
setEditingStep({
id: step.id,
name: step.name,
instructions: step.instructions,
colorClass: step.colorClass,
order: step.order,
});
};
const handleDeleteStep = (stepId: string) => {
const newSteps = steps.filter((s) => s.id !== stepId);
// Reorder remaining steps
newSteps.forEach((s, index) => {
s.order = index;
});
setSteps(newSteps);
};
const handleMoveStep = (stepId: string, direction: 'up' | 'down') => {
const stepIndex = sortedSteps.findIndex((s) => s.id === stepId);
if (
(direction === 'up' && stepIndex === 0) ||
(direction === 'down' && stepIndex === sortedSteps.length - 1)
) {
return;
}
const newSteps = [...sortedSteps];
const targetIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
// Swap orders
const temp = newSteps[stepIndex].order;
newSteps[stepIndex].order = newSteps[targetIndex].order;
newSteps[targetIndex].order = temp;
setSteps(newSteps);
};
const handleFileUpload = () => {
fileInputRef.current?.click();
};
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const content = await file.text();
setEditingStep((prev) => (prev ? { ...prev, instructions: content } : null));
toast.success('Instructions loaded from file');
} catch (error) {
toast.error('Failed to load file');
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSaveStep = () => {
if (!editingStep) return;
if (!editingStep.name.trim()) {
toast.error('Step name is required');
return;
}
if (!editingStep.instructions.trim()) {
toast.error('Step instructions are required');
return;
}
const now = new Date().toISOString();
if (editingStep.id) {
// Update existing step
setSteps((prev) =>
prev.map((s) =>
s.id === editingStep.id
? {
...s,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
updatedAt: now,
}
: s
)
);
} else {
// Add new step
const newStep: PipelineStep = {
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: steps.length,
createdAt: now,
updatedAt: now,
};
setSteps((prev) => [...prev, newStep]);
}
setEditingStep(null);
};
const handleSaveConfig = async () => {
setIsSubmitting(true);
try {
// If the user is currently editing a step and clicks "Save Configuration",
// include that step in the config (common expectation) instead of silently dropping it.
let effectiveSteps = steps;
if (editingStep) {
if (!editingStep.name.trim()) {
toast.error('Step name is required');
return;
}
if (!editingStep.instructions.trim()) {
toast.error('Step instructions are required');
return;
}
const now = new Date().toISOString();
if (editingStep.id) {
// Update existing (or add if missing for some reason)
const existingIdx = effectiveSteps.findIndex((s) => s.id === editingStep.id);
if (existingIdx >= 0) {
effectiveSteps = effectiveSteps.map((s) =>
s.id === editingStep.id
? {
...s,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
updatedAt: now,
}
: s
);
} else {
effectiveSteps = [
...effectiveSteps,
{
id: editingStep.id,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: effectiveSteps.length,
createdAt: now,
updatedAt: now,
},
];
}
} else {
// Add new step
effectiveSteps = [
...effectiveSteps,
{
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: effectiveSteps.length,
createdAt: now,
updatedAt: now,
},
];
}
// Keep local UI state consistent with what we are saving.
setSteps(effectiveSteps);
setEditingStep(null);
}
const sortedEffectiveSteps = [...effectiveSteps].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0)
);
const config: PipelineConfig = {
version: 1,
steps: sortedEffectiveSteps.map((s, index) => ({ ...s, order: index })),
};
await onSave(config);
toast.success('Pipeline configuration saved');
onClose();
} catch (error) {
toast.error('Failed to save pipeline configuration');
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
{/* Hidden file input for loading instructions from .md files */}
<input
ref={fileInputRef}
type="file"
accept=".md,.txt"
className="hidden"
onChange={handleFileInputChange}
/>
<DialogHeader>
<DialogTitle>Pipeline Settings</DialogTitle>
<DialogDescription>
Configure custom pipeline steps that run after a feature completes "In Progress". Each
step will automatically prompt the agent with its instructions.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 space-y-4">
{/* Steps List */}
{sortedSteps.length > 0 ? (
<div className="space-y-2">
{sortedSteps.map((step, index) => (
<div
key={step.id}
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
>
<div className="flex flex-col gap-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'down')}
disabled={index === sortedSteps.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<div
className={cn(
'w-3 h-8 rounded',
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
)}
/>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div>
<div className="text-xs text-muted-foreground truncate">
{(step.instructions || '').substring(0, 100)}
{(step.instructions || '').length > 100 ? '...' : ''}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditStep(step)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDeleteStep(step.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>No pipeline steps configured.</p>
<p className="text-sm">
Add steps to create a custom workflow after features complete.
</p>
</div>
)}
{/* Add Step Button */}
{!editingStep && (
<Button variant="outline" className="w-full" onClick={handleAddStep}>
<Plus className="h-4 w-4 mr-2" />
Add Pipeline Step
</Button>
)}
{/* Edit/Add Step Form */}
{editingStep && (
<div className="border rounded-lg p-4 space-y-4 bg-muted/20">
<div className="flex items-center justify-between">
<h4 className="font-medium">{editingStep.id ? 'Edit Step' : 'New Step'}</h4>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setEditingStep(null)}
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Template Selector - only show for new steps */}
{!editingStep.id && (
<div className="space-y-2">
<Label>Start from Template</Label>
<Select
onValueChange={(templateId) => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
if (template) {
setEditingStep((prev) =>
prev
? {
...prev,
name: template.name,
instructions: template.instructions,
colorClass: template.colorClass,
}
: null
);
toast.success(`Loaded "${template.name}" template`);
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a template (optional)" />
</SelectTrigger>
<SelectContent>
{STEP_TEMPLATES.map((template) => (
<SelectItem key={template.id} value={template.id}>
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
template.colorClass.replace('/20', '')
)}
/>
{template.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Select a pre-built template to populate the form, or create your own from
scratch.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="step-name">Step Name</Label>
<Input
id="step-name"
placeholder="e.g., Code Review, Testing, Documentation"
value={editingStep.name}
onChange={(e) =>
setEditingStep((prev) => (prev ? { ...prev, name: e.target.value } : null))
}
/>
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex flex-wrap gap-2">
{COLOR_OPTIONS.map((color) => (
<button
key={color.value}
type="button"
className={cn(
'w-8 h-8 rounded-full transition-all',
color.preview,
editingStep.colorClass === color.value
? 'ring-2 ring-offset-2 ring-primary'
: 'opacity-60 hover:opacity-100'
)}
onClick={() =>
setEditingStep((prev) =>
prev ? { ...prev, colorClass: color.value } : null
)
}
title={color.label}
/>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="step-instructions">Agent Instructions</Label>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={handleFileUpload}
>
<Upload className="h-3 w-3 mr-1" />
Load from .md file
</Button>
</div>
<Textarea
id="step-instructions"
placeholder="Instructions for the agent to follow during this pipeline step..."
value={editingStep.instructions}
onChange={(e) =>
setEditingStep((prev) =>
prev ? { ...prev, instructions: e.target.value } : null
)
}
rows={6}
className="font-mono text-sm"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditingStep(null)}>
Cancel
</Button>
<Button onClick={handleSaveStep}>
{editingStep.id ? 'Update Step' : 'Add Step'}
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
{isSubmitting
? 'Saving...'
: editingStep
? 'Save Step & Configuration'
: 'Save Configuration'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -23,7 +23,8 @@ export function useBoardColumnFeatures({
}: UseBoardColumnFeaturesProps) {
// Memoize column features to prevent unnecessary re-renders
const columnFeaturesMap = useMemo(() => {
const map: Record<ColumnId, Feature[]> = {
// Use a more flexible type to support dynamic pipeline statuses
const map: Record<string, Feature[]> = {
backlog: [],
in_progress: [],
waiting_approval: [],
@@ -76,31 +77,56 @@ export function useBoardColumnFeatures({
matchesWorktree = featureBranch === effectiveBranch;
}
// Use the feature's status (fallback to backlog for unknown statuses)
const status = f.status || 'backlog';
// IMPORTANT:
// Historically, we forced "running" features into in_progress so they never disappeared
// during stale reload windows. With pipelines, a feature can legitimately be running while
// its status is `pipeline_*`, so we must respect that status to render it in the right column.
if (isRunning) {
// Only show running tasks if they match the current worktree
if (matchesWorktree) {
if (!matchesWorktree) return;
if (status.startsWith('pipeline_')) {
if (!map[status]) map[status] = [];
map[status].push(f);
return;
}
// If it's running and has a known non-backlog status, keep it in that status.
// Otherwise, fallback to in_progress as the "active work" column.
if (status !== 'backlog' && map[status]) {
map[status].push(f);
} else {
map.in_progress.push(f);
}
} else {
// Otherwise, use the feature's status (fallback to backlog for unknown statuses)
const status = f.status as ColumnId;
return;
}
// Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch
if (status === 'backlog') {
if (matchesWorktree) {
map.backlog.push(f);
}
} else if (map[status]) {
// Only show if matches current worktree or has no worktree assigned
if (matchesWorktree) {
map[status].push(f);
}
} else {
// Unknown status, default to backlog
if (matchesWorktree) {
map.backlog.push(f);
// Not running: place by status (and worktree filter)
// Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch
if (status === 'backlog') {
if (matchesWorktree) {
map.backlog.push(f);
}
} else if (map[status]) {
// Only show if matches current worktree or has no worktree assigned
if (matchesWorktree) {
map[status].push(f);
}
} else if (status.startsWith('pipeline_')) {
// Handle pipeline statuses - initialize array if needed
if (matchesWorktree) {
if (!map[status]) {
map[status] = [];
}
map[status].push(f);
}
} else {
// Unknown status, default to backlog
if (matchesWorktree) {
map.backlog.push(f);
}
}
});
@@ -147,7 +173,7 @@ export function useBoardColumnFeatures({
const getColumnFeatures = useCallback(
(columnId: ColumnId) => {
return columnFeaturesMap[columnId];
return columnFeaturesMap[columnId] || [];
},
[columnFeaturesMap]
);

View File

@@ -203,6 +203,11 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// This ensures the feature card shows the "Approve Plan" button
console.log('[Board] Plan approval required, reloading features...');
loadFeatures();
} else if (event.type === 'pipeline_step_started') {
// Pipeline steps update the feature status to `pipeline_*` before the step runs.
// Reload so the card moves into the correct pipeline column immediately.
console.log('[Board] Pipeline step started, reloading features...');
loadFeatures();
} else if (event.type === 'auto_mode_error') {
// Reload features when an error occurs (feature moved to waiting_approval)
console.log('[Board] Feature error, reloading features...', event.error);

View File

@@ -1,14 +1,15 @@
import { useMemo } from 'react';
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { KanbanColumn, KanbanCard } from './components';
import { Feature } from '@/store/app-store';
import { FastForward, Lightbulb, Archive } from 'lucide-react';
import { FastForward, Lightbulb, Archive, Plus, Settings2 } from 'lucide-react';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { COLUMNS, ColumnId } from './constants';
import { getColumnsWithPipeline, type Column, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
interface KanbanBoardProps {
sensors: any;
@@ -49,6 +50,8 @@ interface KanbanBoardProps {
onShowSuggestions: () => void;
suggestionsCount: number;
onArchiveAllVerified: () => void;
pipelineConfig: PipelineConfig | null;
onOpenPipelineSettings?: () => void;
}
export function KanbanBoard({
@@ -82,13 +85,18 @@ export function KanbanBoard({
onShowSuggestions,
suggestionsCount,
onArchiveAllVerified,
pipelineConfig,
onOpenPipelineSettings,
}: KanbanBoardProps) {
// Generate columns including pipeline steps
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
// Use responsive column widths based on window size
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
const { columnWidth, containerStyle } = useResponsiveKanban(COLUMNS.length);
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
return (
<div className="flex-1 overflow-x-hidden px-5 pb-4 relative" style={backgroundImageStyle}>
<div className="flex-1 overflow-x-auto px-5 pb-4 relative" style={backgroundImageStyle}>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
@@ -96,8 +104,8 @@ export function KanbanBoard({
onDragEnd={onDragEnd}
>
<div className="h-full py-1" style={containerStyle}>
{COLUMNS.map((column) => {
const columnFeatures = getColumnFeatures(column.id);
{columns.map((column) => {
const columnFeatures = getColumnFeatures(column.id as ColumnId);
return (
<KanbanColumn
key={column.id}
@@ -156,6 +164,28 @@ export function KanbanBoard({
</HotkeyButton>
)}
</div>
) : column.id === 'in_progress' ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Pipeline Settings"
data-testid="pipeline-settings-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : column.isPipelineStep ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Edit Pipeline Step"
data-testid="edit-pipeline-step-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : undefined
}
>
@@ -210,19 +240,32 @@ export function KanbanBoard({
}}
>
{activeFeature && (
<Card
className="rotate-2 shadow-2xl shadow-black/25 border-primary/50 bg-card/95 backdrop-blur-sm transition-transform"
style={{ width: `${columnWidth}px` }}
>
<CardHeader className="p-3">
<CardTitle className="text-sm font-medium line-clamp-2">
{activeFeature.description}
</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
{activeFeature.category}
</CardDescription>
</CardHeader>
</Card>
<div style={{ width: `${columnWidth}px` }}>
<KanbanCard
feature={activeFeature}
isOverlay
onEdit={() => {}}
onDelete={() => {}}
onViewOutput={() => {}}
onVerify={() => {}}
onResume={() => {}}
onForceStop={() => {}}
onManualVerify={() => {}}
onMoveBackToInProgress={() => {}}
onFollowUp={() => {}}
onImplement={() => {}}
onComplete={() => {}}
onViewPlan={() => {}}
onApprovePlan={() => {}}
onSpawnTask={() => {}}
hasContext={featuresWithContext.has(activeFeature.id)}
isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)}
opacity={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
/>
</div>
)}
</DragOverlay>
</DndContext>