mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge pull request #360 from AutoMaker-Org/feat/mass-edit-backlog-features
feat: add mass edit feature for backlog kanban cards
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
80
Dockerfile.dev
Normal file
80
Dockerfile.dev
Normal file
@@ -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"]
|
||||
19
README.md
19
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
|
||||
|
||||
@@ -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));
|
||||
|
||||
75
apps/server/src/routes/features/routes/bulk-update.ts
Normal file
75
apps/server/src/routes/features/routes/bulk-update.ts
Normal file
@@ -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<Feature>;
|
||||
}
|
||||
|
||||
interface BulkUpdateResult {
|
||||
featureId: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function createBulkUpdateHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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<ReturnType<typeof useAppStore.getState>['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<Feature>) => {
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
<GraphView
|
||||
@@ -1134,6 +1217,27 @@ export function BoardView() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection Action Bar */}
|
||||
{isSelectionMode && (
|
||||
<SelectionActionBar
|
||||
selectedCount={selectedCount}
|
||||
totalCount={allSelectableFeatureIds.length}
|
||||
onEdit={() => setShowMassEditDialog(true)}
|
||||
onClear={clearSelection}
|
||||
onSelectAll={() => selectAll(allSelectableFeatureIds)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mass Edit Dialog */}
|
||||
<MassEditDialog
|
||||
open={showMassEditDialog}
|
||||
onClose={() => setShowMassEditDialog(false)}
|
||||
selectedFeatures={selectedFeatures}
|
||||
onApply={handleBulkUpdate}
|
||||
showProfilesOnly={showProfilesOnly}
|
||||
aiProfiles={aiProfiles}
|
||||
/>
|
||||
|
||||
{/* Board Background Modal */}
|
||||
<BoardBackgroundModal
|
||||
open={showBoardBackgroundModal}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { KanbanCard } from './kanban-card/kanban-card';
|
||||
export { KanbanColumn } from './kanban-column';
|
||||
export { SelectionActionBar } from './selection-action-bar';
|
||||
|
||||
@@ -17,6 +17,7 @@ interface CardActionsProps {
|
||||
isCurrentAutoTask: boolean;
|
||||
hasContext?: boolean;
|
||||
shortcutKey?: string;
|
||||
isSelectionMode?: boolean;
|
||||
onEdit: () => 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 (
|
||||
<div className="flex flex-wrap gap-1.5 -mx-3 -mb-3 px-3 pb-3">
|
||||
{isCurrentAutoTask && (
|
||||
|
||||
@@ -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 (
|
||||
<CardHeader className="p-3 pb-2 block">
|
||||
{/* Running task header */}
|
||||
{isCurrentAutoTask && (
|
||||
{isCurrentAutoTask && !isSelectionMode && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
|
||||
<Loader2 className="w-3.5 h-3.5 text-[var(--status-in-progress)] animate-spin" />
|
||||
@@ -119,7 +121,7 @@ export function CardHeaderSection({
|
||||
)}
|
||||
|
||||
{/* Backlog header */}
|
||||
{!isCurrentAutoTask && feature.status === 'backlog' && (
|
||||
{!isCurrentAutoTask && !isSelectionMode && feature.status === 'backlog' && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -150,6 +152,7 @@ export function CardHeaderSection({
|
||||
|
||||
{/* Waiting approval / Verified header */}
|
||||
{!isCurrentAutoTask &&
|
||||
!isSelectionMode &&
|
||||
(feature.status === 'waiting_approval' || feature.status === 'verified') && (
|
||||
<>
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Feature, useAppStore } from '@/store/app-store';
|
||||
import { CardBadges, PriorityBadges } from './card-badges';
|
||||
import { CardHeaderSection } from './card-header';
|
||||
@@ -22,7 +23,12 @@ function getCardBorderStyle(enabled: boolean, opacity: number): React.CSSPropert
|
||||
return {};
|
||||
}
|
||||
|
||||
function getCursorClass(isOverlay: boolean | undefined, isDraggable: boolean): string {
|
||||
function getCursorClass(
|
||||
isOverlay: boolean | undefined,
|
||||
isDraggable: boolean,
|
||||
isSelectionMode: boolean
|
||||
): string {
|
||||
if (isSelectionMode) return 'cursor-pointer';
|
||||
if (isOverlay) return 'cursor-grabbing';
|
||||
if (isDraggable) return 'cursor-grab active:cursor-grabbing';
|
||||
return 'cursor-default';
|
||||
@@ -54,6 +60,10 @@ interface KanbanCardProps {
|
||||
cardBorderEnabled?: boolean;
|
||||
cardBorderOpacity?: number;
|
||||
isOverlay?: boolean;
|
||||
// Selection mode props
|
||||
isSelectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSelect?: () => void;
|
||||
}
|
||||
|
||||
export const KanbanCard = memo(function KanbanCard({
|
||||
@@ -82,6 +92,9 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
cardBorderEnabled = true,
|
||||
cardBorderOpacity = 100,
|
||||
isOverlay,
|
||||
isSelectionMode = false,
|
||||
isSelected = false,
|
||||
onToggleSelect,
|
||||
}: KanbanCardProps) {
|
||||
const { useWorktrees } = useAppStore();
|
||||
const [isLifted, setIsLifted] = useState(false);
|
||||
@@ -95,13 +108,14 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
}, [isOverlay]);
|
||||
|
||||
const isDraggable =
|
||||
feature.status === 'backlog' ||
|
||||
feature.status === 'waiting_approval' ||
|
||||
feature.status === 'verified' ||
|
||||
(feature.status === 'in_progress' && !isCurrentAutoTask);
|
||||
!isSelectionMode &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'waiting_approval' ||
|
||||
feature.status === 'verified' ||
|
||||
(feature.status === 'in_progress' && !isCurrentAutoTask));
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: feature.id,
|
||||
disabled: !isDraggable || isOverlay,
|
||||
disabled: !isDraggable || isOverlay || isSelectionMode,
|
||||
});
|
||||
|
||||
const dndStyle = {
|
||||
@@ -110,9 +124,12 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
|
||||
const cardStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity);
|
||||
|
||||
// Only allow selection for backlog features
|
||||
const isSelectable = isSelectionMode && feature.status === 'backlog';
|
||||
|
||||
const wrapperClasses = cn(
|
||||
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
|
||||
getCursorClass(isOverlay, isDraggable),
|
||||
getCursorClass(isOverlay, isDraggable, isSelectable),
|
||||
isOverlay && isLifted && 'scale-105 rotate-1 z-50'
|
||||
);
|
||||
|
||||
@@ -127,14 +144,24 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
!isCurrentAutoTask &&
|
||||
cardBorderEnabled &&
|
||||
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
|
||||
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg'
|
||||
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
|
||||
isSelected && isSelectable && 'ring-2 ring-brand-500 ring-offset-1 ring-offset-background'
|
||||
);
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
if (isSelectable && onToggleSelect) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggleSelect();
|
||||
}
|
||||
};
|
||||
|
||||
const renderCardContent = () => (
|
||||
<Card
|
||||
style={isCurrentAutoTask ? undefined : cardStyle}
|
||||
className={innerCardClasses}
|
||||
onDoubleClick={onEdit}
|
||||
onDoubleClick={isSelectionMode ? undefined : onEdit}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* Background overlay with opacity */}
|
||||
{(!isDragging || isOverlay) && (
|
||||
@@ -150,8 +177,16 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
{/* Status Badges Row */}
|
||||
<CardBadges feature={feature} />
|
||||
|
||||
{/* Category row */}
|
||||
<div className="px-3 pt-4">
|
||||
{/* Category row with selection checkbox */}
|
||||
<div className="px-3 pt-3 flex items-center gap-2">
|
||||
{isSelectionMode && !isOverlay && feature.status === 'backlog' && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleSelect?.()}
|
||||
className="h-4 w-4 border-2 data-[state=checked]:bg-brand-500 data-[state=checked]:border-brand-500 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground/70 font-medium">{feature.category}</span>
|
||||
</div>
|
||||
|
||||
@@ -163,6 +198,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
feature={feature}
|
||||
isDraggable={isDraggable}
|
||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onViewOutput={onViewOutput}
|
||||
@@ -187,6 +223,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||
hasContext={hasContext}
|
||||
shortcutKey={shortcutKey}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onEdit={onEdit}
|
||||
onViewOutput={onViewOutput}
|
||||
onVerify={onVerify}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Pencil, X, CheckSquare } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SelectionActionBarProps {
|
||||
selectedCount: number;
|
||||
totalCount: number;
|
||||
onEdit: () => void;
|
||||
onClear: () => void;
|
||||
onSelectAll: () => void;
|
||||
}
|
||||
|
||||
export function SelectionActionBar({
|
||||
selectedCount,
|
||||
totalCount,
|
||||
onEdit,
|
||||
onClear,
|
||||
onSelectAll,
|
||||
}: SelectionActionBarProps) {
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
const allSelected = selectedCount === totalCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-6 left-1/2 -translate-x-1/2 z-50',
|
||||
'flex items-center gap-3 px-4 py-3 rounded-xl',
|
||||
'bg-background/95 backdrop-blur-sm border border-border shadow-lg',
|
||||
'animate-in slide-in-from-bottom-4 fade-in duration-200'
|
||||
)}
|
||||
data-testid="selection-action-bar"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="h-8 bg-brand-500 hover:bg-brand-600"
|
||||
data-testid="selection-edit-button"
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-1.5" />
|
||||
Edit Selected
|
||||
</Button>
|
||||
|
||||
{!allSelected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onSelectAll}
|
||||
className="h-8"
|
||||
data-testid="selection-select-all-button"
|
||||
>
|
||||
<CheckSquare className="w-4 h-4 mr-1.5" />
|
||||
Select All ({totalCount})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClear}
|
||||
className="h-8 text-muted-foreground hover:text-foreground"
|
||||
data-testid="selection-clear-button"
|
||||
>
|
||||
<X className="w-4 h-4 mr-1.5" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<Feature>) => Promise<void>;
|
||||
showProfilesOnly: boolean;
|
||||
aiProfiles: AIProfile[];
|
||||
}
|
||||
|
||||
interface ApplyState {
|
||||
model: boolean;
|
||||
thinkingLevel: boolean;
|
||||
planningMode: boolean;
|
||||
requirePlanApproval: boolean;
|
||||
priority: boolean;
|
||||
skipTests: boolean;
|
||||
}
|
||||
|
||||
function getMixedValues(features: Feature[]): Record<string, boolean> {
|
||||
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<T>(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 (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 rounded-lg border transition-colors',
|
||||
willApply ? 'border-brand-500/50 bg-brand-500/5' : 'border-border bg-muted/20'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={willApply}
|
||||
onCheckedChange={(checked) => onApplyChange(!!checked)}
|
||||
className="data-[state=checked]:bg-brand-500 data-[state=checked]:border-brand-500"
|
||||
/>
|
||||
<Label
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
onClick={() => onApplyChange(!willApply)}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
{isMixed && (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-500">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Mixed values
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn(!willApply && 'opacity-50 pointer-events-none')}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MassEditDialog({
|
||||
open,
|
||||
onClose,
|
||||
selectedFeatures,
|
||||
onApply,
|
||||
showProfilesOnly,
|
||||
aiProfiles,
|
||||
}: MassEditDialogProps) {
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
|
||||
// Track which fields to apply
|
||||
const [applyState, setApplyState] = useState<ApplyState>({
|
||||
model: false,
|
||||
thinkingLevel: false,
|
||||
planningMode: false,
|
||||
requirePlanApproval: false,
|
||||
priority: false,
|
||||
skipTests: false,
|
||||
});
|
||||
|
||||
// Field values
|
||||
const [model, setModel] = useState<ModelAlias>('sonnet');
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('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<Feature> = {};
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-2xl" data-testid="mass-edit-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit {selectedFeatures.length} Features</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select which settings to apply to all selected features.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 pr-4 space-y-4 max-h-[60vh] overflow-y-auto">
|
||||
{/* Quick Select Profile Section */}
|
||||
{aiProfiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Quick Select Profile</Label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Selecting a profile will automatically enable model settings
|
||||
</p>
|
||||
<ProfileSelect
|
||||
profiles={aiProfiles}
|
||||
selectedModel={model}
|
||||
selectedThinkingLevel={thinkingLevel}
|
||||
selectedCursorModel={isCurrentModelCursor ? model : undefined}
|
||||
onSelect={handleProfileSelect}
|
||||
testIdPrefix="mass-edit-profile"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">AI Model</Label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Or select a specific model configuration
|
||||
</p>
|
||||
<PhaseModelSelector
|
||||
value={{ model, thinkingLevel }}
|
||||
onChange={(entry: PhaseModelEntry) => {
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Planning Mode */}
|
||||
<FieldWrapper
|
||||
label="Planning Mode"
|
||||
isMixed={mixedValues.planningMode || mixedValues.requirePlanApproval}
|
||||
willApply={applyState.planningMode || applyState.requirePlanApproval}
|
||||
onApplyChange={(apply) =>
|
||||
setApplyState((prev) => ({
|
||||
...prev,
|
||||
planningMode: apply,
|
||||
requirePlanApproval: apply,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<PlanningModeSelect
|
||||
mode={planningMode}
|
||||
onModeChange={(newMode) => {
|
||||
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"
|
||||
/>
|
||||
</FieldWrapper>
|
||||
|
||||
{/* Priority */}
|
||||
<FieldWrapper
|
||||
label="Priority"
|
||||
isMixed={mixedValues.priority}
|
||||
willApply={applyState.priority}
|
||||
onApplyChange={(apply) => setApplyState((prev) => ({ ...prev, priority: apply }))}
|
||||
>
|
||||
<PrioritySelect
|
||||
selectedPriority={priority}
|
||||
onPrioritySelect={setPriority}
|
||||
testIdPrefix="mass-edit-priority"
|
||||
/>
|
||||
</FieldWrapper>
|
||||
|
||||
{/* Testing */}
|
||||
<FieldWrapper
|
||||
label="Testing"
|
||||
isMixed={mixedValues.skipTests}
|
||||
willApply={applyState.skipTests}
|
||||
onApplyChange={(apply) => setApplyState((prev) => ({ ...prev, skipTests: apply }))}
|
||||
>
|
||||
<TestingTabContent
|
||||
skipTests={skipTests}
|
||||
onSkipTestsChange={setSkipTests}
|
||||
testIdPrefix="mass-edit"
|
||||
/>
|
||||
</FieldWrapper>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={isApplying}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
disabled={!hasAnyApply || isApplying}
|
||||
loading={isApplying}
|
||||
data-testid="mass-edit-apply-button"
|
||||
>
|
||||
Apply to {selectedFeatures.length} Features
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
interface UseSelectionModeReturn {
|
||||
isSelectionMode: boolean;
|
||||
selectedFeatureIds: Set<string>;
|
||||
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<Set<string>>(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,
|
||||
};
|
||||
}
|
||||
@@ -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<string>;
|
||||
runningAutoTasks: string[];
|
||||
shortcuts: ReturnType<typeof useKeyboardShortcutsConfig>;
|
||||
onStartNextFeatures: () => void;
|
||||
onArchiveAllVerified: () => void;
|
||||
pipelineConfig: PipelineConfig | null;
|
||||
onOpenPipelineSettings?: () => void;
|
||||
// Selection mode props
|
||||
isSelectionMode?: boolean;
|
||||
selectedFeatureIds?: Set<string>;
|
||||
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
|
||||
</Button>
|
||||
) : column.id === 'backlog' ? (
|
||||
columnFeatures.length > 0 && (
|
||||
<HotkeyButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
|
||||
onClick={onStartNextFeatures}
|
||||
hotkey={shortcuts.startNext}
|
||||
hotkeyActive={false}
|
||||
data-testid="start-next-button"
|
||||
>
|
||||
<FastForward className="w-3 h-3 mr-1" />
|
||||
Make
|
||||
</HotkeyButton>
|
||||
)
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${isSelectionMode ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={onToggleSelectionMode}
|
||||
title={isSelectionMode ? 'Switch to Drag Mode' : 'Select Multiple'}
|
||||
data-testid="selection-mode-button"
|
||||
>
|
||||
{isSelectionMode ? (
|
||||
<>
|
||||
<GripVertical className="w-3.5 h-3.5 mr-1" />
|
||||
Drag
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckSquare className="w-3.5 h-3.5 mr-1" />
|
||||
Select
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : column.id === 'in_progress' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -200,6 +207,9 @@ export function KanbanBoard({
|
||||
glassmorphism={backgroundSettings.cardGlassmorphism}
|
||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||
isSelectionMode={isSelectionMode}
|
||||
isSelected={selectedFeatureIds.has(feature.id)}
|
||||
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,8 +2,11 @@ export * from './model-constants';
|
||||
export * from './model-selector';
|
||||
export * from './thinking-level-selector';
|
||||
export * from './profile-quick-select';
|
||||
export * from './profile-select';
|
||||
export * from './testing-tab-content';
|
||||
export * from './priority-selector';
|
||||
export * from './priority-select';
|
||||
export * from './branch-selector';
|
||||
export * from './planning-mode-selector';
|
||||
export * from './planning-mode-select';
|
||||
export * from './ancestor-context-section';
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Zap, ClipboardList, FileText, ScrollText } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { PlanningMode } from '@automaker/types';
|
||||
|
||||
interface PlanningModeSelectProps {
|
||||
mode: PlanningMode;
|
||||
onModeChange: (mode: PlanningMode) => void;
|
||||
requireApproval?: boolean;
|
||||
onRequireApprovalChange?: (require: boolean) => void;
|
||||
testIdPrefix?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const modes = [
|
||||
{
|
||||
value: 'skip' as const,
|
||||
label: 'Skip',
|
||||
description: 'Direct implementation, no upfront planning',
|
||||
icon: Zap,
|
||||
color: 'text-emerald-500',
|
||||
},
|
||||
{
|
||||
value: 'lite' as const,
|
||||
label: 'Lite',
|
||||
description: 'Think through approach, create task list',
|
||||
icon: ClipboardList,
|
||||
color: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
value: 'spec' as const,
|
||||
label: 'Spec',
|
||||
description: 'Generate spec with acceptance criteria',
|
||||
icon: FileText,
|
||||
color: 'text-purple-500',
|
||||
},
|
||||
{
|
||||
value: 'full' as const,
|
||||
label: 'Full',
|
||||
description: 'Comprehensive spec with phased plan',
|
||||
icon: ScrollText,
|
||||
color: 'text-amber-500',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* PlanningModeSelect - Compact dropdown selector for planning modes
|
||||
*
|
||||
* A lightweight alternative to PlanningModeSelector for contexts where
|
||||
* spec management UI is not needed (e.g., mass edit, bulk operations).
|
||||
*
|
||||
* Shows icon + label in dropdown, with description text below.
|
||||
* Does not include spec generation, approval, or require-approval checkbox.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PlanningModeSelect
|
||||
* mode={planningMode}
|
||||
* onModeChange={(mode) => {
|
||||
* setPlanningMode(mode);
|
||||
* setRequireApproval(mode === 'spec' || mode === 'full');
|
||||
* }}
|
||||
* testIdPrefix="mass-edit-planning"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function PlanningModeSelect({
|
||||
mode,
|
||||
onModeChange,
|
||||
requireApproval,
|
||||
onRequireApprovalChange,
|
||||
testIdPrefix = 'planning-mode',
|
||||
className,
|
||||
disabled = false,
|
||||
}: PlanningModeSelectProps) {
|
||||
const selectedMode = modes.find((m) => m.value === mode);
|
||||
|
||||
// Disable approval checkbox for skip/lite modes since they don't use planning
|
||||
const isApprovalDisabled = disabled || mode === 'skip' || mode === 'lite';
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<Select
|
||||
value={mode}
|
||||
onValueChange={(value: string) => onModeChange(value as PlanningMode)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-9" data-testid={`${testIdPrefix}-select-trigger`}>
|
||||
<SelectValue>
|
||||
{selectedMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<selectedMode.icon className={cn('h-4 w-4', selectedMode.color)} />
|
||||
<span>{selectedMode.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modes.map((m) => {
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<SelectItem
|
||||
key={m.value}
|
||||
value={m.value}
|
||||
data-testid={`${testIdPrefix}-option-${m.value}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={cn('h-3.5 w-3.5', m.color)} />
|
||||
<span>{m.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedMode && <p className="text-xs text-muted-foreground">{selectedMode.description}</p>}
|
||||
{onRequireApprovalChange && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Checkbox
|
||||
id={`${testIdPrefix}-require-approval`}
|
||||
checked={requireApproval && !isApprovalDisabled}
|
||||
onCheckedChange={(checked) => onRequireApprovalChange(!!checked)}
|
||||
disabled={isApprovalDisabled}
|
||||
data-testid={`${testIdPrefix}-require-approval-checkbox`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${testIdPrefix}-require-approval`}
|
||||
className={cn(
|
||||
'text-sm font-normal',
|
||||
isApprovalDisabled ? 'cursor-not-allowed text-muted-foreground' : 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
Require plan approval before execution
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { AlertCircle, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PrioritySelectProps {
|
||||
selectedPriority: number;
|
||||
onPrioritySelect: (priority: number) => void;
|
||||
testIdPrefix?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const priorities = [
|
||||
{
|
||||
value: 1,
|
||||
label: 'High',
|
||||
description: 'Urgent, needs immediate attention',
|
||||
icon: ChevronUp,
|
||||
color: 'text-red-500',
|
||||
bgColor: 'bg-red-500/10',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: 'Medium',
|
||||
description: 'Normal priority, standard workflow',
|
||||
icon: AlertCircle,
|
||||
color: 'text-yellow-500',
|
||||
bgColor: 'bg-yellow-500/10',
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
label: 'Low',
|
||||
description: 'Can wait, not time-sensitive',
|
||||
icon: ChevronDown,
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-500/10',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* PrioritySelect - Compact dropdown selector for feature priority
|
||||
*
|
||||
* A lightweight alternative to PrioritySelector for contexts where
|
||||
* space is limited (e.g., mass edit, bulk operations).
|
||||
*
|
||||
* Shows icon + priority level in dropdown, with description below.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PrioritySelect
|
||||
* selectedPriority={priority}
|
||||
* onPrioritySelect={setPriority}
|
||||
* testIdPrefix="mass-edit-priority"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function PrioritySelect({
|
||||
selectedPriority,
|
||||
onPrioritySelect,
|
||||
testIdPrefix = 'priority',
|
||||
className,
|
||||
disabled = false,
|
||||
}: PrioritySelectProps) {
|
||||
const selectedPriorityObj = priorities.find((p) => p.value === selectedPriority);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<Select
|
||||
value={selectedPriority.toString()}
|
||||
onValueChange={(value: string) => onPrioritySelect(parseInt(value, 10))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-9" data-testid={`${testIdPrefix}-select-trigger`}>
|
||||
<SelectValue>
|
||||
{selectedPriorityObj && (
|
||||
<div className="flex items-center gap-2">
|
||||
<selectedPriorityObj.icon className={cn('h-4 w-4', selectedPriorityObj.color)} />
|
||||
<span>{selectedPriorityObj.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{priorities.map((p) => {
|
||||
const Icon = p.icon;
|
||||
return (
|
||||
<SelectItem
|
||||
key={p.value}
|
||||
value={p.value.toString()}
|
||||
data-testid={`${testIdPrefix}-option-${p.label.toLowerCase()}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={cn('h-3.5 w-3.5', p.color)} />
|
||||
<span>{p.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedPriorityObj && (
|
||||
<p className="text-xs text-muted-foreground">{selectedPriorityObj.description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Brain, Terminal } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ModelAlias, ThinkingLevel, AIProfile, CursorModelId } from '@automaker/types';
|
||||
import { CURSOR_MODEL_MAP, profileHasThinking, PROVIDER_PREFIXES } from '@automaker/types';
|
||||
import { PROFILE_ICONS } from './model-constants';
|
||||
|
||||
/**
|
||||
* Get display string for a profile's model configuration
|
||||
*/
|
||||
function getProfileModelDisplay(profile: AIProfile): string {
|
||||
if (profile.provider === 'cursor') {
|
||||
const cursorModel = profile.cursorModel || 'auto';
|
||||
const modelConfig = CURSOR_MODEL_MAP[cursorModel];
|
||||
return modelConfig?.label || cursorModel;
|
||||
}
|
||||
// Claude
|
||||
return profile.model || 'sonnet';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display string for a profile's thinking configuration
|
||||
*/
|
||||
function getProfileThinkingDisplay(profile: AIProfile): string | null {
|
||||
if (profile.provider === 'cursor') {
|
||||
// For Cursor, thinking is embedded in the model
|
||||
return profileHasThinking(profile) ? 'thinking' : null;
|
||||
}
|
||||
// Claude
|
||||
return profile.thinkingLevel && profile.thinkingLevel !== 'none' ? profile.thinkingLevel : null;
|
||||
}
|
||||
|
||||
interface ProfileSelectProps {
|
||||
profiles: AIProfile[];
|
||||
selectedModel: ModelAlias | CursorModelId;
|
||||
selectedThinkingLevel: ThinkingLevel;
|
||||
selectedCursorModel?: string; // For detecting cursor profile selection
|
||||
onSelect: (profile: AIProfile) => void;
|
||||
testIdPrefix?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProfileSelect - Compact dropdown selector for AI profiles
|
||||
*
|
||||
* A lightweight alternative to ProfileQuickSelect for contexts where
|
||||
* space is limited (e.g., mass edit, bulk operations).
|
||||
*
|
||||
* Shows icon + profile name in dropdown, with model details below.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ProfileSelect
|
||||
* profiles={aiProfiles}
|
||||
* selectedModel={model}
|
||||
* selectedThinkingLevel={thinkingLevel}
|
||||
* selectedCursorModel={isCurrentModelCursor ? model : undefined}
|
||||
* onSelect={handleProfileSelect}
|
||||
* testIdPrefix="mass-edit-profile"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function ProfileSelect({
|
||||
profiles,
|
||||
selectedModel,
|
||||
selectedThinkingLevel,
|
||||
selectedCursorModel,
|
||||
onSelect,
|
||||
testIdPrefix = 'profile-select',
|
||||
className,
|
||||
disabled = false,
|
||||
}: ProfileSelectProps) {
|
||||
if (profiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if a profile is selected
|
||||
const isProfileSelected = (profile: AIProfile): boolean => {
|
||||
if (profile.provider === 'cursor') {
|
||||
// For cursor profiles, check if cursor model matches
|
||||
const profileCursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
|
||||
return selectedCursorModel === profileCursorModel;
|
||||
}
|
||||
// For Claude profiles
|
||||
return selectedModel === profile.model && selectedThinkingLevel === profile.thinkingLevel;
|
||||
};
|
||||
|
||||
const selectedProfile = profiles.find(isProfileSelected);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<Select
|
||||
value={selectedProfile?.id || 'none'}
|
||||
onValueChange={(value: string) => {
|
||||
if (value !== 'none') {
|
||||
const profile = profiles.find((p) => p.id === value);
|
||||
if (profile) {
|
||||
onSelect(profile);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-9" data-testid={`${testIdPrefix}-select-trigger`}>
|
||||
<SelectValue>
|
||||
{selectedProfile ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedProfile.provider === 'cursor' ? (
|
||||
<Terminal className="h-4 w-4 text-amber-500" />
|
||||
) : (
|
||||
(() => {
|
||||
const IconComponent = selectedProfile.icon
|
||||
? PROFILE_ICONS[selectedProfile.icon]
|
||||
: Brain;
|
||||
return <IconComponent className="h-4 w-4 text-primary" />;
|
||||
})()
|
||||
)}
|
||||
<span>{selectedProfile.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Select a profile...</span>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none" className="text-muted-foreground">
|
||||
No profile selected
|
||||
</SelectItem>
|
||||
{profiles.map((profile) => {
|
||||
const isCursorProfile = profile.provider === 'cursor';
|
||||
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={profile.id}
|
||||
value={profile.id}
|
||||
data-testid={`${testIdPrefix}-option-${profile.id}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCursorProfile ? (
|
||||
<Terminal className="h-3.5 w-3.5 text-amber-500" />
|
||||
) : (
|
||||
<IconComponent className="h-3.5 w-3.5 text-primary" />
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm">{profile.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{getProfileModelDisplay(profile)}
|
||||
{getProfileThinkingDisplay(profile) &&
|
||||
` + ${getProfileThinkingDisplay(profile)}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedProfile && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getProfileModelDisplay(selectedProfile)}
|
||||
{getProfileThinkingDisplay(selectedProfile) &&
|
||||
` + ${getProfileThinkingDisplay(selectedProfile)}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1147,7 +1147,20 @@ export class HttpApiClient implements ElectronAPI {
|
||||
};
|
||||
|
||||
// Features API
|
||||
features: FeaturesAPI = {
|
||||
features: FeaturesAPI & {
|
||||
bulkUpdate: (
|
||||
projectPath: string,
|
||||
featureIds: string[],
|
||||
updates: Partial<Feature>
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
updatedCount?: number;
|
||||
failedCount?: number;
|
||||
results?: Array<{ featureId: string; success: boolean; error?: string }>;
|
||||
features?: Feature[];
|
||||
error?: string;
|
||||
}>;
|
||||
} = {
|
||||
getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }),
|
||||
get: (projectPath: string, featureId: string) =>
|
||||
this.post('/api/features/get', { projectPath, featureId }),
|
||||
@@ -1161,6 +1174,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.post('/api/features/agent-output', { projectPath, featureId }),
|
||||
generateTitle: (description: string) =>
|
||||
this.post('/api/features/generate-title', { description }),
|
||||
bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial<Feature>) =>
|
||||
this.post('/api/features/bulk-update', { projectPath, featureIds, updates }),
|
||||
};
|
||||
|
||||
// Auto Mode API
|
||||
|
||||
6
dev.mjs
6
dev.mjs
@@ -26,7 +26,7 @@ import {
|
||||
startServerAndWait,
|
||||
ensureDependencies,
|
||||
prompt,
|
||||
launchDockerContainers,
|
||||
launchDockerDevContainers,
|
||||
} from './scripts/launcher-utils.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -88,7 +88,7 @@ async function main() {
|
||||
const { webPort, serverPort, corsOriginEnv } = await resolvePortConfiguration();
|
||||
|
||||
// Show mode selection menu
|
||||
printModeMenu();
|
||||
printModeMenu({ isDev: true });
|
||||
|
||||
// Setup cleanup handlers
|
||||
const cleanup = createCleanupHandler(processes);
|
||||
@@ -170,7 +170,7 @@ async function main() {
|
||||
break;
|
||||
} else if (choice === '3') {
|
||||
console.log('');
|
||||
await launchDockerContainers({ baseDir: __dirname, processes });
|
||||
await launchDockerDevContainers({ baseDir: __dirname, processes });
|
||||
break;
|
||||
} else {
|
||||
log('Invalid choice. Please enter 1, 2, or 3.', 'red');
|
||||
|
||||
142
docker-compose.dev.yml
Normal file
142
docker-compose.dev.yml
Normal file
@@ -0,0 +1,142 @@
|
||||
# Automaker Docker Compose - Development Mode
|
||||
# Runs Automaker with live reload for development.
|
||||
# Source code is volume mounted for instant changes.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.dev.yml up
|
||||
# Then open http://localhost:3007
|
||||
#
|
||||
# This mode:
|
||||
# - Mounts source code as volumes (live reload)
|
||||
# - Runs npm install inside container
|
||||
# - Uses Vite dev server with HMR
|
||||
# - Server runs with tsx watch for TypeScript changes
|
||||
|
||||
services:
|
||||
# Development server (backend API)
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: automaker-dev-server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3008:3008'
|
||||
environment:
|
||||
# Required
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
|
||||
# Optional - Claude CLI OAuth credentials
|
||||
- CLAUDE_OAUTH_CREDENTIALS=${CLAUDE_OAUTH_CREDENTIALS:-}
|
||||
|
||||
# Optional - Cursor CLI OAuth token
|
||||
- CURSOR_AUTH_TOKEN=${CURSOR_AUTH_TOKEN:-}
|
||||
|
||||
# Optional - authentication
|
||||
- AUTOMAKER_API_KEY=${AUTOMAKER_API_KEY:-}
|
||||
|
||||
# Development settings
|
||||
- NODE_ENV=development
|
||||
- PORT=3008
|
||||
- CORS_ORIGIN=http://localhost:3007
|
||||
|
||||
# Optional - restrict to specific directory within container
|
||||
- ALLOWED_ROOT_DIRECTORY=${ALLOWED_ROOT_DIRECTORY:-/projects}
|
||||
- DATA_DIR=/data
|
||||
|
||||
# Internal - indicates containerized environment
|
||||
- IS_CONTAINERIZED=true
|
||||
volumes:
|
||||
# Mount source code for live reload
|
||||
- .:/app:cached
|
||||
|
||||
# Use named volume for node_modules to avoid platform conflicts
|
||||
# This ensures native modules are built for the container's architecture
|
||||
- automaker-dev-node-modules:/app/node_modules
|
||||
|
||||
# Persist data across restarts
|
||||
- automaker-data:/data
|
||||
|
||||
# Persist CLI configurations
|
||||
- automaker-claude-config:/home/automaker/.claude
|
||||
- automaker-cursor-config:/home/automaker/.cursor
|
||||
|
||||
# Note: Workspace mount (/projects) comes from docker-compose.override.yml
|
||||
|
||||
# Install deps, build packages, then start server in watch mode
|
||||
# Note: We override the entrypoint to handle permissions properly
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
# Fix permissions on node_modules (created as root by Docker volume)
|
||||
echo 'Fixing node_modules permissions...'
|
||||
chown -R automaker:automaker /app/node_modules 2>/dev/null || true
|
||||
|
||||
# Run the rest as automaker user
|
||||
exec gosu automaker sh -c "
|
||||
echo 'Installing dependencies...' &&
|
||||
npm install &&
|
||||
echo 'Building shared packages...' &&
|
||||
npm run build:packages &&
|
||||
echo 'Starting server in development mode...' &&
|
||||
npm run _dev:server
|
||||
"
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:3008/api/health']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
# Development UI (frontend with HMR)
|
||||
ui:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: automaker-dev-ui
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3007:3007'
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- VITE_SERVER_URL=http://localhost:3008
|
||||
- TEST_PORT=3007
|
||||
- VITE_SKIP_ELECTRON=true
|
||||
volumes:
|
||||
# Mount source code for live reload
|
||||
- .:/app:cached
|
||||
|
||||
# Share node_modules with server container
|
||||
- automaker-dev-node-modules:/app/node_modules
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
working_dir: /app/apps/ui
|
||||
# Start Vite dev server for UI with HMR
|
||||
# --host flag makes Vite bind to 0.0.0.0 for Docker access
|
||||
# Note: We override the entrypoint to run as automaker user
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
exec gosu automaker sh -c "
|
||||
echo 'Waiting for dependencies to be ready...' &&
|
||||
while [ ! -d /app/node_modules/.bin ]; do sleep 2; done &&
|
||||
echo 'Starting UI development server...' &&
|
||||
cd /app/apps/ui && npx vite --host
|
||||
"
|
||||
|
||||
volumes:
|
||||
automaker-dev-node-modules:
|
||||
name: automaker-dev-node-modules
|
||||
# Named volume for container-specific node_modules
|
||||
|
||||
automaker-data:
|
||||
name: automaker-data
|
||||
|
||||
automaker-claude-config:
|
||||
name: automaker-claude-config
|
||||
|
||||
automaker-cursor-config:
|
||||
name: automaker-cursor-config
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -20,7 +20,8 @@
|
||||
"devDependencies": {
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.2.7",
|
||||
"prettier": "3.7.4"
|
||||
"prettier": "3.7.4",
|
||||
"vitest": "4.0.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <23.0.0"
|
||||
@@ -28,7 +29,7 @@
|
||||
},
|
||||
"apps/server": {
|
||||
"name": "@automaker/server",
|
||||
"version": "0.7.3",
|
||||
"version": "0.8.0",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.76",
|
||||
@@ -78,7 +79,7 @@
|
||||
},
|
||||
"apps/ui": {
|
||||
"name": "@automaker/ui",
|
||||
"version": "0.7.3",
|
||||
"version": "0.8.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"postinstall": "node -e \"const fs=require('fs');if(process.platform==='darwin'){['darwin-arm64','darwin-x64'].forEach(a=>{const p='node_modules/node-pty/prebuilds/'+a+'/spawn-helper';if(fs.existsSync(p))fs.chmodSync(p,0o755)})}\" && node scripts/fix-lockfile-urls.mjs",
|
||||
"fix:lockfile": "node scripts/fix-lockfile-urls.mjs",
|
||||
"dev": "node dev.mjs",
|
||||
"start": "node start.mjs",
|
||||
"_dev:web": "npm run dev:web --workspace=apps/ui",
|
||||
"_dev:electron": "npm run dev:electron --workspace=apps/ui",
|
||||
"_dev:electron:debug": "npm run dev:electron:debug --workspace=apps/ui",
|
||||
@@ -27,6 +26,7 @@
|
||||
"dev:electron:wsl:gpu": "npm run build:packages && npm run _dev:electron:wsl:gpu",
|
||||
"dev:server": "npm run build:packages && npm run _dev:server",
|
||||
"dev:docker": "docker compose up",
|
||||
"dev:docker:rebuild": "docker compose build --no-cache && docker compose up",
|
||||
"dev:full": "npm run build:packages && concurrently \"npm run _dev:server\" \"npm run _dev:web\"",
|
||||
"build": "npm run build:packages && npm run build --workspace=apps/ui",
|
||||
"build:packages": "npm run build -w @automaker/types && npm run build -w @automaker/platform && npm run build -w @automaker/utils && npm run build -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver && npm run build -w @automaker/git-utils",
|
||||
@@ -51,6 +51,7 @@
|
||||
"lint:lockfile": "node scripts/lint-lockfile.mjs",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"rebuild": "rm -rf node_modules apps/*/node_modules libs/*/node_modules && npm install",
|
||||
"prepare": "husky && npm run build:packages"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Shared utilities for Automaker launcher scripts (dev.mjs and start.mjs)
|
||||
* Shared utilities for Automaker launcher scripts (dev.mjs)
|
||||
*
|
||||
* This module contains cross-platform utilities for:
|
||||
* - Process management (ports, killing processes)
|
||||
@@ -489,14 +489,20 @@ export function printHeader(title) {
|
||||
|
||||
/**
|
||||
* Print the application mode menu
|
||||
* @param {object} options - Menu options
|
||||
* @param {boolean} options.isDev - Whether this is dev mode (changes Docker option description)
|
||||
*/
|
||||
export function printModeMenu() {
|
||||
export function printModeMenu({ isDev = false } = {}) {
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log(' Select Application Mode:');
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log(' 1) Web Application (Browser)');
|
||||
console.log(' 2) Desktop Application (Electron)');
|
||||
console.log(' 3) Docker Container (Isolated)');
|
||||
if (isDev) {
|
||||
console.log(' 3) Docker Container (Dev with Live Reload)');
|
||||
} else {
|
||||
console.log(' 3) Docker Container (Isolated)');
|
||||
}
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log('');
|
||||
}
|
||||
@@ -678,19 +684,58 @@ export function sanitizeProjectName(name) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Docker images need to be rebuilt based on Dockerfile or package.json changes
|
||||
* @param {string} baseDir - Base directory containing Dockerfile and package.json
|
||||
* @returns {boolean} - Whether images need to be rebuilt
|
||||
* Get the current git commit SHA
|
||||
* @param {string} baseDir - Base directory of the git repository
|
||||
* @returns {string|null} - Current commit SHA or null if not available
|
||||
*/
|
||||
export function getCurrentCommitSha(baseDir) {
|
||||
try {
|
||||
const sha = execSync('git rev-parse HEAD', {
|
||||
encoding: 'utf-8',
|
||||
cwd: baseDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
return sha || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the commit SHA from a Docker image label
|
||||
* @param {string} imageName - Docker image name
|
||||
* @returns {string|null} - Commit SHA from image label or null if not found
|
||||
*/
|
||||
export function getImageCommitSha(imageName) {
|
||||
try {
|
||||
const labelValue = execSync(
|
||||
`docker image inspect ${imageName} --format "{{index .Config.Labels \\"automaker.git.commit.sha\\"}}" 2>/dev/null`,
|
||||
{ encoding: 'utf-8' }
|
||||
).trim();
|
||||
return labelValue && labelValue !== 'unknown' && labelValue !== '' ? labelValue : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Docker images need to be rebuilt based on git commit SHA
|
||||
* Compares the current git commit with the commit SHA stored in the image labels
|
||||
* @param {string} baseDir - Base directory containing Dockerfile and docker-compose.yml
|
||||
* @returns {{needsRebuild: boolean, reason: string, currentSha: string|null, imageSha: string|null}}
|
||||
*/
|
||||
export function shouldRebuildDockerImages(baseDir) {
|
||||
try {
|
||||
const dockerfilePath = path.join(baseDir, 'Dockerfile');
|
||||
const packageJsonPath = path.join(baseDir, 'package.json');
|
||||
|
||||
// Get modification times of source files
|
||||
const dockerfileMtime = statSync(dockerfilePath).mtimeMs;
|
||||
const packageJsonMtime = statSync(packageJsonPath).mtimeMs;
|
||||
const latestSourceMtime = Math.max(dockerfileMtime, packageJsonMtime);
|
||||
// Get current git commit SHA
|
||||
const currentSha = getCurrentCommitSha(baseDir);
|
||||
if (!currentSha) {
|
||||
return {
|
||||
needsRebuild: true,
|
||||
reason: 'Could not determine current git commit',
|
||||
currentSha: null,
|
||||
imageSha: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Get project name from docker-compose config, falling back to directory name
|
||||
let projectName;
|
||||
@@ -701,76 +746,94 @@ export function shouldRebuildDockerImages(baseDir) {
|
||||
});
|
||||
const config = JSON.parse(composeConfig);
|
||||
projectName = config.name;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Fallback handled below
|
||||
}
|
||||
|
||||
// Sanitize project name (whether from config or fallback)
|
||||
// This prevents command injection and ensures valid Docker image names
|
||||
// Sanitize project name
|
||||
const sanitizedProjectName = sanitizeProjectName(projectName || path.basename(baseDir));
|
||||
const serverImageName = `${sanitizedProjectName}_server`;
|
||||
const uiImageName = `${sanitizedProjectName}_ui`;
|
||||
const serverImageName = `${sanitizedProjectName}-server`;
|
||||
const uiImageName = `${sanitizedProjectName}-ui`;
|
||||
|
||||
// Check if images exist and get their creation times
|
||||
let needsRebuild = false;
|
||||
// Check if images exist
|
||||
const serverExists = checkImageExists(serverImageName);
|
||||
const uiExists = checkImageExists(uiImageName);
|
||||
|
||||
try {
|
||||
// Check server image
|
||||
const serverImageInfo = execSync(
|
||||
`docker image inspect ${serverImageName} --format "{{.Created}}" 2>/dev/null || echo ""`,
|
||||
{ encoding: 'utf-8', cwd: baseDir }
|
||||
).trim();
|
||||
|
||||
// Check UI image
|
||||
const uiImageInfo = execSync(
|
||||
`docker image inspect ${uiImageName} --format "{{.Created}}" 2>/dev/null || echo ""`,
|
||||
{ encoding: 'utf-8', cwd: baseDir }
|
||||
).trim();
|
||||
|
||||
// If either image doesn't exist, we need to rebuild
|
||||
if (!serverImageInfo || !uiImageInfo) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parse image creation times (ISO 8601 format)
|
||||
const serverCreated = new Date(serverImageInfo).getTime();
|
||||
const uiCreated = new Date(uiImageInfo).getTime();
|
||||
const oldestImageTime = Math.min(serverCreated, uiCreated);
|
||||
|
||||
// If source files are newer than images, rebuild
|
||||
needsRebuild = latestSourceMtime > oldestImageTime;
|
||||
} catch (error) {
|
||||
// If images don't exist or inspect fails, rebuild
|
||||
needsRebuild = true;
|
||||
if (!serverExists || !uiExists) {
|
||||
return {
|
||||
needsRebuild: true,
|
||||
reason: 'Docker images do not exist',
|
||||
currentSha,
|
||||
imageSha: null,
|
||||
};
|
||||
}
|
||||
|
||||
return needsRebuild;
|
||||
// Get commit SHA from server image (both should have the same)
|
||||
const imageSha = getImageCommitSha(serverImageName);
|
||||
|
||||
if (!imageSha) {
|
||||
return {
|
||||
needsRebuild: true,
|
||||
reason: 'Docker images have no commit SHA label (legacy build)',
|
||||
currentSha,
|
||||
imageSha: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Compare commit SHAs
|
||||
if (currentSha !== imageSha) {
|
||||
return {
|
||||
needsRebuild: true,
|
||||
reason: `Code changed: ${imageSha.substring(0, 8)} -> ${currentSha.substring(0, 8)}`,
|
||||
currentSha,
|
||||
imageSha,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
needsRebuild: false,
|
||||
reason: 'Images are up to date',
|
||||
currentSha,
|
||||
imageSha,
|
||||
};
|
||||
} catch (error) {
|
||||
// If we can't check, err on the side of rebuilding
|
||||
log('Could not check Docker image status, will rebuild to be safe', 'yellow');
|
||||
return true;
|
||||
return {
|
||||
needsRebuild: true,
|
||||
reason: 'Could not check Docker image status',
|
||||
currentSha: null,
|
||||
imageSha: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch Docker containers with docker-compose
|
||||
* Check if a Docker image exists
|
||||
* @param {string} imageName - Docker image name
|
||||
* @returns {boolean} - Whether the image exists
|
||||
*/
|
||||
function checkImageExists(imageName) {
|
||||
try {
|
||||
execSync(`docker image inspect ${imageName} 2>/dev/null`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch Docker containers for development with live reload
|
||||
* Uses docker-compose.dev.yml which volume mounts the source code
|
||||
* Also includes docker-compose.override.yml if it exists (for workspace mounts)
|
||||
* @param {object} options - Configuration options
|
||||
* @param {string} options.baseDir - Base directory containing docker-compose.yml
|
||||
* @param {string} options.baseDir - Base directory containing docker-compose.dev.yml
|
||||
* @param {object} options.processes - Processes object to track docker process
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function launchDockerContainers({ baseDir, processes }) {
|
||||
log('Launching Docker Container (Isolated Mode)...', 'blue');
|
||||
|
||||
// Check if Dockerfile or package.json changed and rebuild if needed
|
||||
const needsRebuild = shouldRebuildDockerImages(baseDir);
|
||||
const buildFlag = needsRebuild ? ['--build'] : [];
|
||||
|
||||
if (needsRebuild) {
|
||||
log('Dockerfile or package.json changed - rebuilding images...', 'yellow');
|
||||
} else {
|
||||
log('Starting Docker containers...', 'yellow');
|
||||
}
|
||||
export async function launchDockerDevContainers({ baseDir, processes }) {
|
||||
log('Launching Docker Container (Development Mode with Live Reload)...', 'blue');
|
||||
console.log('');
|
||||
|
||||
// Check if ANTHROPIC_API_KEY is set
|
||||
@@ -781,9 +844,26 @@ export async function launchDockerContainers({ baseDir, processes }) {
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Start containers with docker-compose
|
||||
// Will rebuild if Dockerfile or package.json changed
|
||||
processes.docker = crossSpawn('docker', ['compose', 'up', ...buildFlag], {
|
||||
log('Starting development container...', 'yellow');
|
||||
log('Source code is volume mounted for live reload', 'yellow');
|
||||
log('Running npm install inside container (this may take a moment on first run)...', 'yellow');
|
||||
console.log('');
|
||||
|
||||
// Build compose file arguments
|
||||
// Start with dev compose file, then add override if it exists
|
||||
const composeArgs = ['compose', '-f', 'docker-compose.dev.yml'];
|
||||
|
||||
// Check if docker-compose.override.yml exists and include it for workspace mounts
|
||||
const overridePath = path.join(baseDir, 'docker-compose.override.yml');
|
||||
if (fsNative.existsSync(overridePath)) {
|
||||
composeArgs.push('-f', 'docker-compose.override.yml');
|
||||
log('Using docker-compose.override.yml for workspace mount', 'yellow');
|
||||
}
|
||||
|
||||
composeArgs.push('up', '--build');
|
||||
|
||||
// Use docker-compose.dev.yml for development
|
||||
processes.docker = crossSpawn('docker', composeArgs, {
|
||||
stdio: 'inherit',
|
||||
cwd: baseDir,
|
||||
env: {
|
||||
@@ -791,6 +871,99 @@ export async function launchDockerContainers({ baseDir, processes }) {
|
||||
},
|
||||
});
|
||||
|
||||
log('Development container starting...', 'blue');
|
||||
log('UI will be available at: http://localhost:3007 (with HMR)', 'green');
|
||||
log('API will be available at: http://localhost:3008', 'green');
|
||||
console.log('');
|
||||
log('Changes to source files will automatically reload.', 'yellow');
|
||||
log('Press Ctrl+C to stop the container.', 'yellow');
|
||||
|
||||
await new Promise((resolve) => {
|
||||
processes.docker.on('close', resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch Docker containers with docker-compose (production mode)
|
||||
* Uses git commit SHA to determine if rebuild is needed
|
||||
* @param {object} options - Configuration options
|
||||
* @param {string} options.baseDir - Base directory containing docker-compose.yml
|
||||
* @param {object} options.processes - Processes object to track docker process
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function launchDockerContainers({ baseDir, processes }) {
|
||||
log('Launching Docker Container (Isolated Mode)...', 'blue');
|
||||
|
||||
// Check if ANTHROPIC_API_KEY is set
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
log('Warning: ANTHROPIC_API_KEY environment variable is not set.', 'yellow');
|
||||
log('The server will require an API key to function.', 'yellow');
|
||||
log('Set it with: export ANTHROPIC_API_KEY=your-key', 'yellow');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Check if rebuild is needed based on git commit SHA
|
||||
const rebuildCheck = shouldRebuildDockerImages(baseDir);
|
||||
|
||||
if (rebuildCheck.needsRebuild) {
|
||||
log(`Rebuild needed: ${rebuildCheck.reason}`, 'yellow');
|
||||
|
||||
if (rebuildCheck.currentSha) {
|
||||
log(`Building images for commit: ${rebuildCheck.currentSha.substring(0, 8)}`, 'blue');
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Build with commit SHA label
|
||||
const buildArgs = ['compose', 'build'];
|
||||
if (rebuildCheck.currentSha) {
|
||||
buildArgs.push('--build-arg', `GIT_COMMIT_SHA=${rebuildCheck.currentSha}`);
|
||||
}
|
||||
|
||||
const buildProcess = crossSpawn('docker', buildArgs, {
|
||||
stdio: 'inherit',
|
||||
cwd: baseDir,
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
buildProcess.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
log('Build failed. Exiting.', 'red');
|
||||
reject(new Error(`Docker build failed with code ${code}`));
|
||||
} else {
|
||||
log('Build complete. Starting containers...', 'green');
|
||||
console.log('');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
buildProcess.on('error', (err) => reject(err));
|
||||
});
|
||||
|
||||
// Start containers (already built above)
|
||||
processes.docker = crossSpawn('docker', ['compose', 'up'], {
|
||||
stdio: 'inherit',
|
||||
cwd: baseDir,
|
||||
env: {
|
||||
...process.env,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
log(
|
||||
`Images are up to date (commit: ${rebuildCheck.currentSha?.substring(0, 8) || 'unknown'})`,
|
||||
'green'
|
||||
);
|
||||
log('Starting Docker containers...', 'yellow');
|
||||
console.log('');
|
||||
|
||||
// Start containers without rebuilding
|
||||
processes.docker = crossSpawn('docker', ['compose', 'up'], {
|
||||
stdio: 'inherit',
|
||||
cwd: baseDir,
|
||||
env: {
|
||||
...process.env,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
log('Docker containers starting...', 'blue');
|
||||
log('UI will be available at: http://localhost:3007', 'green');
|
||||
log('API will be available at: http://localhost:3008', 'green');
|
||||
|
||||
247
start.mjs
247
start.mjs
@@ -1,247 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Automaker - Production Mode Launch Script
|
||||
*
|
||||
* This script runs the application in production mode (no Vite dev server).
|
||||
* It builds everything if needed, then serves static files via vite preview.
|
||||
*
|
||||
* Key differences from dev.mjs:
|
||||
* - Uses pre-built static files instead of Vite dev server (faster startup)
|
||||
* - No HMR or hot reloading
|
||||
* - Server runs from compiled dist/ directory
|
||||
* - Uses "vite preview" to serve static UI files
|
||||
*
|
||||
* Usage: npm run start
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import {
|
||||
createRestrictedFs,
|
||||
log,
|
||||
runNpmAndWait,
|
||||
runNpx,
|
||||
printHeader,
|
||||
printModeMenu,
|
||||
resolvePortConfiguration,
|
||||
createCleanupHandler,
|
||||
setupSignalHandlers,
|
||||
startServerAndWait,
|
||||
ensureDependencies,
|
||||
prompt,
|
||||
killProcessTree,
|
||||
sleep,
|
||||
launchDockerContainers,
|
||||
} from './scripts/launcher-utils.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Create restricted fs for this script's directory
|
||||
const fs = createRestrictedFs(__dirname, 'start.mjs');
|
||||
|
||||
// Track background processes for cleanup
|
||||
const processes = {
|
||||
server: null,
|
||||
web: null,
|
||||
electron: null,
|
||||
docker: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Build all production artifacts
|
||||
*/
|
||||
async function ensureProductionBuilds() {
|
||||
// Always build shared packages first to ensure they're up to date
|
||||
log('Building shared packages...', 'blue');
|
||||
try {
|
||||
await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' }, __dirname);
|
||||
log('✓ Shared packages built', 'green');
|
||||
} catch (error) {
|
||||
log(`Failed to build shared packages: ${error.message}`, 'red');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Always rebuild server to ensure it's in sync with packages
|
||||
log('Building server...', 'blue');
|
||||
try {
|
||||
await runNpmAndWait(
|
||||
['run', 'build'],
|
||||
{ stdio: 'inherit' },
|
||||
path.join(__dirname, 'apps', 'server')
|
||||
);
|
||||
log('✓ Server built', 'green');
|
||||
} catch (error) {
|
||||
log(`Failed to build server: ${error.message}`, 'red');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Always rebuild UI to ensure it's in sync with latest code
|
||||
log('Building UI...', 'blue');
|
||||
try {
|
||||
await runNpmAndWait(['run', 'build'], { stdio: 'inherit' }, __dirname);
|
||||
log('✓ UI built', 'green');
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
log(`Failed to build UI: ${error.message}`, 'red');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
async function main() {
|
||||
// Change to script directory
|
||||
process.chdir(__dirname);
|
||||
|
||||
printHeader('Automaker Production Mode');
|
||||
|
||||
// Ensure dependencies are installed
|
||||
await ensureDependencies(fs, __dirname);
|
||||
|
||||
// Build production artifacts if needed
|
||||
await ensureProductionBuilds();
|
||||
|
||||
// Resolve port configuration (check/kill/change ports)
|
||||
const { webPort, serverPort, corsOriginEnv } = await resolvePortConfiguration();
|
||||
|
||||
// Show mode selection menu
|
||||
printModeMenu();
|
||||
|
||||
// Setup cleanup handlers
|
||||
const cleanup = createCleanupHandler(processes);
|
||||
setupSignalHandlers(cleanup);
|
||||
|
||||
// Prompt for choice
|
||||
while (true) {
|
||||
const choice = await prompt('Enter your choice (1, 2, or 3): ');
|
||||
|
||||
if (choice === '1') {
|
||||
console.log('');
|
||||
log('Launching Web Application (Production Mode)...', 'blue');
|
||||
|
||||
// Start the backend server in PRODUCTION mode
|
||||
// Uses "npm run start" in apps/server which runs the compiled dist/
|
||||
// NOT the Vite dev server (no HMR, faster startup)
|
||||
processes.server = await startServerAndWait({
|
||||
serverPort,
|
||||
corsOriginEnv,
|
||||
npmArgs: ['run', 'start'],
|
||||
cwd: path.join(__dirname, 'apps', 'server'),
|
||||
fs,
|
||||
baseDir: __dirname,
|
||||
});
|
||||
|
||||
if (!processes.server) {
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log(`Starting web server...`, 'blue');
|
||||
|
||||
// Start vite preview to serve pre-built static files
|
||||
// This is NOT Vite dev server - it just serves the dist/ folder
|
||||
// No HMR, no compilation, just static file serving
|
||||
processes.web = runNpx(
|
||||
['vite', 'preview', '--port', String(webPort)],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
VITE_SERVER_URL: `http://localhost:${serverPort}`,
|
||||
},
|
||||
},
|
||||
path.join(__dirname, 'apps', 'ui')
|
||||
);
|
||||
|
||||
log(`The application is available at: http://localhost:${webPort}`, 'green');
|
||||
console.log('');
|
||||
|
||||
await new Promise((resolve) => {
|
||||
processes.web.on('close', resolve);
|
||||
});
|
||||
|
||||
break;
|
||||
} else if (choice === '2') {
|
||||
console.log('');
|
||||
log('Launching Desktop Application (Production Mode)...', 'blue');
|
||||
log('(Electron will start its own backend server)', 'yellow');
|
||||
console.log('');
|
||||
|
||||
// Run electron directly with the built main.js
|
||||
const electronMainPath = path.join(__dirname, 'apps', 'ui', 'dist-electron', 'main.js');
|
||||
|
||||
if (!fs.existsSync(electronMainPath)) {
|
||||
log('Error: Electron main process not built. Run build first.', 'red');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Start vite preview to serve built static files for electron
|
||||
// (Electron in non-packaged mode needs a server to load from)
|
||||
log('Starting static file server...', 'blue');
|
||||
processes.web = runNpx(
|
||||
['vite', 'preview', '--port', String(webPort)],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
VITE_SERVER_URL: `http://localhost:${serverPort}`,
|
||||
},
|
||||
},
|
||||
path.join(__dirname, 'apps', 'ui')
|
||||
);
|
||||
|
||||
// Wait for vite preview to start
|
||||
await sleep(2000);
|
||||
|
||||
// Use electron from node_modules with NODE_ENV=production
|
||||
// This ensures electron loads from the preview server, not Vite dev
|
||||
processes.electron = runNpx(
|
||||
['electron', electronMainPath],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
TEST_PORT: String(webPort),
|
||||
PORT: String(serverPort),
|
||||
VITE_DEV_SERVER_URL: `http://localhost:${webPort}`,
|
||||
VITE_SERVER_URL: `http://localhost:${serverPort}`,
|
||||
CORS_ORIGIN: corsOriginEnv,
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
},
|
||||
path.join(__dirname, 'apps', 'ui')
|
||||
);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
processes.electron.on('close', () => {
|
||||
// Also kill vite preview when electron closes
|
||||
if (processes.web && !processes.web.killed && processes.web.pid) {
|
||||
killProcessTree(processes.web.pid);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
break;
|
||||
} else if (choice === '3') {
|
||||
console.log('');
|
||||
await launchDockerContainers({ baseDir: __dirname, processes });
|
||||
break;
|
||||
} else {
|
||||
log('Invalid choice. Please enter 1, 2, or 3.', 'red');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run main function
|
||||
main().catch(async (err) => {
|
||||
console.error(err);
|
||||
const cleanup = createCleanupHandler(processes);
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (cleanupErr) {
|
||||
console.error('Cleanup error:', cleanupErr);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user