Merge branch 'AutoMaker-Org:main' into main

This commit is contained in:
yumesha
2026-01-08 08:52:59 +08:00
committed by GitHub
31 changed files with 2059 additions and 410 deletions

View File

@@ -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}

View File

@@ -1,2 +1,3 @@
export { KanbanCard } from './kanban-card/kanban-card';
export { KanbanColumn } from './kanban-column';
export { SelectionActionBar } from './selection-action-bar';

View File

@@ -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 && (

View File

@@ -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">

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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';

View File

@@ -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>
);
}

View File

@@ -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';

View File

@@ -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,
};
}

View File

@@ -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)}
/>
);
})}

View File

@@ -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';

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -131,6 +131,39 @@ export const isElectronMode = (): boolean => {
return api?.isElectron === true || !!api?.getApiKey;
};
// Cached external server mode flag
let cachedExternalServerMode: boolean | null = null;
/**
* Check if running in external server mode (Docker API)
* In this mode, Electron uses session-based auth like web mode
*/
export const checkExternalServerMode = async (): Promise<boolean> => {
if (cachedExternalServerMode !== null) {
return cachedExternalServerMode;
}
if (typeof window !== 'undefined') {
const api = window.electronAPI as any;
if (api?.isExternalServerMode) {
try {
cachedExternalServerMode = Boolean(await api.isExternalServerMode());
return cachedExternalServerMode;
} catch (error) {
logger.warn('Failed to check external server mode:', error);
}
}
}
cachedExternalServerMode = false;
return false;
};
/**
* Get cached external server mode (synchronous, returns null if not yet checked)
*/
export const isExternalServerMode = (): boolean | null => cachedExternalServerMode;
/**
* Initialize API key and server URL for Electron mode authentication.
* In web mode, authentication uses HTTP-only cookies instead.
@@ -463,19 +496,29 @@ export class HttpApiClient implements ElectronAPI {
this.isConnecting = true;
// Electron mode must authenticate with the injected API key.
// If the key isn't ready yet, do NOT fall back to /api/auth/token (web-mode flow).
// Electron mode typically authenticates with the injected API key.
// However, in external-server/cookie-auth flows, the API key may be unavailable.
// In that case, fall back to the same wsToken/cookie authentication used in web mode
// so the UI still receives real-time events (running tasks, logs, etc.).
if (isElectronMode()) {
const apiKey = getApiKey();
if (!apiKey) {
logger.warn('Electron mode: API key not ready, delaying WebSocket connect');
this.isConnecting = false;
if (!this.reconnectTimer) {
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connectWebSocket();
}, 250);
}
logger.warn('Electron mode: API key missing, attempting wsToken/cookie auth for WebSocket');
this.fetchWsToken()
.then((wsToken) => {
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
if (wsToken) {
this.establishWebSocket(`${wsUrl}?wsToken=${encodeURIComponent(wsToken)}`);
} else {
// Fallback: try connecting without token (will fail if not authenticated)
logger.warn('No wsToken available, attempting WebSocket connection anyway');
this.establishWebSocket(wsUrl);
}
})
.catch((error) => {
logger.error('Failed to prepare WebSocket connection (electron fallback):', error);
this.isConnecting = false;
});
return;
}
@@ -1147,7 +1190,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 +1217,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

View File

@@ -132,6 +132,9 @@ let saveWindowBoundsTimeout: ReturnType<typeof setTimeout> | null = null;
// API key for CSRF protection
let apiKey: string | null = null;
// Track if we're using an external server (Docker API mode)
let isExternalServerMode = false;
/**
* Get the relative path to API key file within userData
*/
@@ -688,14 +691,35 @@ app.whenReady().then(async () => {
}
}
// Generate or load API key for CSRF protection (before starting server)
ensureApiKey();
try {
// Find available ports (prevents conflicts with other apps using same ports)
serverPort = await findAvailablePort(DEFAULT_SERVER_PORT);
if (serverPort !== DEFAULT_SERVER_PORT) {
logger.info('Default server port', DEFAULT_SERVER_PORT, 'in use, using port', serverPort);
// Check if we should skip the embedded server (for Docker API mode)
const skipEmbeddedServer = process.env.SKIP_EMBEDDED_SERVER === 'true';
isExternalServerMode = skipEmbeddedServer;
if (skipEmbeddedServer) {
// Use the default server port (Docker container runs on 3008)
serverPort = DEFAULT_SERVER_PORT;
logger.info('SKIP_EMBEDDED_SERVER=true, using external server at port', serverPort);
// Wait for external server to be ready
logger.info('Waiting for external server...');
await waitForServer(60); // Give Docker container more time to start
logger.info('External server is ready');
// In external server mode, we don't set an API key here.
// The renderer will detect external server mode and use session-based
// auth like web mode, redirecting to /login where the user enters
// the API key from the Docker container logs.
logger.info('External server mode: using session-based authentication');
} else {
// Generate or load API key for CSRF protection (before starting server)
ensureApiKey();
// Find available ports (prevents conflicts with other apps using same ports)
serverPort = await findAvailablePort(DEFAULT_SERVER_PORT);
if (serverPort !== DEFAULT_SERVER_PORT) {
logger.info('Default server port', DEFAULT_SERVER_PORT, 'in use, using port', serverPort);
}
}
staticPort = await findAvailablePort(DEFAULT_STATIC_PORT);
@@ -708,8 +732,10 @@ app.whenReady().then(async () => {
await startStaticServer();
}
// Start backend server
await startServer();
// Start backend server (unless using external server)
if (!skipEmbeddedServer) {
await startServer();
}
// Create window
createWindow();
@@ -909,10 +935,20 @@ ipcMain.handle('server:getUrl', async () => {
});
// Get API key for authentication
// Returns null in external server mode to trigger session-based auth
ipcMain.handle('auth:getApiKey', () => {
if (isExternalServerMode) {
return null;
}
return apiKey;
});
// Check if running in external server mode (Docker API)
// Used by renderer to determine auth flow
ipcMain.handle('auth:isExternalServerMode', () => {
return isExternalServerMode;
});
// Window management - update minimum width based on sidebar state
// Now uses a fixed small minimum since horizontal scrolling handles overflow
ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => {

View File

@@ -25,6 +25,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Get API key for authentication
getApiKey: (): Promise<string | null> => ipcRenderer.invoke('auth:getApiKey'),
// Check if running in external server mode (Docker API)
isExternalServerMode: (): Promise<boolean> => ipcRenderer.invoke('auth:isExternalServerMode'),
// Native dialogs - better UX than prompt()
openDirectory: (): Promise<Electron.OpenDialogReturnValue> =>
ipcRenderer.invoke('dialog:openDirectory'),

View File

@@ -18,6 +18,8 @@ import {
verifySession,
checkSandboxEnvironment,
getServerUrlSync,
checkExternalServerMode,
isExternalServerMode,
} from '@/lib/http-api-client';
import { Toaster } from 'sonner';
import { ThemeOption, themeOptions } from '@/config/theme-options';
@@ -192,13 +194,16 @@ function RootLayoutContent() {
// Initialize API key for Electron mode
await initApiKey();
// In Electron mode, we're always authenticated via header
if (isElectronMode()) {
// Check if running in external server mode (Docker API)
const externalMode = await checkExternalServerMode();
// In Electron mode (but NOT external server mode), we're always authenticated via header
if (isElectronMode() && !externalMode) {
useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true });
return;
}
// In web mode, verify the session cookie is still valid
// In web mode OR external server mode, verify the session cookie is still valid
// by making a request to an authenticated endpoint
const isValid = await verifySession();
@@ -239,17 +244,20 @@ function RootLayoutContent() {
};
}, []);
// Routing rules (web mode):
// Routing rules (web mode and external server mode):
// - If not authenticated: force /login (even /setup is protected)
// - If authenticated but setup incomplete: force /setup
useEffect(() => {
if (!setupHydrated) return;
// Check if we need session-based auth (web mode OR external server mode)
const needsSessionAuth = !isElectronMode() || isExternalServerMode() === true;
// Wait for auth check to complete before enforcing any redirects
if (!isElectronMode() && !authChecked) return;
if (needsSessionAuth && !authChecked) return;
// Unauthenticated -> force /login
if (!isElectronMode() && !isAuthenticated) {
if (needsSessionAuth && !isAuthenticated) {
if (location.pathname !== '/login') {
navigate({ to: '/login' });
}
@@ -355,8 +363,11 @@ function RootLayoutContent() {
);
}
// Wait for auth check before rendering protected routes (web mode only)
if (!isElectronMode() && !authChecked) {
// Check if we need session-based auth (web mode OR external server mode)
const needsSessionAuth = !isElectronMode() || isExternalServerMode() === true;
// Wait for auth check before rendering protected routes (web mode and external server mode)
if (needsSessionAuth && !authChecked) {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<LoadingState message="Loading..." />
@@ -364,9 +375,9 @@ function RootLayoutContent() {
);
}
// Redirect to login if not authenticated (web mode)
// Redirect to login if not authenticated (web mode and external server mode)
// Show loading state while navigation to login is in progress
if (!isElectronMode() && !isAuthenticated) {
if (needsSessionAuth && !isAuthenticated) {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<LoadingState message="Redirecting to login..." />