diff --git a/Dockerfile b/Dockerfile index e45ddf24..c32b1764 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,6 +55,10 @@ RUN npm run build:packages && npm run build --workspace=apps/server # ============================================================================= FROM node:22-slim AS server +# Build argument for tracking which commit this image was built from +ARG GIT_COMMIT_SHA=unknown +LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}" + # Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch) RUN apt-get update && apt-get install -y --no-install-recommends \ git curl bash gosu ca-certificates openssh-client \ @@ -184,6 +188,10 @@ RUN npm run build:packages && npm run build --workspace=apps/ui # ============================================================================= FROM nginx:alpine AS ui +# Build argument for tracking which commit this image was built from +ARG GIT_COMMIT_SHA=unknown +LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}" + # Copy built files COPY --from=ui-builder /app/apps/ui/dist /usr/share/nginx/html diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 00000000..87ac6bf6 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,80 @@ +# Automaker Development Dockerfile +# For development with live reload via volume mounting +# Source code is NOT copied - it's mounted as a volume +# +# Usage: +# docker compose -f docker-compose.dev.yml up + +FROM node:22-slim + +# Install build dependencies for native modules (node-pty) and runtime tools +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 make g++ \ + git curl bash gosu ca-certificates openssh-client \ + && GH_VERSION="2.63.2" \ + && ARCH=$(uname -m) \ + && case "$ARCH" in \ + x86_64) GH_ARCH="amd64" ;; \ + aarch64|arm64) GH_ARCH="arm64" ;; \ + *) echo "Unsupported architecture: $ARCH" && exit 1 ;; \ + esac \ + && curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz \ + && tar -xzf gh.tar.gz \ + && mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh \ + && rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} \ + && rm -rf /var/lib/apt/lists/* + +# Install Claude CLI globally +RUN npm install -g @anthropic-ai/claude-code + +# Create non-root user +RUN groupadd -g 1001 automaker && \ + useradd -u 1001 -g automaker -m -d /home/automaker -s /bin/bash automaker && \ + mkdir -p /home/automaker/.local/bin && \ + mkdir -p /home/automaker/.cursor && \ + chown -R automaker:automaker /home/automaker && \ + chmod 700 /home/automaker/.cursor + +# Install Cursor CLI as automaker user +USER automaker +ENV HOME=/home/automaker +RUN curl https://cursor.com/install -fsS | bash || true +USER root + +# Add PATH to profile for Cursor CLI +RUN mkdir -p /etc/profile.d && \ + echo 'export PATH="/home/automaker/.local/bin:$PATH"' > /etc/profile.d/cursor-cli.sh && \ + chmod +x /etc/profile.d/cursor-cli.sh + +# Add to user bashrc files +RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /home/automaker/.bashrc && \ + chown automaker:automaker /home/automaker/.bashrc +RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /root/.bashrc + +WORKDIR /app + +# Create directories with proper permissions +RUN mkdir -p /data /projects && chown automaker:automaker /data /projects + +# Configure git for mounted volumes +RUN git config --system --add safe.directory '*' && \ + git config --system credential.helper '!gh auth git-credential' + +# Copy entrypoint script +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Environment variables +ENV PORT=3008 +ENV DATA_DIR=/data +ENV HOME=/home/automaker +ENV PATH="/home/automaker/.local/bin:${PATH}" + +# Expose both dev ports +EXPOSE 3007 3008 + +# Use entrypoint for permission handling +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] + +# Default command - will be overridden by docker-compose +CMD ["npm", "run", "dev:web"] diff --git a/README.md b/README.md index 9ca0f368..8bfd2a0a 100644 --- a/README.md +++ b/README.md @@ -117,24 +117,16 @@ cd automaker # 2. Install dependencies npm install -# 3. Build shared packages (Now can be skipped npm install / run dev does it automaticly) +# 3. Build shared packages (can be skipped - npm run dev does it automatically) npm run build:packages -# 4. Start Automaker (production mode) -npm run start +# 4. Start Automaker +npm run dev # Choose between: # 1. Web Application (browser at localhost:3007) # 2. Desktop Application (Electron - recommended) ``` -**Note:** The `npm run start` command will: - -- Check for dependencies and install if needed -- Build the application if needed -- Kill any processes on ports 3007/3008 -- Present an interactive menu to choose your run mode -- Run in production mode (no hot reload) - **Authentication Setup:** On first run, Automaker will automatically show a setup wizard where you can configure authentication. You can choose to: - Use **Claude Code CLI** (recommended) - Automaker will detect your CLI credentials automatically @@ -150,7 +142,7 @@ export ANTHROPIC_API_KEY="sk-ant-..." echo "ANTHROPIC_API_KEY=sk-ant-..." > .env ``` -**For Development:** If you want to develop on Automaker with Vite live reload and hot module replacement, use `npm run dev` instead. This will start the development server with fast refresh and instant updates as you make changes. +**For Development:** `npm run dev` starts the development server with Vite live reload and hot module replacement for fast refresh and instant updates as you make changes. ## How to Run @@ -194,9 +186,6 @@ npm run dev:web ```bash # Build for web deployment (uses Vite) npm run build - -# Run production build -npm run start ``` #### Desktop Application diff --git a/apps/server/src/routes/features/index.ts b/apps/server/src/routes/features/index.ts index 8cb287d1..4f62ee17 100644 --- a/apps/server/src/routes/features/index.ts +++ b/apps/server/src/routes/features/index.ts @@ -9,6 +9,7 @@ import { createListHandler } from './routes/list.js'; import { createGetHandler } from './routes/get.js'; import { createCreateHandler } from './routes/create.js'; import { createUpdateHandler } from './routes/update.js'; +import { createBulkUpdateHandler } from './routes/bulk-update.js'; import { createDeleteHandler } from './routes/delete.js'; import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js'; import { createGenerateTitleHandler } from './routes/generate-title.js'; @@ -20,6 +21,11 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router { router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader)); router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader)); router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader)); + router.post( + '/bulk-update', + validatePathParams('projectPath'), + createBulkUpdateHandler(featureLoader) + ); router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader)); router.post('/agent-output', createAgentOutputHandler(featureLoader)); router.post('/raw-output', createRawOutputHandler(featureLoader)); diff --git a/apps/server/src/routes/features/routes/bulk-update.ts b/apps/server/src/routes/features/routes/bulk-update.ts new file mode 100644 index 00000000..a1c97e72 --- /dev/null +++ b/apps/server/src/routes/features/routes/bulk-update.ts @@ -0,0 +1,75 @@ +/** + * POST /bulk-update endpoint - Update multiple features at once + */ + +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import type { Feature } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +interface BulkUpdateRequest { + projectPath: string; + featureIds: string[]; + updates: Partial; +} + +interface BulkUpdateResult { + featureId: string; + success: boolean; + error?: string; +} + +export function createBulkUpdateHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureIds, updates } = req.body as BulkUpdateRequest; + + if (!projectPath || !featureIds || !Array.isArray(featureIds) || featureIds.length === 0) { + res.status(400).json({ + success: false, + error: 'projectPath and featureIds (non-empty array) are required', + }); + return; + } + + if (!updates || Object.keys(updates).length === 0) { + res.status(400).json({ + success: false, + error: 'updates object with at least one field is required', + }); + return; + } + + const results: BulkUpdateResult[] = []; + const updatedFeatures: Feature[] = []; + + for (const featureId of featureIds) { + try { + const updated = await featureLoader.update(projectPath, featureId, updates); + results.push({ featureId, success: true }); + updatedFeatures.push(updated); + } catch (error) { + results.push({ + featureId, + success: false, + error: getErrorMessage(error), + }); + } + } + + const successCount = results.filter((r) => r.success).length; + const failureCount = results.filter((r) => !r.success).length; + + res.json({ + success: failureCount === 0, + updatedCount: successCount, + failedCount: failureCount, + results, + features: updatedFeatures, + }); + } catch (error) { + logError(error, 'Bulk update features failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index f59ccbe6..2c82261b 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -56,7 +56,10 @@ import { useBoardBackground, useBoardPersistence, useFollowUpState, + useSelectionMode, } from './board-view/hooks'; +import { SelectionActionBar } from './board-view/components'; +import { MassEditDialog } from './board-view/dialogs'; // Stable empty array to avoid infinite loop in selector const EMPTY_WORKTREES: ReturnType['getWorktrees']> = []; @@ -154,6 +157,19 @@ export function BoardView() { handleFollowUpDialogChange, } = useFollowUpState(); + // Selection mode hook for mass editing + const { + isSelectionMode, + selectedFeatureIds, + selectedCount, + toggleSelectionMode, + toggleFeatureSelection, + selectAll, + clearSelection, + exitSelectionMode, + } = useSelectionMode(); + const [showMassEditDialog, setShowMassEditDialog] = useState(false); + // Search filter for Kanban cards const [searchQuery, setSearchQuery] = useState(''); // Plan approval loading state @@ -447,6 +463,72 @@ export function BoardView() { currentWorktreeBranch, }); + // Handler for bulk updating multiple features + const handleBulkUpdate = useCallback( + async (updates: Partial) => { + if (!currentProject || selectedFeatureIds.size === 0) return; + + try { + const api = getHttpApiClient(); + const featureIds = Array.from(selectedFeatureIds); + const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates); + + if (result.success) { + // Update local state + featureIds.forEach((featureId) => { + updateFeature(featureId, updates); + }); + toast.success(`Updated ${result.updatedCount} features`); + exitSelectionMode(); + } else { + toast.error('Failed to update some features', { + description: `${result.failedCount} features failed to update`, + }); + } + } catch (error) { + logger.error('Bulk update failed:', error); + toast.error('Failed to update features'); + } + }, + [currentProject, selectedFeatureIds, updateFeature, exitSelectionMode] + ); + + // Get selected features for mass edit dialog + const selectedFeatures = useMemo(() => { + return hookFeatures.filter((f) => selectedFeatureIds.has(f.id)); + }, [hookFeatures, selectedFeatureIds]); + + // Get backlog feature IDs in current branch for "Select All" + const allSelectableFeatureIds = useMemo(() => { + return hookFeatures + .filter((f) => { + // Only backlog features + if (f.status !== 'backlog') return false; + + // Filter by current worktree branch + const featureBranch = f.branchName; + if (!featureBranch) { + // No branch assigned - only selectable on primary worktree + return currentWorktreePath === null; + } + if (currentWorktreeBranch === null) { + // Viewing main but branch hasn't been initialized + return currentProject?.path + ? isPrimaryWorktreeBranch(currentProject.path, featureBranch) + : false; + } + // Match by branch name + return featureBranch === currentWorktreeBranch; + }) + .map((f) => f.id); + }, [ + hookFeatures, + currentWorktreePath, + currentWorktreeBranch, + currentProject?.path, + isPrimaryWorktreeBranch, + ]); + // Handler for addressing PR comments - creates a feature and starts it automatically const handleAddressPRComments = useCallback( async (worktree: WorktreeInfo, prInfo: PRInfo) => { @@ -1091,7 +1173,6 @@ export function BoardView() { onManualVerify={handleManualVerify} onMoveBackToInProgress={handleMoveBackToInProgress} onFollowUp={handleOpenFollowUp} - onCommit={handleCommitFeature} onComplete={handleCompleteFeature} onImplement={handleStartImplementation} onViewPlan={(feature) => setViewPlanFeature(feature)} @@ -1102,13 +1183,15 @@ export function BoardView() { }} featuresWithContext={featuresWithContext} runningAutoTasks={runningAutoTasks} - shortcuts={shortcuts} - onStartNextFeatures={handleStartNextFeatures} onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} pipelineConfig={ currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null } onOpenPipelineSettings={() => setShowPipelineSettings(true)} + isSelectionMode={isSelectionMode} + selectedFeatureIds={selectedFeatureIds} + onToggleFeatureSelection={toggleFeatureSelection} + onToggleSelectionMode={toggleSelectionMode} /> ) : ( + {/* Selection Action Bar */} + {isSelectionMode && ( + setShowMassEditDialog(true)} + onClear={clearSelection} + onSelectAll={() => selectAll(allSelectableFeatureIds)} + /> + )} + + {/* Mass Edit Dialog */} + setShowMassEditDialog(false)} + selectedFeatures={selectedFeatures} + onApply={handleBulkUpdate} + showProfilesOnly={showProfilesOnly} + aiProfiles={aiProfiles} + /> + {/* Board Background Modal */} void; onViewOutput?: () => void; onVerify?: () => void; @@ -35,6 +36,7 @@ export function CardActions({ isCurrentAutoTask, hasContext, shortcutKey, + isSelectionMode = false, onEdit, onViewOutput, onVerify, @@ -47,6 +49,11 @@ export function CardActions({ onViewPlan, onApprovePlan, }: CardActionsProps) { + // Hide all actions when in selection mode + if (isSelectionMode) { + return null; + } + return (
{isCurrentAutoTask && ( diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index 6f486caa..b48f78a3 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -29,6 +29,7 @@ interface CardHeaderProps { feature: Feature; isDraggable: boolean; isCurrentAutoTask: boolean; + isSelectionMode?: boolean; onEdit: () => void; onDelete: () => void; onViewOutput?: () => void; @@ -39,6 +40,7 @@ export function CardHeaderSection({ feature, isDraggable, isCurrentAutoTask, + isSelectionMode = false, onEdit, onDelete, onViewOutput, @@ -59,7 +61,7 @@ export function CardHeaderSection({ return ( {/* Running task header */} - {isCurrentAutoTask && ( + {isCurrentAutoTask && !isSelectionMode && (
@@ -119,7 +121,7 @@ export function CardHeaderSection({ )} {/* Backlog header */} - {!isCurrentAutoTask && feature.status === 'backlog' && ( + {!isCurrentAutoTask && !isSelectionMode && feature.status === 'backlog' && (
+ + {!allSelected && ( + + )} + + +
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/dialogs/index.ts b/apps/ui/src/components/views/board-view/dialogs/index.ts index 6979f9d4..b8d5aa30 100644 --- a/apps/ui/src/components/views/board-view/dialogs/index.ts +++ b/apps/ui/src/components/views/board-view/dialogs/index.ts @@ -7,3 +7,4 @@ export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog' export { EditFeatureDialog } from './edit-feature-dialog'; export { FollowUpDialog } from './follow-up-dialog'; export { PlanApprovalDialog } from './plan-approval-dialog'; +export { MassEditDialog } from './mass-edit-dialog'; diff --git a/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx new file mode 100644 index 00000000..6e198e63 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx @@ -0,0 +1,325 @@ +import { useState, useEffect, useMemo } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import { AlertCircle } from 'lucide-react'; +import { modelSupportsThinking } from '@/lib/utils'; +import { Feature, ModelAlias, ThinkingLevel, AIProfile, PlanningMode } from '@/store/app-store'; +import { ProfileSelect, TestingTabContent, PrioritySelect, PlanningModeSelect } from '../shared'; +import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector'; +import { isCursorModel, PROVIDER_PREFIXES, type PhaseModelEntry } from '@automaker/types'; +import { cn } from '@/lib/utils'; + +interface MassEditDialogProps { + open: boolean; + onClose: () => void; + selectedFeatures: Feature[]; + onApply: (updates: Partial) => Promise; + showProfilesOnly: boolean; + aiProfiles: AIProfile[]; +} + +interface ApplyState { + model: boolean; + thinkingLevel: boolean; + planningMode: boolean; + requirePlanApproval: boolean; + priority: boolean; + skipTests: boolean; +} + +function getMixedValues(features: Feature[]): Record { + if (features.length === 0) return {}; + const first = features[0]; + return { + model: !features.every((f) => f.model === first.model), + thinkingLevel: !features.every((f) => f.thinkingLevel === first.thinkingLevel), + planningMode: !features.every((f) => f.planningMode === first.planningMode), + requirePlanApproval: !features.every( + (f) => f.requirePlanApproval === first.requirePlanApproval + ), + priority: !features.every((f) => f.priority === first.priority), + skipTests: !features.every((f) => f.skipTests === first.skipTests), + }; +} + +function getInitialValue(features: Feature[], key: keyof Feature, defaultValue: T): T { + if (features.length === 0) return defaultValue; + return (features[0][key] as T) ?? defaultValue; +} + +interface FieldWrapperProps { + label: string; + isMixed: boolean; + willApply: boolean; + onApplyChange: (apply: boolean) => void; + children: React.ReactNode; +} + +function FieldWrapper({ label, isMixed, willApply, onApplyChange, children }: FieldWrapperProps) { + return ( +
+
+
+ onApplyChange(!!checked)} + className="data-[state=checked]:bg-brand-500 data-[state=checked]:border-brand-500" + /> + +
+ {isMixed && ( + + + Mixed values + + )} +
+
{children}
+
+ ); +} + +export function MassEditDialog({ + open, + onClose, + selectedFeatures, + onApply, + showProfilesOnly, + aiProfiles, +}: MassEditDialogProps) { + const [isApplying, setIsApplying] = useState(false); + + // Track which fields to apply + const [applyState, setApplyState] = useState({ + model: false, + thinkingLevel: false, + planningMode: false, + requirePlanApproval: false, + priority: false, + skipTests: false, + }); + + // Field values + const [model, setModel] = useState('sonnet'); + const [thinkingLevel, setThinkingLevel] = useState('none'); + const [planningMode, setPlanningMode] = useState('skip'); + const [requirePlanApproval, setRequirePlanApproval] = useState(false); + const [priority, setPriority] = useState(2); + const [skipTests, setSkipTests] = useState(false); + + // Calculate mixed values + const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]); + + // Reset state when dialog opens with new features + useEffect(() => { + if (open && selectedFeatures.length > 0) { + setApplyState({ + model: false, + thinkingLevel: false, + planningMode: false, + requirePlanApproval: false, + priority: false, + skipTests: false, + }); + setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias); + setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel); + setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode); + setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false)); + setPriority(getInitialValue(selectedFeatures, 'priority', 2)); + setSkipTests(getInitialValue(selectedFeatures, 'skipTests', false)); + } + }, [open, selectedFeatures]); + + const handleModelSelect = (newModel: string) => { + const isCursor = isCursorModel(newModel); + setModel(newModel as ModelAlias); + if (isCursor || !modelSupportsThinking(newModel)) { + setThinkingLevel('none'); + } + }; + + const handleProfileSelect = (profile: AIProfile) => { + if (profile.provider === 'cursor') { + const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`; + setModel(cursorModel as ModelAlias); + setThinkingLevel('none'); + } else { + setModel((profile.model || 'sonnet') as ModelAlias); + setThinkingLevel(profile.thinkingLevel || 'none'); + } + setApplyState((prev) => ({ ...prev, model: true, thinkingLevel: true })); + }; + + const handleApply = async () => { + const updates: Partial = {}; + + if (applyState.model) updates.model = model; + if (applyState.thinkingLevel) updates.thinkingLevel = thinkingLevel; + if (applyState.planningMode) updates.planningMode = planningMode; + if (applyState.requirePlanApproval) updates.requirePlanApproval = requirePlanApproval; + if (applyState.priority) updates.priority = priority; + if (applyState.skipTests) updates.skipTests = skipTests; + + if (Object.keys(updates).length === 0) { + onClose(); + return; + } + + setIsApplying(true); + try { + await onApply(updates); + onClose(); + } finally { + setIsApplying(false); + } + }; + + const hasAnyApply = Object.values(applyState).some(Boolean); + const isCurrentModelCursor = isCursorModel(model); + const modelAllowsThinking = !isCurrentModelCursor && modelSupportsThinking(model); + + return ( + !open && onClose()}> + + + Edit {selectedFeatures.length} Features + + Select which settings to apply to all selected features. + + + +
+ {/* Quick Select Profile Section */} + {aiProfiles.length > 0 && ( +
+ +

+ Selecting a profile will automatically enable model settings +

+ +
+ )} + + {/* Model Selector */} +
+ +

+ Or select a specific model configuration +

+ { + setModel(entry.model as ModelAlias); + setThinkingLevel(entry.thinkingLevel || 'none'); + // Auto-enable model and thinking level for apply state + setApplyState((prev) => ({ + ...prev, + model: true, + thinkingLevel: true, + })); + }} + compact + /> +
+ + {/* Separator */} +
+ + {/* Planning Mode */} + + setApplyState((prev) => ({ + ...prev, + planningMode: apply, + requirePlanApproval: apply, + })) + } + > + { + setPlanningMode(newMode); + // Auto-suggest approval based on mode, but user can override + setRequirePlanApproval(newMode === 'spec' || newMode === 'full'); + }} + requireApproval={requirePlanApproval} + onRequireApprovalChange={setRequirePlanApproval} + testIdPrefix="mass-edit-planning" + /> + + + {/* Priority */} + setApplyState((prev) => ({ ...prev, priority: apply }))} + > + + + + {/* Testing */} + setApplyState((prev) => ({ ...prev, skipTests: apply }))} + > + + +
+ + + + + + +
+ ); +} diff --git a/apps/ui/src/components/views/board-view/hooks/index.ts b/apps/ui/src/components/views/board-view/hooks/index.ts index 9b855b06..272937f4 100644 --- a/apps/ui/src/components/views/board-view/hooks/index.ts +++ b/apps/ui/src/components/views/board-view/hooks/index.ts @@ -7,3 +7,4 @@ export { useBoardEffects } from './use-board-effects'; export { useBoardBackground } from './use-board-background'; export { useBoardPersistence } from './use-board-persistence'; export { useFollowUpState } from './use-follow-up-state'; +export { useSelectionMode } from './use-selection-mode'; diff --git a/apps/ui/src/components/views/board-view/hooks/use-selection-mode.ts b/apps/ui/src/components/views/board-view/hooks/use-selection-mode.ts new file mode 100644 index 00000000..1470f447 --- /dev/null +++ b/apps/ui/src/components/views/board-view/hooks/use-selection-mode.ts @@ -0,0 +1,82 @@ +import { useState, useCallback, useEffect } from 'react'; + +interface UseSelectionModeReturn { + isSelectionMode: boolean; + selectedFeatureIds: Set; + selectedCount: number; + toggleSelectionMode: () => void; + toggleFeatureSelection: (featureId: string) => void; + selectAll: (featureIds: string[]) => void; + clearSelection: () => void; + isFeatureSelected: (featureId: string) => boolean; + exitSelectionMode: () => void; +} + +export function useSelectionMode(): UseSelectionModeReturn { + const [isSelectionMode, setIsSelectionMode] = useState(false); + const [selectedFeatureIds, setSelectedFeatureIds] = useState>(new Set()); + + const toggleSelectionMode = useCallback(() => { + setIsSelectionMode((prev) => { + if (prev) { + // Exiting selection mode - clear selection + setSelectedFeatureIds(new Set()); + } + return !prev; + }); + }, []); + + const exitSelectionMode = useCallback(() => { + setIsSelectionMode(false); + setSelectedFeatureIds(new Set()); + }, []); + + const toggleFeatureSelection = useCallback((featureId: string) => { + setSelectedFeatureIds((prev) => { + const next = new Set(prev); + if (next.has(featureId)) { + next.delete(featureId); + } else { + next.add(featureId); + } + return next; + }); + }, []); + + const selectAll = useCallback((featureIds: string[]) => { + setSelectedFeatureIds(new Set(featureIds)); + }, []); + + const clearSelection = useCallback(() => { + setSelectedFeatureIds(new Set()); + }, []); + + const isFeatureSelected = useCallback( + (featureId: string) => selectedFeatureIds.has(featureId), + [selectedFeatureIds] + ); + + // Handle Escape key to exit selection mode + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isSelectionMode) { + exitSelectionMode(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isSelectionMode, exitSelectionMode]); + + return { + isSelectionMode, + selectedFeatureIds, + selectedCount: selectedFeatureIds.size, + toggleSelectionMode, + toggleFeatureSelection, + selectAll, + clearSelection, + isFeatureSelected, + exitSelectionMode, + }; +} diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index c21711b9..2962852d 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -2,13 +2,11 @@ import { useMemo } from 'react'; import { DndContext, DragOverlay } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; 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, Archive, Plus, Settings2 } from 'lucide-react'; -import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; +import { Archive, Settings2, CheckSquare, GripVertical } from 'lucide-react'; import { useResponsiveKanban } from '@/hooks/use-responsive-kanban'; -import { getColumnsWithPipeline, type Column, type ColumnId } from './constants'; +import { getColumnsWithPipeline, type ColumnId } from './constants'; import type { PipelineConfig } from '@automaker/types'; interface KanbanBoardProps { @@ -37,7 +35,6 @@ interface KanbanBoardProps { onManualVerify: (feature: Feature) => void; onMoveBackToInProgress: (feature: Feature) => void; onFollowUp: (feature: Feature) => void; - onCommit: (feature: Feature) => void; onComplete: (feature: Feature) => void; onImplement: (feature: Feature) => void; onViewPlan: (feature: Feature) => void; @@ -45,11 +42,14 @@ interface KanbanBoardProps { onSpawnTask?: (feature: Feature) => void; featuresWithContext: Set; runningAutoTasks: string[]; - shortcuts: ReturnType; - onStartNextFeatures: () => void; onArchiveAllVerified: () => void; pipelineConfig: PipelineConfig | null; onOpenPipelineSettings?: () => void; + // Selection mode props + isSelectionMode?: boolean; + selectedFeatureIds?: Set; + onToggleFeatureSelection?: (featureId: string) => void; + onToggleSelectionMode?: () => void; } export function KanbanBoard({ @@ -70,7 +70,6 @@ export function KanbanBoard({ onManualVerify, onMoveBackToInProgress, onFollowUp, - onCommit, onComplete, onImplement, onViewPlan, @@ -78,11 +77,13 @@ export function KanbanBoard({ onSpawnTask, featuresWithContext, runningAutoTasks, - shortcuts, - onStartNextFeatures, onArchiveAllVerified, pipelineConfig, onOpenPipelineSettings, + isSelectionMode = false, + selectedFeatureIds = new Set(), + onToggleFeatureSelection, + onToggleSelectionMode, }: KanbanBoardProps) { // Generate columns including pipeline steps const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]); @@ -126,20 +127,26 @@ export function KanbanBoard({ Complete All ) : column.id === 'backlog' ? ( - columnFeatures.length > 0 && ( - - - Make - - ) + ) : column.id === 'in_progress' ? (