mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
Merge remote-tracking branch 'origin/v0.14.0rc' into feature/v0.14.0rc-1768981415660-tt2v
# Conflicts: # apps/ui/src/components/views/project-settings-view/config/navigation.ts # apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts
This commit is contained in:
@@ -1489,6 +1489,7 @@ export function BoardView() {
|
||||
branchSuggestions={branchSuggestions}
|
||||
branchCardCounts={branchCardCounts}
|
||||
currentBranch={currentWorktreeBranch || undefined}
|
||||
projectPath={currentProject?.path}
|
||||
/>
|
||||
|
||||
{/* Board Background Modal */}
|
||||
@@ -1538,6 +1539,7 @@ export function BoardView() {
|
||||
isMaximized={isMaximized}
|
||||
parentFeature={spawnParentFeature}
|
||||
allFeatures={hookFeatures}
|
||||
projectPath={currentProject?.path}
|
||||
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
||||
selectedNonMainWorktreeBranch={
|
||||
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
||||
@@ -1568,6 +1570,7 @@ export function BoardView() {
|
||||
currentBranch={currentWorktreeBranch || undefined}
|
||||
isMaximized={isMaximized}
|
||||
allFeatures={hookFeatures}
|
||||
projectPath={currentProject?.path}
|
||||
/>
|
||||
|
||||
{/* Agent Output Modal */}
|
||||
|
||||
@@ -3,9 +3,10 @@ import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Feature, useAppStore } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
|
||||
import { AlertCircle, Lock, Hand, Sparkles, SkipForward } from 'lucide-react';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { usePipelineConfig } from '@/hooks/queries/use-pipeline';
|
||||
|
||||
/** Uniform badge style for all card badges */
|
||||
const uniformBadgeClass =
|
||||
@@ -51,9 +52,13 @@ export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps)
|
||||
|
||||
interface PriorityBadgesProps {
|
||||
feature: Feature;
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
export const PriorityBadges = memo(function PriorityBadges({ feature }: PriorityBadgesProps) {
|
||||
export const PriorityBadges = memo(function PriorityBadges({
|
||||
feature,
|
||||
projectPath,
|
||||
}: PriorityBadgesProps) {
|
||||
const { enableDependencyBlocking, features } = useAppStore(
|
||||
useShallow((state) => ({
|
||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||
@@ -62,6 +67,9 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
|
||||
);
|
||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||
|
||||
// Fetch pipeline config to check if there are pipelines to exclude
|
||||
const { data: pipelineConfig } = usePipelineConfig(projectPath);
|
||||
|
||||
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
||||
const blockingDependencies = useMemo(() => {
|
||||
if (!enableDependencyBlocking || feature.status !== 'backlog') {
|
||||
@@ -108,7 +116,19 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
|
||||
const showManualVerification =
|
||||
feature.skipTests && !feature.error && feature.status === 'backlog';
|
||||
|
||||
const showBadges = feature.priority || showManualVerification || isBlocked || isJustFinished;
|
||||
// Check if feature has excluded pipeline steps
|
||||
const excludedStepCount = feature.excludedPipelineSteps?.length || 0;
|
||||
const totalPipelineSteps = pipelineConfig?.steps?.length || 0;
|
||||
const hasPipelineExclusions =
|
||||
excludedStepCount > 0 && totalPipelineSteps > 0 && feature.status === 'backlog';
|
||||
const allPipelinesExcluded = hasPipelineExclusions && excludedStepCount >= totalPipelineSteps;
|
||||
|
||||
const showBadges =
|
||||
feature.priority ||
|
||||
showManualVerification ||
|
||||
isBlocked ||
|
||||
isJustFinished ||
|
||||
hasPipelineExclusions;
|
||||
|
||||
if (!showBadges) {
|
||||
return null;
|
||||
@@ -227,6 +247,39 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Pipeline exclusion badge */}
|
||||
{hasPipelineExclusions && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
allPipelinesExcluded
|
||||
? 'bg-violet-500/20 border-violet-500/50 text-violet-500'
|
||||
: 'bg-violet-500/10 border-violet-500/30 text-violet-400'
|
||||
)}
|
||||
data-testid={`pipeline-exclusion-badge-${feature.id}`}
|
||||
>
|
||||
<SkipForward className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
|
||||
<p className="font-medium mb-1">
|
||||
{allPipelinesExcluded
|
||||
? 'All pipelines skipped'
|
||||
: `${excludedStepCount} of ${totalPipelineSteps} pipeline${totalPipelineSteps !== 1 ? 's' : ''} skipped`}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{allPipelinesExcluded
|
||||
? 'This feature will skip all custom pipeline steps'
|
||||
: 'Some custom pipeline steps will be skipped for this feature'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -236,7 +236,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
</div>
|
||||
|
||||
{/* Priority and Manual Verification badges */}
|
||||
<PriorityBadges feature={feature} />
|
||||
<PriorityBadges feature={feature} projectPath={currentProject?.path} />
|
||||
|
||||
{/* Card Header */}
|
||||
<CardHeaderSection
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
AncestorContextSection,
|
||||
EnhanceWithAI,
|
||||
EnhancementHistoryButton,
|
||||
PipelineExclusionControls,
|
||||
type BaseHistoryEntry,
|
||||
} from '../shared';
|
||||
import type { WorkMode } from '../shared';
|
||||
@@ -101,6 +102,7 @@ type FeatureData = {
|
||||
requirePlanApproval: boolean;
|
||||
dependencies?: string[];
|
||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||
excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature
|
||||
workMode: WorkMode;
|
||||
};
|
||||
|
||||
@@ -118,6 +120,10 @@ interface AddFeatureDialogProps {
|
||||
isMaximized: boolean;
|
||||
parentFeature?: Feature | null;
|
||||
allFeatures?: Feature[];
|
||||
/**
|
||||
* Path to the current project for loading pipeline config.
|
||||
*/
|
||||
projectPath?: string;
|
||||
/**
|
||||
* When a non-main worktree is selected in the board header, this will be set to that worktree's branch.
|
||||
* When set, the dialog will default to 'custom' work mode with this branch pre-filled.
|
||||
@@ -151,6 +157,7 @@ export function AddFeatureDialog({
|
||||
isMaximized,
|
||||
parentFeature = null,
|
||||
allFeatures = [],
|
||||
projectPath,
|
||||
selectedNonMainWorktreeBranch,
|
||||
forceCurrentBranchMode,
|
||||
}: AddFeatureDialogProps) {
|
||||
@@ -194,9 +201,20 @@ export function AddFeatureDialog({
|
||||
const [parentDependencies, setParentDependencies] = useState<string[]>([]);
|
||||
const [childDependencies, setChildDependencies] = useState<string[]>([]);
|
||||
|
||||
// Pipeline exclusion state
|
||||
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>([]);
|
||||
|
||||
// Get defaults from store
|
||||
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees, defaultFeatureModel } =
|
||||
useAppStore();
|
||||
const {
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
useWorktrees,
|
||||
defaultFeatureModel,
|
||||
currentProject,
|
||||
} = useAppStore();
|
||||
|
||||
// Use project-level default feature model if set, otherwise fall back to global
|
||||
const effectiveDefaultFeatureModel = currentProject?.defaultFeatureModel ?? defaultFeatureModel;
|
||||
|
||||
// Track previous open state to detect when dialog opens
|
||||
const wasOpenRef = useRef(false);
|
||||
@@ -216,7 +234,7 @@ export function AddFeatureDialog({
|
||||
);
|
||||
setPlanningMode(defaultPlanningMode);
|
||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||
setModelEntry(defaultFeatureModel);
|
||||
setModelEntry(effectiveDefaultFeatureModel);
|
||||
|
||||
// Initialize description history (empty for new feature)
|
||||
setDescriptionHistory([]);
|
||||
@@ -234,6 +252,9 @@ export function AddFeatureDialog({
|
||||
// Reset dependency selections
|
||||
setParentDependencies([]);
|
||||
setChildDependencies([]);
|
||||
|
||||
// Reset pipeline exclusions (all pipelines enabled by default)
|
||||
setExcludedPipelineSteps([]);
|
||||
}
|
||||
}, [
|
||||
open,
|
||||
@@ -241,7 +262,7 @@ export function AddFeatureDialog({
|
||||
defaultBranch,
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
defaultFeatureModel,
|
||||
effectiveDefaultFeatureModel,
|
||||
useWorktrees,
|
||||
selectedNonMainWorktreeBranch,
|
||||
forceCurrentBranchMode,
|
||||
@@ -328,6 +349,7 @@ export function AddFeatureDialog({
|
||||
requirePlanApproval,
|
||||
dependencies: finalDependencies,
|
||||
childDependencies: childDependencies.length > 0 ? childDependencies : undefined,
|
||||
excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined,
|
||||
workMode,
|
||||
};
|
||||
};
|
||||
@@ -343,7 +365,7 @@ export function AddFeatureDialog({
|
||||
// When a non-main worktree is selected, use its branch name for custom mode
|
||||
setBranchName(selectedNonMainWorktreeBranch || '');
|
||||
setPriority(2);
|
||||
setModelEntry(defaultFeatureModel);
|
||||
setModelEntry(effectiveDefaultFeatureModel);
|
||||
setWorkMode(
|
||||
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
|
||||
);
|
||||
@@ -354,6 +376,7 @@ export function AddFeatureDialog({
|
||||
setDescriptionHistory([]);
|
||||
setParentDependencies([]);
|
||||
setChildDependencies([]);
|
||||
setExcludedPipelineSteps([]);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -696,6 +719,16 @@ export function AddFeatureDialog({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pipeline Exclusion Controls */}
|
||||
<div className="pt-2">
|
||||
<PipelineExclusionControls
|
||||
projectPath={projectPath}
|
||||
excludedPipelineSteps={excludedPipelineSteps}
|
||||
onExcludedStepsChange={setExcludedPipelineSteps}
|
||||
testIdPrefix="add-feature-pipeline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
PlanningModeSelect,
|
||||
EnhanceWithAI,
|
||||
EnhancementHistoryButton,
|
||||
PipelineExclusionControls,
|
||||
type EnhancementMode,
|
||||
} from '../shared';
|
||||
import type { WorkMode } from '../shared';
|
||||
@@ -67,6 +68,7 @@ interface EditFeatureDialogProps {
|
||||
requirePlanApproval: boolean;
|
||||
dependencies?: string[];
|
||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||
excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature
|
||||
},
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: EnhancementMode,
|
||||
@@ -78,6 +80,7 @@ interface EditFeatureDialogProps {
|
||||
currentBranch?: string;
|
||||
isMaximized: boolean;
|
||||
allFeatures: Feature[];
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
export function EditFeatureDialog({
|
||||
@@ -90,6 +93,7 @@ export function EditFeatureDialog({
|
||||
currentBranch,
|
||||
isMaximized,
|
||||
allFeatures,
|
||||
projectPath,
|
||||
}: EditFeatureDialogProps) {
|
||||
const navigate = useNavigate();
|
||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
||||
@@ -146,6 +150,11 @@ export function EditFeatureDialog({
|
||||
return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id);
|
||||
});
|
||||
|
||||
// Pipeline exclusion state
|
||||
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>(
|
||||
feature?.excludedPipelineSteps ?? []
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEditingFeature(feature);
|
||||
if (feature) {
|
||||
@@ -171,6 +180,8 @@ export function EditFeatureDialog({
|
||||
.map((f) => f.id);
|
||||
setChildDependencies(childDeps);
|
||||
setOriginalChildDependencies(childDeps);
|
||||
// Reset pipeline exclusion state
|
||||
setExcludedPipelineSteps(feature.excludedPipelineSteps ?? []);
|
||||
} else {
|
||||
setEditFeaturePreviewMap(new Map());
|
||||
setDescriptionChangeSource(null);
|
||||
@@ -179,6 +190,7 @@ export function EditFeatureDialog({
|
||||
setParentDependencies([]);
|
||||
setChildDependencies([]);
|
||||
setOriginalChildDependencies([]);
|
||||
setExcludedPipelineSteps([]);
|
||||
}
|
||||
}, [feature, allFeatures]);
|
||||
|
||||
@@ -232,6 +244,7 @@ export function EditFeatureDialog({
|
||||
workMode,
|
||||
dependencies: parentDependencies,
|
||||
childDependencies: childDepsChanged ? childDependencies : undefined,
|
||||
excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined,
|
||||
};
|
||||
|
||||
// Determine if description changed and what source to use
|
||||
@@ -618,6 +631,16 @@ export function EditFeatureDialog({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pipeline Exclusion Controls */}
|
||||
<div className="pt-2">
|
||||
<PipelineExclusionControls
|
||||
projectPath={projectPath}
|
||||
excludedPipelineSteps={excludedPipelineSteps}
|
||||
onExcludedStepsChange={setExcludedPipelineSteps}
|
||||
testIdPrefix="edit-feature-pipeline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,13 @@ import { Label } from '@/components/ui/label';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { modelSupportsThinking } from '@/lib/utils';
|
||||
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
|
||||
import { TestingTabContent, PrioritySelect, PlanningModeSelect, WorkModeSelector } from '../shared';
|
||||
import {
|
||||
TestingTabContent,
|
||||
PrioritySelect,
|
||||
PlanningModeSelect,
|
||||
WorkModeSelector,
|
||||
PipelineExclusionControls,
|
||||
} from '../shared';
|
||||
import type { WorkMode } from '../shared';
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
|
||||
@@ -28,6 +34,7 @@ interface MassEditDialogProps {
|
||||
branchSuggestions: string[];
|
||||
branchCardCounts?: Record<string, number>;
|
||||
currentBranch?: string;
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
interface ApplyState {
|
||||
@@ -38,11 +45,13 @@ interface ApplyState {
|
||||
priority: boolean;
|
||||
skipTests: boolean;
|
||||
branchName: boolean;
|
||||
excludedPipelineSteps: boolean;
|
||||
}
|
||||
|
||||
function getMixedValues(features: Feature[]): Record<string, boolean> {
|
||||
if (features.length === 0) return {};
|
||||
const first = features[0];
|
||||
const firstExcludedSteps = JSON.stringify(first.excludedPipelineSteps || []);
|
||||
return {
|
||||
model: !features.every((f) => f.model === first.model),
|
||||
thinkingLevel: !features.every((f) => f.thinkingLevel === first.thinkingLevel),
|
||||
@@ -53,6 +62,9 @@ function getMixedValues(features: Feature[]): Record<string, boolean> {
|
||||
priority: !features.every((f) => f.priority === first.priority),
|
||||
skipTests: !features.every((f) => f.skipTests === first.skipTests),
|
||||
branchName: !features.every((f) => f.branchName === first.branchName),
|
||||
excludedPipelineSteps: !features.every(
|
||||
(f) => JSON.stringify(f.excludedPipelineSteps || []) === firstExcludedSteps
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,6 +123,7 @@ export function MassEditDialog({
|
||||
branchSuggestions,
|
||||
branchCardCounts,
|
||||
currentBranch,
|
||||
projectPath,
|
||||
}: MassEditDialogProps) {
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
|
||||
@@ -123,6 +136,7 @@ export function MassEditDialog({
|
||||
priority: false,
|
||||
skipTests: false,
|
||||
branchName: false,
|
||||
excludedPipelineSteps: false,
|
||||
});
|
||||
|
||||
// Field values
|
||||
@@ -146,6 +160,11 @@ export function MassEditDialog({
|
||||
return getInitialValue(selectedFeatures, 'branchName', '') as string;
|
||||
});
|
||||
|
||||
// Pipeline exclusion state
|
||||
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>(() => {
|
||||
return getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[];
|
||||
});
|
||||
|
||||
// Calculate mixed values
|
||||
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);
|
||||
|
||||
@@ -160,6 +179,7 @@ export function MassEditDialog({
|
||||
priority: false,
|
||||
skipTests: false,
|
||||
branchName: false,
|
||||
excludedPipelineSteps: false,
|
||||
});
|
||||
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
||||
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
||||
@@ -172,6 +192,10 @@ export function MassEditDialog({
|
||||
const initialBranchName = getInitialValue(selectedFeatures, 'branchName', '') as string;
|
||||
setBranchName(initialBranchName);
|
||||
setWorkMode(initialBranchName ? 'custom' : 'current');
|
||||
// Reset pipeline exclusions
|
||||
setExcludedPipelineSteps(
|
||||
getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[]
|
||||
);
|
||||
}
|
||||
}, [open, selectedFeatures]);
|
||||
|
||||
@@ -190,6 +214,10 @@ export function MassEditDialog({
|
||||
// For 'custom' mode, use the specified branch name
|
||||
updates.branchName = workMode === 'custom' ? branchName : '';
|
||||
}
|
||||
if (applyState.excludedPipelineSteps) {
|
||||
updates.excludedPipelineSteps =
|
||||
excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
onClose();
|
||||
@@ -353,6 +381,23 @@ export function MassEditDialog({
|
||||
testIdPrefix="mass-edit-work-mode"
|
||||
/>
|
||||
</FieldWrapper>
|
||||
|
||||
{/* Pipeline Exclusion */}
|
||||
<FieldWrapper
|
||||
label="Pipeline Steps"
|
||||
isMixed={mixedValues.excludedPipelineSteps}
|
||||
willApply={applyState.excludedPipelineSteps}
|
||||
onApplyChange={(apply) =>
|
||||
setApplyState((prev) => ({ ...prev, excludedPipelineSteps: apply }))
|
||||
}
|
||||
>
|
||||
<PipelineExclusionControls
|
||||
projectPath={projectPath}
|
||||
excludedPipelineSteps={excludedPipelineSteps}
|
||||
onExcludedStepsChange={setExcludedPipelineSteps}
|
||||
testIdPrefix="mass-edit-pipeline"
|
||||
/>
|
||||
</FieldWrapper>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -11,3 +11,4 @@ export * from './planning-mode-select';
|
||||
export * from './ancestor-context-section';
|
||||
export * from './work-mode-selector';
|
||||
export * from './enhancement';
|
||||
export * from './pipeline-exclusion-controls';
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { GitBranch, Workflow } from 'lucide-react';
|
||||
import { usePipelineConfig } from '@/hooks/queries/use-pipeline';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PipelineExclusionControlsProps {
|
||||
projectPath: string | undefined;
|
||||
excludedPipelineSteps: string[];
|
||||
onExcludedStepsChange: (excludedSteps: string[]) => void;
|
||||
testIdPrefix?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for selecting which custom pipeline steps should be excluded for a feature.
|
||||
* Each pipeline step is shown as a toggleable switch, defaulting to enabled (included).
|
||||
* Disabling a step adds it to the exclusion list.
|
||||
*/
|
||||
export function PipelineExclusionControls({
|
||||
projectPath,
|
||||
excludedPipelineSteps,
|
||||
onExcludedStepsChange,
|
||||
testIdPrefix = 'pipeline-exclusion',
|
||||
disabled = false,
|
||||
}: PipelineExclusionControlsProps) {
|
||||
const { data: pipelineConfig, isLoading } = usePipelineConfig(projectPath);
|
||||
|
||||
// Sort steps by order
|
||||
const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order);
|
||||
|
||||
// If no pipeline steps exist or loading, don't render anything
|
||||
if (isLoading || sortedSteps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toggleStep = (stepId: string) => {
|
||||
const isCurrentlyExcluded = excludedPipelineSteps.includes(stepId);
|
||||
if (isCurrentlyExcluded) {
|
||||
// Remove from exclusions (enable the step)
|
||||
onExcludedStepsChange(excludedPipelineSteps.filter((id) => id !== stepId));
|
||||
} else {
|
||||
// Add to exclusions (disable the step)
|
||||
onExcludedStepsChange([...excludedPipelineSteps, stepId]);
|
||||
}
|
||||
};
|
||||
|
||||
const allExcluded = sortedSteps.every((step) => excludedPipelineSteps.includes(step.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Workflow className="w-4 h-4 text-muted-foreground" />
|
||||
<Label className="text-sm font-medium">Custom Pipeline Steps</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{sortedSteps.map((step) => {
|
||||
const isIncluded = !excludedPipelineSteps.includes(step.id);
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-3 px-3 py-2 rounded-md border',
|
||||
isIncluded
|
||||
? 'border-border/50 bg-muted/30'
|
||||
: 'border-border/30 bg-muted/10 opacity-60'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full flex-shrink-0',
|
||||
step.colorClass || 'bg-gray-400'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: step.colorClass?.startsWith('#') ? step.colorClass : undefined,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm truncate',
|
||||
isIncluded ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{step.name}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isIncluded}
|
||||
onCheckedChange={() => toggleStep(step.id)}
|
||||
disabled={disabled}
|
||||
data-testid={`${testIdPrefix}-step-${step.id}`}
|
||||
aria-label={`${isIncluded ? 'Disable' : 'Enable'} ${step.name} pipeline step`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{allExcluded && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<GitBranch className="w-3.5 h-3.5" />
|
||||
All pipeline steps disabled. Feature will skip directly to verification.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enabled steps will run after implementation. Disable steps to skip them for this feature.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,10 +33,11 @@ import {
|
||||
SplitSquareHorizontal,
|
||||
Undo2,
|
||||
Zap,
|
||||
FlaskConical,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus, TestSessionInfo } from '../types';
|
||||
import { TooltipWrapper } from './tooltip-wrapper';
|
||||
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
|
||||
import {
|
||||
@@ -63,6 +64,14 @@ interface WorktreeActionsDropdownProps {
|
||||
standalone?: boolean;
|
||||
/** Whether auto mode is running for this worktree */
|
||||
isAutoModeRunning?: boolean;
|
||||
/** Whether a test command is configured in project settings */
|
||||
hasTestCommand?: boolean;
|
||||
/** Whether tests are being started for this worktree */
|
||||
isStartingTests?: boolean;
|
||||
/** Whether tests are currently running for this worktree */
|
||||
isTestRunning?: boolean;
|
||||
/** Active test session info for this worktree */
|
||||
testSessionInfo?: TestSessionInfo;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
@@ -84,6 +93,12 @@ interface WorktreeActionsDropdownProps {
|
||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||
onMerge: (worktree: WorktreeInfo) => void;
|
||||
/** Start running tests for this worktree */
|
||||
onStartTests?: (worktree: WorktreeInfo) => void;
|
||||
/** Stop running tests for this worktree */
|
||||
onStopTests?: (worktree: WorktreeInfo) => void;
|
||||
/** View test logs for this worktree */
|
||||
onViewTestLogs?: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
}
|
||||
|
||||
@@ -101,6 +116,10 @@ export function WorktreeActionsDropdown({
|
||||
gitRepoStatus,
|
||||
standalone = false,
|
||||
isAutoModeRunning = false,
|
||||
hasTestCommand = false,
|
||||
isStartingTests = false,
|
||||
isTestRunning = false,
|
||||
testSessionInfo,
|
||||
onOpenChange,
|
||||
onPull,
|
||||
onPush,
|
||||
@@ -122,6 +141,9 @@ export function WorktreeActionsDropdown({
|
||||
onRunInitScript,
|
||||
onToggleAutoMode,
|
||||
onMerge,
|
||||
onStartTests,
|
||||
onStopTests,
|
||||
onViewTestLogs,
|
||||
hasInitScript,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
// Get available editors for the "Open In" submenu
|
||||
@@ -231,6 +253,65 @@ export function WorktreeActionsDropdown({
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{/* Test Runner section - only show when test command is configured */}
|
||||
{hasTestCommand && onStartTests && (
|
||||
<>
|
||||
{isTestRunning ? (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
|
||||
Tests Running
|
||||
</DropdownMenuLabel>
|
||||
{onViewTestLogs && (
|
||||
<DropdownMenuItem onClick={() => onViewTestLogs(worktree)} className="text-xs">
|
||||
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||
View Test Logs
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onStopTests && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onStopTests(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5 mr-2" />
|
||||
Stop Tests
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onStartTests(worktree)}
|
||||
disabled={isStartingTests}
|
||||
className="text-xs"
|
||||
>
|
||||
<FlaskConical
|
||||
className={cn('w-3.5 h-3.5 mr-2', isStartingTests && 'animate-pulse')}
|
||||
/>
|
||||
{isStartingTests ? 'Starting Tests...' : 'Run Tests'}
|
||||
</DropdownMenuItem>
|
||||
{onViewTestLogs && testSessionInfo && (
|
||||
<DropdownMenuItem onClick={() => onViewTestLogs(worktree)} className="text-xs">
|
||||
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||
View Last Test Results
|
||||
{testSessionInfo.status === 'passed' && (
|
||||
<span className="ml-auto text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded">
|
||||
passed
|
||||
</span>
|
||||
)}
|
||||
{testSessionInfo.status === 'failed' && (
|
||||
<span className="ml-auto text-[10px] bg-red-500/20 text-red-600 px-1.5 py-0.5 rounded">
|
||||
failed
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Auto Mode toggle */}
|
||||
{onToggleAutoMode && (
|
||||
<>
|
||||
|
||||
@@ -5,7 +5,14 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
import type {
|
||||
WorktreeInfo,
|
||||
BranchInfo,
|
||||
DevServerInfo,
|
||||
PRInfo,
|
||||
GitRepoStatus,
|
||||
TestSessionInfo,
|
||||
} from '../types';
|
||||
import { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||
|
||||
@@ -33,6 +40,12 @@ interface WorktreeTabProps {
|
||||
gitRepoStatus: GitRepoStatus;
|
||||
/** Whether auto mode is running for this worktree */
|
||||
isAutoModeRunning?: boolean;
|
||||
/** Whether tests are being started for this worktree */
|
||||
isStartingTests?: boolean;
|
||||
/** Whether tests are currently running for this worktree */
|
||||
isTestRunning?: boolean;
|
||||
/** Active test session info for this worktree */
|
||||
testSessionInfo?: TestSessionInfo;
|
||||
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||
onBranchDropdownOpenChange: (open: boolean) => void;
|
||||
onActionsDropdownOpenChange: (open: boolean) => void;
|
||||
@@ -59,7 +72,15 @@ interface WorktreeTabProps {
|
||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||
/** Start running tests for this worktree */
|
||||
onStartTests?: (worktree: WorktreeInfo) => void;
|
||||
/** Stop running tests for this worktree */
|
||||
onStopTests?: (worktree: WorktreeInfo) => void;
|
||||
/** View test logs for this worktree */
|
||||
onViewTestLogs?: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
/** Whether a test command is configured in project settings */
|
||||
hasTestCommand?: boolean;
|
||||
}
|
||||
|
||||
export function WorktreeTab({
|
||||
@@ -85,6 +106,9 @@ export function WorktreeTab({
|
||||
hasRemoteBranch,
|
||||
gitRepoStatus,
|
||||
isAutoModeRunning = false,
|
||||
isStartingTests = false,
|
||||
isTestRunning = false,
|
||||
testSessionInfo,
|
||||
onSelectWorktree,
|
||||
onBranchDropdownOpenChange,
|
||||
onActionsDropdownOpenChange,
|
||||
@@ -111,7 +135,11 @@ export function WorktreeTab({
|
||||
onViewDevServerLogs,
|
||||
onRunInitScript,
|
||||
onToggleAutoMode,
|
||||
onStartTests,
|
||||
onStopTests,
|
||||
onViewTestLogs,
|
||||
hasInitScript,
|
||||
hasTestCommand = false,
|
||||
}: WorktreeTabProps) {
|
||||
// Make the worktree tab a drop target for feature cards
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
@@ -395,6 +423,10 @@ export function WorktreeTab({
|
||||
devServerInfo={devServerInfo}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunning}
|
||||
hasTestCommand={hasTestCommand}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunning}
|
||||
testSessionInfo={testSessionInfo}
|
||||
onOpenChange={onActionsDropdownOpenChange}
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
@@ -416,6 +448,9 @@ export function WorktreeTab({
|
||||
onViewDevServerLogs={onViewDevServerLogs}
|
||||
onRunInitScript={onRunInitScript}
|
||||
onToggleAutoMode={onToggleAutoMode}
|
||||
onStartTests={onStartTests}
|
||||
onStopTests={onStopTests}
|
||||
onViewTestLogs={onViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,19 @@ export interface DevServerInfo {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface TestSessionInfo {
|
||||
sessionId: string;
|
||||
worktreePath: string;
|
||||
/** The test command being run (from project settings) */
|
||||
command: string;
|
||||
status: 'pending' | 'running' | 'passed' | 'failed' | 'cancelled';
|
||||
testFile?: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
exitCode?: number | null;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface FeatureInfo {
|
||||
id: string;
|
||||
branchName?: string;
|
||||
|
||||
@@ -6,8 +6,15 @@ import { pathsEqual } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { useWorktreeInitScript } from '@/hooks/queries';
|
||||
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
||||
import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries';
|
||||
import { useTestRunnerEvents } from '@/hooks/use-test-runners';
|
||||
import { useTestRunnersStore } from '@/store/test-runners-store';
|
||||
import type {
|
||||
TestRunnerStartedEvent,
|
||||
TestRunnerOutputEvent,
|
||||
TestRunnerCompletedEvent,
|
||||
} from '@/types/electron';
|
||||
import type { WorktreePanelProps, WorktreeInfo, TestSessionInfo } from './types';
|
||||
import {
|
||||
useWorktrees,
|
||||
useDevServers,
|
||||
@@ -25,6 +32,7 @@ import {
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
||||
import { Undo2 } from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
@@ -161,6 +169,194 @@ export function WorktreePanel({
|
||||
const { data: initScriptData } = useWorktreeInitScript(projectPath);
|
||||
const hasInitScript = initScriptData?.exists ?? false;
|
||||
|
||||
// Check if test command is configured in project settings
|
||||
const { data: projectSettings } = useProjectSettings(projectPath);
|
||||
const hasTestCommand = !!projectSettings?.testCommand;
|
||||
|
||||
// Test runner state management
|
||||
// Use the test runners store to get global state for all worktrees
|
||||
const testRunnersStore = useTestRunnersStore();
|
||||
const [isStartingTests, setIsStartingTests] = useState(false);
|
||||
|
||||
// Subscribe to test runner events to update store state in real-time
|
||||
// This ensures the UI updates when tests start, output is received, or tests complete
|
||||
useTestRunnerEvents(
|
||||
// onStarted - a new test run has begun
|
||||
useCallback(
|
||||
(event: TestRunnerStartedEvent) => {
|
||||
testRunnersStore.startSession({
|
||||
sessionId: event.sessionId,
|
||||
worktreePath: event.worktreePath,
|
||||
command: event.command,
|
||||
status: 'running',
|
||||
testFile: event.testFile,
|
||||
startedAt: event.timestamp,
|
||||
});
|
||||
},
|
||||
[testRunnersStore]
|
||||
),
|
||||
// onOutput - test output received
|
||||
useCallback(
|
||||
(event: TestRunnerOutputEvent) => {
|
||||
testRunnersStore.appendOutput(event.sessionId, event.content);
|
||||
},
|
||||
[testRunnersStore]
|
||||
),
|
||||
// onCompleted - test run finished
|
||||
useCallback(
|
||||
(event: TestRunnerCompletedEvent) => {
|
||||
testRunnersStore.completeSession(
|
||||
event.sessionId,
|
||||
event.status,
|
||||
event.exitCode,
|
||||
event.duration
|
||||
);
|
||||
// Show toast notification for test completion
|
||||
const statusEmoji =
|
||||
event.status === 'passed' ? '✅' : event.status === 'failed' ? '❌' : '⏹️';
|
||||
const statusText =
|
||||
event.status === 'passed' ? 'passed' : event.status === 'failed' ? 'failed' : 'stopped';
|
||||
toast(`${statusEmoji} Tests ${statusText}`, {
|
||||
description: `Exit code: ${event.exitCode ?? 'N/A'}`,
|
||||
duration: 4000,
|
||||
});
|
||||
},
|
||||
[testRunnersStore]
|
||||
)
|
||||
);
|
||||
|
||||
// Test logs panel state
|
||||
const [testLogsPanelOpen, setTestLogsPanelOpen] = useState(false);
|
||||
const [testLogsPanelWorktree, setTestLogsPanelWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
// Helper to check if tests are running for a specific worktree
|
||||
const isTestRunningForWorktree = useCallback(
|
||||
(worktree: WorktreeInfo): boolean => {
|
||||
return testRunnersStore.isWorktreeRunning(worktree.path);
|
||||
},
|
||||
[testRunnersStore]
|
||||
);
|
||||
|
||||
// Helper to get test session info for a specific worktree
|
||||
const getTestSessionInfo = useCallback(
|
||||
(worktree: WorktreeInfo): TestSessionInfo | undefined => {
|
||||
const session = testRunnersStore.getActiveSession(worktree.path);
|
||||
if (!session) {
|
||||
// Check for completed sessions to show last result
|
||||
const allSessions = Object.values(testRunnersStore.sessions).filter(
|
||||
(s) => s.worktreePath === worktree.path
|
||||
);
|
||||
const lastSession = allSessions.sort(
|
||||
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
||||
)[0];
|
||||
if (lastSession) {
|
||||
return {
|
||||
sessionId: lastSession.sessionId,
|
||||
worktreePath: lastSession.worktreePath,
|
||||
command: lastSession.command,
|
||||
status: lastSession.status as TestSessionInfo['status'],
|
||||
testFile: lastSession.testFile,
|
||||
startedAt: lastSession.startedAt,
|
||||
finishedAt: lastSession.finishedAt,
|
||||
exitCode: lastSession.exitCode,
|
||||
duration: lastSession.duration,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
worktreePath: session.worktreePath,
|
||||
command: session.command,
|
||||
status: session.status as TestSessionInfo['status'],
|
||||
testFile: session.testFile,
|
||||
startedAt: session.startedAt,
|
||||
finishedAt: session.finishedAt,
|
||||
exitCode: session.exitCode,
|
||||
duration: session.duration,
|
||||
};
|
||||
},
|
||||
[testRunnersStore]
|
||||
);
|
||||
|
||||
// Handler to start tests for a worktree
|
||||
const handleStartTests = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
setIsStartingTests(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.startTests) {
|
||||
toast.error('Test runner API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.startTests(worktree.path, { projectPath });
|
||||
if (result.success) {
|
||||
toast.success('Tests started', {
|
||||
description: `Running tests in ${worktree.branch}`,
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to start tests', {
|
||||
description: result.error || 'Unknown error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to start tests', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsStartingTests(false);
|
||||
}
|
||||
},
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
// Handler to stop tests for a worktree
|
||||
const handleStopTests = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const session = testRunnersStore.getActiveSession(worktree.path);
|
||||
if (!session) {
|
||||
toast.error('No active test session to stop');
|
||||
return;
|
||||
}
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.stopTests) {
|
||||
toast.error('Test runner API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.stopTests(session.sessionId);
|
||||
if (result.success) {
|
||||
toast.success('Tests stopped', {
|
||||
description: `Stopped tests in ${worktree.branch}`,
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to stop tests', {
|
||||
description: result.error || 'Unknown error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to stop tests', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[testRunnersStore]
|
||||
);
|
||||
|
||||
// Handler to view test logs for a worktree
|
||||
const handleViewTestLogs = useCallback((worktree: WorktreeInfo) => {
|
||||
setTestLogsPanelWorktree(worktree);
|
||||
setTestLogsPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handler to close test logs panel
|
||||
const handleCloseTestLogsPanel = useCallback(() => {
|
||||
setTestLogsPanelOpen(false);
|
||||
}, []);
|
||||
|
||||
// View changes dialog state
|
||||
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
|
||||
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
|
||||
@@ -392,6 +588,10 @@ export function WorktreePanel({
|
||||
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
|
||||
hasTestCommand={hasTestCommand}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(selectedWorktree)}
|
||||
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
@@ -413,6 +613,9 @@ export function WorktreePanel({
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
)}
|
||||
@@ -494,6 +697,17 @@ export function WorktreePanel({
|
||||
onMerged={handleMerged}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
{/* Test Logs Panel */}
|
||||
<TestLogsPanel
|
||||
open={testLogsPanelOpen}
|
||||
onClose={handleCloseTestLogsPanel}
|
||||
worktreePath={testLogsPanelWorktree?.path ?? null}
|
||||
branch={testLogsPanelWorktree?.branch}
|
||||
onStopTests={
|
||||
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -530,6 +744,9 @@ export function WorktreePanel({
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(mainWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(mainWorktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||
@@ -556,7 +773,11 @@ export function WorktreePanel({
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -596,6 +817,9 @@ export function WorktreePanel({
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(worktree)}
|
||||
testSessionInfo={getTestSessionInfo(worktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
@@ -622,7 +846,11 @@ export function WorktreePanel({
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -703,6 +931,17 @@ export function WorktreePanel({
|
||||
onMerged={handleMerged}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
{/* Test Logs Panel */}
|
||||
<TestLogsPanel
|
||||
open={testLogsPanelOpen}
|
||||
onClose={handleCloseTestLogsPanel}
|
||||
worktreePath={testLogsPanelWorktree?.path ?? null}
|
||||
branch={testLogsPanelWorktree?.branch}
|
||||
onStopTests={
|
||||
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -392,6 +392,7 @@ export function GraphViewPage() {
|
||||
currentBranch={currentWorktreeBranch || undefined}
|
||||
isMaximized={false}
|
||||
allFeatures={hookFeatures}
|
||||
projectPath={currentProject?.path}
|
||||
/>
|
||||
|
||||
{/* Add Feature Dialog (for spawning) */}
|
||||
@@ -414,6 +415,7 @@ export function GraphViewPage() {
|
||||
isMaximized={false}
|
||||
parentFeature={spawnParentFeature}
|
||||
allFeatures={hookFeatures}
|
||||
projectPath={currentProject?.path}
|
||||
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
||||
selectedNonMainWorktreeBranch={
|
||||
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { User, GitBranch, Palette, AlertTriangle, Workflow, Database } from 'lucide-react';
|
||||
import {
|
||||
User,
|
||||
GitBranch,
|
||||
Palette,
|
||||
AlertTriangle,
|
||||
Workflow,
|
||||
Database,
|
||||
FlaskConical,
|
||||
} from 'lucide-react';
|
||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||
|
||||
export interface ProjectNavigationItem {
|
||||
@@ -11,6 +19,7 @@ export interface ProjectNavigationItem {
|
||||
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
||||
{ id: 'identity', label: 'Identity', icon: User },
|
||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||
{ id: 'testing', label: 'Testing', icon: FlaskConical },
|
||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||
{ id: 'claude', label: 'Models', icon: Workflow },
|
||||
{ id: 'data', label: 'Data', icon: Database },
|
||||
|
||||
@@ -4,6 +4,7 @@ export type ProjectSettingsViewId =
|
||||
| 'identity'
|
||||
| 'theme'
|
||||
| 'worktrees'
|
||||
| 'testing'
|
||||
| 'claude'
|
||||
| 'data'
|
||||
| 'danger';
|
||||
|
||||
@@ -2,5 +2,6 @@ export { ProjectSettingsView } from './project-settings-view';
|
||||
export { ProjectIdentitySection } from './project-identity-section';
|
||||
export { ProjectThemeSection } from './project-theme-section';
|
||||
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
export { TestingSection } from './testing-section';
|
||||
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
||||
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
ClaudeCompatibleProvider,
|
||||
ClaudeModelAlias,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types';
|
||||
|
||||
interface ProjectBulkReplaceDialogProps {
|
||||
open: boolean;
|
||||
@@ -50,6 +50,10 @@ const PHASE_LABELS: Record<PhaseModelKey, string> = {
|
||||
|
||||
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
||||
|
||||
// Special key for default feature model (not a phase but included in bulk replace)
|
||||
const DEFAULT_FEATURE_MODEL_KEY = '__defaultFeatureModel__' as const;
|
||||
type ExtendedPhaseKey = PhaseModelKey | typeof DEFAULT_FEATURE_MODEL_KEY;
|
||||
|
||||
// Claude model display names
|
||||
const CLAUDE_MODEL_DISPLAY: Record<ClaudeModelAlias, string> = {
|
||||
haiku: 'Claude Haiku',
|
||||
@@ -62,11 +66,18 @@ export function ProjectBulkReplaceDialog({
|
||||
onOpenChange,
|
||||
project,
|
||||
}: ProjectBulkReplaceDialogProps) {
|
||||
const { phaseModels, setProjectPhaseModelOverride, claudeCompatibleProviders } = useAppStore();
|
||||
const {
|
||||
phaseModels,
|
||||
setProjectPhaseModelOverride,
|
||||
claudeCompatibleProviders,
|
||||
defaultFeatureModel,
|
||||
setProjectDefaultFeatureModel,
|
||||
} = useAppStore();
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>('anthropic');
|
||||
|
||||
// Get project-level overrides
|
||||
const projectOverrides = project.phaseModelOverrides || {};
|
||||
const projectDefaultFeatureModel = project.defaultFeatureModel;
|
||||
|
||||
// Get enabled providers
|
||||
const enabledProviders = useMemo(() => {
|
||||
@@ -122,11 +133,15 @@ export function ProjectBulkReplaceDialog({
|
||||
const findModelForClaudeAlias = (
|
||||
provider: ClaudeCompatibleProvider | null,
|
||||
claudeAlias: ClaudeModelAlias,
|
||||
phase: PhaseModelKey
|
||||
key: ExtendedPhaseKey
|
||||
): PhaseModelEntry => {
|
||||
if (!provider) {
|
||||
// Anthropic Direct - reset to default phase model (includes correct thinking levels)
|
||||
return DEFAULT_PHASE_MODELS[phase];
|
||||
// For default feature model, use the default from global settings
|
||||
if (key === DEFAULT_FEATURE_MODEL_KEY) {
|
||||
return DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
|
||||
}
|
||||
return DEFAULT_PHASE_MODELS[key];
|
||||
}
|
||||
|
||||
// Find model that maps to this Claude alias
|
||||
@@ -146,60 +161,91 @@ export function ProjectBulkReplaceDialog({
|
||||
return { model: claudeAlias };
|
||||
};
|
||||
|
||||
// Helper to generate preview item for any entry
|
||||
const generatePreviewItem = (
|
||||
key: ExtendedPhaseKey,
|
||||
label: string,
|
||||
currentEntry: PhaseModelEntry
|
||||
) => {
|
||||
const claudeAlias = getClaudeModelAlias(currentEntry);
|
||||
const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, key);
|
||||
|
||||
// Get display names
|
||||
const getCurrentDisplay = (): string => {
|
||||
if (currentEntry.providerId) {
|
||||
const provider = enabledProviders.find((p) => p.id === currentEntry.providerId);
|
||||
if (provider) {
|
||||
const model = provider.models?.find((m) => m.id === currentEntry.model);
|
||||
return model?.displayName || currentEntry.model;
|
||||
}
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model;
|
||||
};
|
||||
|
||||
const getNewDisplay = (): string => {
|
||||
if (newEntry.providerId && selectedProviderConfig) {
|
||||
const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model);
|
||||
return model?.displayName || newEntry.model;
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model;
|
||||
};
|
||||
|
||||
const isChanged =
|
||||
currentEntry.model !== newEntry.model ||
|
||||
currentEntry.providerId !== newEntry.providerId ||
|
||||
currentEntry.thinkingLevel !== newEntry.thinkingLevel;
|
||||
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
claudeAlias,
|
||||
currentDisplay: getCurrentDisplay(),
|
||||
newDisplay: getNewDisplay(),
|
||||
newEntry,
|
||||
isChanged,
|
||||
};
|
||||
};
|
||||
|
||||
// Generate preview of changes
|
||||
const preview = useMemo(() => {
|
||||
return ALL_PHASES.map((phase) => {
|
||||
// Current effective value (project override or global)
|
||||
// Default feature model entry (first in the list)
|
||||
const globalDefaultFeature = defaultFeatureModel ?? DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
|
||||
const currentDefaultFeature = projectDefaultFeatureModel || globalDefaultFeature;
|
||||
const defaultFeaturePreview = generatePreviewItem(
|
||||
DEFAULT_FEATURE_MODEL_KEY,
|
||||
'Default Feature Model',
|
||||
currentDefaultFeature
|
||||
);
|
||||
|
||||
// Phase model entries
|
||||
const phasePreview = ALL_PHASES.map((phase) => {
|
||||
const globalEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase];
|
||||
const currentEntry = projectOverrides[phase] || globalEntry;
|
||||
const claudeAlias = getClaudeModelAlias(currentEntry);
|
||||
const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, phase);
|
||||
|
||||
// Get display names
|
||||
const getCurrentDisplay = (): string => {
|
||||
if (currentEntry.providerId) {
|
||||
const provider = enabledProviders.find((p) => p.id === currentEntry.providerId);
|
||||
if (provider) {
|
||||
const model = provider.models?.find((m) => m.id === currentEntry.model);
|
||||
return model?.displayName || currentEntry.model;
|
||||
}
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model;
|
||||
};
|
||||
|
||||
const getNewDisplay = (): string => {
|
||||
if (newEntry.providerId && selectedProviderConfig) {
|
||||
const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model);
|
||||
return model?.displayName || newEntry.model;
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model;
|
||||
};
|
||||
|
||||
const isChanged =
|
||||
currentEntry.model !== newEntry.model ||
|
||||
currentEntry.providerId !== newEntry.providerId ||
|
||||
currentEntry.thinkingLevel !== newEntry.thinkingLevel;
|
||||
|
||||
return {
|
||||
phase,
|
||||
label: PHASE_LABELS[phase],
|
||||
claudeAlias,
|
||||
currentDisplay: getCurrentDisplay(),
|
||||
newDisplay: getNewDisplay(),
|
||||
newEntry,
|
||||
isChanged,
|
||||
};
|
||||
return generatePreviewItem(phase, PHASE_LABELS[phase], currentEntry);
|
||||
});
|
||||
}, [phaseModels, projectOverrides, selectedProviderConfig, enabledProviders]);
|
||||
|
||||
return [defaultFeaturePreview, ...phasePreview];
|
||||
}, [
|
||||
phaseModels,
|
||||
projectOverrides,
|
||||
selectedProviderConfig,
|
||||
enabledProviders,
|
||||
defaultFeatureModel,
|
||||
projectDefaultFeatureModel,
|
||||
]);
|
||||
|
||||
// Count how many will change
|
||||
const changeCount = preview.filter((p) => p.isChanged).length;
|
||||
|
||||
// Apply the bulk replace as project overrides
|
||||
const handleApply = () => {
|
||||
preview.forEach(({ phase, newEntry, isChanged }) => {
|
||||
preview.forEach(({ key, newEntry, isChanged }) => {
|
||||
if (isChanged) {
|
||||
setProjectPhaseModelOverride(project.id, phase, newEntry);
|
||||
if (key === DEFAULT_FEATURE_MODEL_KEY) {
|
||||
setProjectDefaultFeatureModel(project.id, newEntry);
|
||||
} else {
|
||||
setProjectPhaseModelOverride(project.id, key as PhaseModelKey, newEntry);
|
||||
}
|
||||
}
|
||||
});
|
||||
onOpenChange(false);
|
||||
@@ -295,7 +341,7 @@ export function ProjectBulkReplaceDialog({
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Preview Changes</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{changeCount} of {ALL_PHASES.length} will be overridden
|
||||
{changeCount} of {preview.length} will be overridden
|
||||
</span>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
|
||||
@@ -311,15 +357,23 @@ export function ProjectBulkReplaceDialog({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => (
|
||||
{preview.map(({ key, label, currentDisplay, newDisplay, isChanged }) => (
|
||||
<tr
|
||||
key={phase}
|
||||
key={key}
|
||||
className={cn(
|
||||
'border-t border-border/50',
|
||||
isChanged ? 'bg-brand-500/5' : 'opacity-50'
|
||||
isChanged ? 'bg-brand-500/5' : 'opacity-50',
|
||||
key === DEFAULT_FEATURE_MODEL_KEY && 'bg-accent/30'
|
||||
)}
|
||||
>
|
||||
<td className="p-2 font-medium">{label}</td>
|
||||
<td className="p-2 font-medium">
|
||||
{label}
|
||||
{key === DEFAULT_FEATURE_MODEL_KEY && (
|
||||
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-brand-500/20 text-brand-500">
|
||||
Feature Default
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 text-muted-foreground">{currentDisplay}</td>
|
||||
<td className="p-2 text-center">
|
||||
{isChanged ? (
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Workflow, RotateCcw, Globe, Check, Replace } from 'lucide-react';
|
||||
import { Workflow, RotateCcw, Globe, Check, Replace, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||
import { ProjectBulkReplaceDialog } from './project-bulk-replace-dialog';
|
||||
import type { PhaseModelKey, PhaseModelEntry } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types';
|
||||
|
||||
interface ProjectModelsSectionProps {
|
||||
project: Project;
|
||||
@@ -88,6 +88,127 @@ const MEMORY_TASKS: PhaseConfig[] = [
|
||||
|
||||
const ALL_PHASES = [...QUICK_TASKS, ...VALIDATION_TASKS, ...GENERATION_TASKS, ...MEMORY_TASKS];
|
||||
|
||||
/**
|
||||
* Default feature model override section for per-project settings.
|
||||
*/
|
||||
function FeatureDefaultModelOverrideSection({ project }: { project: Project }) {
|
||||
const {
|
||||
defaultFeatureModel: globalDefaultFeatureModel,
|
||||
setProjectDefaultFeatureModel,
|
||||
claudeCompatibleProviders,
|
||||
} = useAppStore();
|
||||
|
||||
const globalValue: PhaseModelEntry =
|
||||
globalDefaultFeatureModel ?? DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
|
||||
const projectOverride = project.defaultFeatureModel;
|
||||
const hasOverride = !!projectOverride;
|
||||
const effectiveValue = projectOverride || globalValue;
|
||||
|
||||
// Get display name for a model
|
||||
const getModelDisplayName = (entry: PhaseModelEntry): string => {
|
||||
if (entry.providerId) {
|
||||
const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId);
|
||||
if (provider) {
|
||||
const model = provider.models?.find((m) => m.id === entry.model);
|
||||
if (model) {
|
||||
return `${model.displayName} (${provider.name})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Default to model ID for built-in models (both short aliases and canonical IDs)
|
||||
const modelMap: Record<string, string> = {
|
||||
haiku: 'Claude Haiku',
|
||||
sonnet: 'Claude Sonnet',
|
||||
opus: 'Claude Opus',
|
||||
'claude-haiku': 'Claude Haiku',
|
||||
'claude-sonnet': 'Claude Sonnet',
|
||||
'claude-opus': 'Claude Opus',
|
||||
};
|
||||
return modelMap[entry.model] || entry.model;
|
||||
};
|
||||
|
||||
const handleClearOverride = () => {
|
||||
setProjectDefaultFeatureModel(project.id, null);
|
||||
};
|
||||
|
||||
const handleSetOverride = (entry: PhaseModelEntry) => {
|
||||
setProjectDefaultFeatureModel(project.id, entry);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">Feature Defaults</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default model for new feature cards in this project
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between p-4 rounded-xl',
|
||||
'bg-accent/20 border',
|
||||
hasOverride ? 'border-brand-500/30 bg-brand-500/5' : 'border-border/30',
|
||||
'hover:bg-accent/30 transition-colors'
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-brand-500/10 flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-brand-500" />
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-foreground">Default Feature Model</h4>
|
||||
{hasOverride ? (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-brand-500/20 text-brand-500">
|
||||
Override
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
|
||||
<Globe className="w-3 h-3" />
|
||||
Global
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-10">
|
||||
Model and thinking level used when creating new feature cards
|
||||
</p>
|
||||
{hasOverride && (
|
||||
<p className="text-xs text-brand-500 mt-1 ml-10">
|
||||
Using: {getModelDisplayName(effectiveValue)}
|
||||
</p>
|
||||
)}
|
||||
{!hasOverride && (
|
||||
<p className="text-xs text-muted-foreground/70 mt-1 ml-10">
|
||||
Using global: {getModelDisplayName(globalValue)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearOverride}
|
||||
className="h-8 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
<PhaseModelSelector
|
||||
compact
|
||||
value={effectiveValue}
|
||||
onChange={handleSetOverride}
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhaseOverrideItem({
|
||||
phase,
|
||||
project,
|
||||
@@ -234,8 +355,10 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
|
||||
useAppStore();
|
||||
const [showBulkReplace, setShowBulkReplace] = useState(false);
|
||||
|
||||
// Count how many overrides are set
|
||||
const overrideCount = Object.keys(project.phaseModelOverrides || {}).length;
|
||||
// Count how many overrides are set (including defaultFeatureModel)
|
||||
const phaseOverrideCount = Object.keys(project.phaseModelOverrides || {}).length;
|
||||
const hasDefaultFeatureModelOverride = !!project.defaultFeatureModel;
|
||||
const overrideCount = phaseOverrideCount + (hasDefaultFeatureModelOverride ? 1 : 0);
|
||||
|
||||
// Check if Claude is available
|
||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||
@@ -328,6 +451,9 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-8">
|
||||
{/* Feature Defaults */}
|
||||
<FeatureDefaultModelOverrideSection project={project} />
|
||||
|
||||
{/* Quick Tasks */}
|
||||
<PhaseGroup
|
||||
title="Quick Tasks"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { ProjectIdentitySection } from './project-identity-section';
|
||||
import { ProjectThemeSection } from './project-theme-section';
|
||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
import { TestingSection } from './testing-section';
|
||||
import { ProjectModelsSection } from './project-models-section';
|
||||
import { DataManagementSection } from './data-management-section';
|
||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||
@@ -86,6 +87,8 @@ export function ProjectSettingsView() {
|
||||
return <ProjectThemeSection project={currentProject} />;
|
||||
case 'worktrees':
|
||||
return <WorktreePreferencesSection project={currentProject} />;
|
||||
case 'testing':
|
||||
return <TestingSection project={currentProject} />;
|
||||
case 'claude':
|
||||
return <ProjectModelsSection project={currentProject} />;
|
||||
case 'data':
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FlaskConical, Save, RotateCcw, Info } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface TestingSectionProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export function TestingSection({ project }: TestingSectionProps) {
|
||||
const [testCommand, setTestCommand] = useState('');
|
||||
const [originalTestCommand, setOriginalTestCommand] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Check if there are unsaved changes
|
||||
const hasChanges = testCommand !== originalTestCommand;
|
||||
|
||||
// Load project settings when project changes
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
const currentPath = project.path;
|
||||
|
||||
const loadProjectSettings = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
const response = await httpClient.settings.getProject(currentPath);
|
||||
|
||||
// Avoid updating state if component unmounted or project changed
|
||||
if (isCancelled) return;
|
||||
|
||||
if (response.success && response.settings) {
|
||||
const command = response.settings.testCommand || '';
|
||||
setTestCommand(command);
|
||||
setOriginalTestCommand(command);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCancelled) {
|
||||
console.error('Failed to load project settings:', error);
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadProjectSettings();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [project.path]);
|
||||
|
||||
// Save test command
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
const normalizedCommand = testCommand.trim();
|
||||
const response = await httpClient.settings.updateProject(project.path, {
|
||||
testCommand: normalizedCommand || undefined,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setTestCommand(normalizedCommand);
|
||||
setOriginalTestCommand(normalizedCommand);
|
||||
toast.success('Test command saved');
|
||||
} else {
|
||||
toast.error('Failed to save test command', {
|
||||
description: response.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save test command:', error);
|
||||
toast.error('Failed to save test command');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [project.path, testCommand]);
|
||||
|
||||
// Reset to original value
|
||||
const handleReset = useCallback(() => {
|
||||
setTestCommand(originalTestCommand);
|
||||
}, [originalTestCommand]);
|
||||
|
||||
// Use a preset command
|
||||
const handleUsePreset = useCallback((command: string) => {
|
||||
setTestCommand(command);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<FlaskConical className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Testing Configuration
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Configure how tests are run for this project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="md" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Test Command Input */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="test-command" className="text-foreground font-medium">
|
||||
Test Command
|
||||
</Label>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-amber-500 font-medium">(unsaved changes)</span>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id="test-command"
|
||||
value={testCommand}
|
||||
onChange={(e) => setTestCommand(e.target.value)}
|
||||
placeholder="e.g., npm test, yarn test, pytest, go test ./..."
|
||||
className="font-mono text-sm"
|
||||
data-testid="test-command-input"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
The command to run tests for this project. If not specified, the test runner will
|
||||
auto-detect based on your project structure (package.json, Cargo.toml, go.mod,
|
||||
etc.).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto-detection Info */}
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
|
||||
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">Auto-detection</p>
|
||||
<p>
|
||||
When no custom command is set, the test runner automatically detects and uses the
|
||||
appropriate test framework based on your project files (Vitest, Jest, Pytest,
|
||||
Cargo, Go Test, etc.).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Presets */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Quick Presets</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ label: 'npm test', command: 'npm test' },
|
||||
{ label: 'yarn test', command: 'yarn test' },
|
||||
{ label: 'pnpm test', command: 'pnpm test' },
|
||||
{ label: 'bun test', command: 'bun test' },
|
||||
{ label: 'pytest', command: 'pytest' },
|
||||
{ label: 'cargo test', command: 'cargo test' },
|
||||
{ label: 'go test', command: 'go test ./...' },
|
||||
].map((preset) => (
|
||||
<Button
|
||||
key={preset.command}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUsePreset(preset.command)}
|
||||
className="text-xs font-mono"
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
Click a preset to use it as your test command.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges || isSaving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,9 +12,18 @@ const LOG_LEVEL_OPTIONS: { value: ServerLogLevel; label: string; description: st
|
||||
{ value: 'debug', label: 'Debug', description: 'Show all messages including debug' },
|
||||
];
|
||||
|
||||
// Check if we're in development mode
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
|
||||
export function DeveloperSection() {
|
||||
const { serverLogLevel, setServerLogLevel, enableRequestLogging, setEnableRequestLogging } =
|
||||
useAppStore();
|
||||
const {
|
||||
serverLogLevel,
|
||||
setServerLogLevel,
|
||||
enableRequestLogging,
|
||||
setEnableRequestLogging,
|
||||
showQueryDevtools,
|
||||
setShowQueryDevtools,
|
||||
} = useAppStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -85,6 +94,28 @@ export function DeveloperSection() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* React Query DevTools - only shown in development mode */}
|
||||
{IS_DEV && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border/30">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">React Query DevTools</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Show React Query DevTools panel in the bottom-right corner for debugging queries and
|
||||
cache.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={showQueryDevtools}
|
||||
onCheckedChange={(checked) => {
|
||||
setShowQueryDevtools(checked);
|
||||
toast.success(checked ? 'Query DevTools enabled' : 'Query DevTools disabled', {
|
||||
description: 'React Query DevTools visibility updated',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ import type {
|
||||
ClaudeCompatibleProvider,
|
||||
ClaudeModelAlias,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types';
|
||||
|
||||
interface BulkReplaceDialogProps {
|
||||
open: boolean;
|
||||
@@ -48,6 +48,10 @@ const PHASE_LABELS: Record<PhaseModelKey, string> = {
|
||||
|
||||
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
||||
|
||||
// Special key for default feature model (not a phase but included in bulk replace)
|
||||
const DEFAULT_FEATURE_MODEL_KEY = '__defaultFeatureModel__' as const;
|
||||
type ExtendedPhaseKey = PhaseModelKey | typeof DEFAULT_FEATURE_MODEL_KEY;
|
||||
|
||||
// Claude model display names
|
||||
const CLAUDE_MODEL_DISPLAY: Record<ClaudeModelAlias, string> = {
|
||||
haiku: 'Claude Haiku',
|
||||
@@ -56,7 +60,13 @@ const CLAUDE_MODEL_DISPLAY: Record<ClaudeModelAlias, string> = {
|
||||
};
|
||||
|
||||
export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps) {
|
||||
const { phaseModels, setPhaseModel, claudeCompatibleProviders } = useAppStore();
|
||||
const {
|
||||
phaseModels,
|
||||
setPhaseModel,
|
||||
claudeCompatibleProviders,
|
||||
defaultFeatureModel,
|
||||
setDefaultFeatureModel,
|
||||
} = useAppStore();
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>('anthropic');
|
||||
|
||||
// Get enabled providers
|
||||
@@ -113,11 +123,15 @@ export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps
|
||||
const findModelForClaudeAlias = (
|
||||
provider: ClaudeCompatibleProvider | null,
|
||||
claudeAlias: ClaudeModelAlias,
|
||||
phase: PhaseModelKey
|
||||
key: ExtendedPhaseKey
|
||||
): PhaseModelEntry => {
|
||||
if (!provider) {
|
||||
// Anthropic Direct - reset to default phase model (includes correct thinking levels)
|
||||
return DEFAULT_PHASE_MODELS[phase];
|
||||
// For default feature model, use the default from global settings
|
||||
if (key === DEFAULT_FEATURE_MODEL_KEY) {
|
||||
return DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
|
||||
}
|
||||
return DEFAULT_PHASE_MODELS[key];
|
||||
}
|
||||
|
||||
// Find model that maps to this Claude alias
|
||||
@@ -137,58 +151,83 @@ export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps
|
||||
return { model: claudeAlias };
|
||||
};
|
||||
|
||||
// Helper to generate preview item for any entry
|
||||
const generatePreviewItem = (
|
||||
key: ExtendedPhaseKey,
|
||||
label: string,
|
||||
currentEntry: PhaseModelEntry
|
||||
) => {
|
||||
const claudeAlias = getClaudeModelAlias(currentEntry);
|
||||
const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, key);
|
||||
|
||||
// Get display names
|
||||
const getCurrentDisplay = (): string => {
|
||||
if (currentEntry.providerId) {
|
||||
const provider = enabledProviders.find((p) => p.id === currentEntry.providerId);
|
||||
if (provider) {
|
||||
const model = provider.models?.find((m) => m.id === currentEntry.model);
|
||||
return model?.displayName || currentEntry.model;
|
||||
}
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model;
|
||||
};
|
||||
|
||||
const getNewDisplay = (): string => {
|
||||
if (newEntry.providerId && selectedProviderConfig) {
|
||||
const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model);
|
||||
return model?.displayName || newEntry.model;
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model;
|
||||
};
|
||||
|
||||
const isChanged =
|
||||
currentEntry.model !== newEntry.model ||
|
||||
currentEntry.providerId !== newEntry.providerId ||
|
||||
currentEntry.thinkingLevel !== newEntry.thinkingLevel;
|
||||
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
claudeAlias,
|
||||
currentDisplay: getCurrentDisplay(),
|
||||
newDisplay: getNewDisplay(),
|
||||
newEntry,
|
||||
isChanged,
|
||||
};
|
||||
};
|
||||
|
||||
// Generate preview of changes
|
||||
const preview = useMemo(() => {
|
||||
return ALL_PHASES.map((phase) => {
|
||||
// Default feature model entry (first in the list)
|
||||
const defaultFeatureModelEntry =
|
||||
defaultFeatureModel ?? DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
|
||||
const defaultFeaturePreview = generatePreviewItem(
|
||||
DEFAULT_FEATURE_MODEL_KEY,
|
||||
'Default Feature Model',
|
||||
defaultFeatureModelEntry
|
||||
);
|
||||
|
||||
// Phase model entries
|
||||
const phasePreview = ALL_PHASES.map((phase) => {
|
||||
const currentEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase];
|
||||
const claudeAlias = getClaudeModelAlias(currentEntry);
|
||||
const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, phase);
|
||||
|
||||
// Get display names
|
||||
const getCurrentDisplay = (): string => {
|
||||
if (currentEntry.providerId) {
|
||||
const provider = enabledProviders.find((p) => p.id === currentEntry.providerId);
|
||||
if (provider) {
|
||||
const model = provider.models?.find((m) => m.id === currentEntry.model);
|
||||
return model?.displayName || currentEntry.model;
|
||||
}
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model;
|
||||
};
|
||||
|
||||
const getNewDisplay = (): string => {
|
||||
if (newEntry.providerId && selectedProviderConfig) {
|
||||
const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model);
|
||||
return model?.displayName || newEntry.model;
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model;
|
||||
};
|
||||
|
||||
const isChanged =
|
||||
currentEntry.model !== newEntry.model ||
|
||||
currentEntry.providerId !== newEntry.providerId ||
|
||||
currentEntry.thinkingLevel !== newEntry.thinkingLevel;
|
||||
|
||||
return {
|
||||
phase,
|
||||
label: PHASE_LABELS[phase],
|
||||
claudeAlias,
|
||||
currentDisplay: getCurrentDisplay(),
|
||||
newDisplay: getNewDisplay(),
|
||||
newEntry,
|
||||
isChanged,
|
||||
};
|
||||
return generatePreviewItem(phase, PHASE_LABELS[phase], currentEntry);
|
||||
});
|
||||
}, [phaseModels, selectedProviderConfig, enabledProviders]);
|
||||
|
||||
return [defaultFeaturePreview, ...phasePreview];
|
||||
}, [phaseModels, selectedProviderConfig, enabledProviders, defaultFeatureModel]);
|
||||
|
||||
// Count how many will change
|
||||
const changeCount = preview.filter((p) => p.isChanged).length;
|
||||
|
||||
// Apply the bulk replace
|
||||
const handleApply = () => {
|
||||
preview.forEach(({ phase, newEntry, isChanged }) => {
|
||||
preview.forEach(({ key, newEntry, isChanged }) => {
|
||||
if (isChanged) {
|
||||
setPhaseModel(phase, newEntry);
|
||||
if (key === DEFAULT_FEATURE_MODEL_KEY) {
|
||||
setDefaultFeatureModel(newEntry);
|
||||
} else {
|
||||
setPhaseModel(key as PhaseModelKey, newEntry);
|
||||
}
|
||||
}
|
||||
});
|
||||
onOpenChange(false);
|
||||
@@ -284,7 +323,7 @@ export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Preview Changes</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{changeCount} of {ALL_PHASES.length} will change
|
||||
{changeCount} of {preview.length} will change
|
||||
</span>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
|
||||
@@ -298,15 +337,23 @@ export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => (
|
||||
{preview.map(({ key, label, currentDisplay, newDisplay, isChanged }) => (
|
||||
<tr
|
||||
key={phase}
|
||||
key={key}
|
||||
className={cn(
|
||||
'border-t border-border/50',
|
||||
isChanged ? 'bg-brand-500/5' : 'opacity-50'
|
||||
isChanged ? 'bg-brand-500/5' : 'opacity-50',
|
||||
key === DEFAULT_FEATURE_MODEL_KEY && 'bg-accent/30'
|
||||
)}
|
||||
>
|
||||
<td className="p-2 font-medium">{label}</td>
|
||||
<td className="p-2 font-medium">
|
||||
{label}
|
||||
{key === DEFAULT_FEATURE_MODEL_KEY && (
|
||||
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-brand-500/20 text-brand-500">
|
||||
Feature Default
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 text-muted-foreground">{currentDisplay}</td>
|
||||
<td className="p-2 text-center">
|
||||
{isChanged ? (
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { Workflow, RotateCcw, Replace } from 'lucide-react';
|
||||
import { Workflow, RotateCcw, Replace, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PhaseModelSelector } from './phase-model-selector';
|
||||
import { BulkReplaceDialog } from './bulk-replace-dialog';
|
||||
import type { PhaseModelKey } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import type { PhaseModelKey, PhaseModelEntry } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types';
|
||||
|
||||
interface PhaseConfig {
|
||||
key: PhaseModelKey;
|
||||
@@ -113,6 +113,54 @@ function PhaseGroup({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default model for new feature cards section.
|
||||
* This is separate from phase models but logically belongs with model configuration.
|
||||
*/
|
||||
function FeatureDefaultModelSection() {
|
||||
const { defaultFeatureModel, setDefaultFeatureModel } = useAppStore();
|
||||
const defaultValue: PhaseModelEntry =
|
||||
defaultFeatureModel ?? DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">Feature Defaults</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default model for new feature cards when created
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between p-4 rounded-xl',
|
||||
'bg-accent/20 border border-border/30',
|
||||
'hover:bg-accent/30 transition-colors'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 pr-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-brand-500/10 flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-brand-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground">Default Feature Model</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Model and thinking level used when creating new feature cards
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PhaseModelSelector
|
||||
compact
|
||||
value={defaultValue}
|
||||
onChange={setDefaultFeatureModel}
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelDefaultsSection() {
|
||||
const { resetPhaseModels, claudeCompatibleProviders } = useAppStore();
|
||||
const [showBulkReplace, setShowBulkReplace] = useState(false);
|
||||
@@ -171,6 +219,9 @@ export function ModelDefaultsSection() {
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-8">
|
||||
{/* Feature Defaults */}
|
||||
<FeatureDefaultModelSection />
|
||||
|
||||
{/* Quick Tasks */}
|
||||
<PhaseGroup
|
||||
title="Quick Tasks"
|
||||
|
||||
@@ -522,6 +522,9 @@ export function PhaseModelSelector({
|
||||
return [...staticModels, ...uniqueDynamic];
|
||||
}, [dynamicOpencodeModels, enabledDynamicModelIds]);
|
||||
|
||||
// Check if providers are disabled (needed for rendering conditions)
|
||||
const isCursorDisabled = disabledProviders.includes('cursor');
|
||||
|
||||
// Group models (filtering out disabled providers)
|
||||
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
|
||||
const favs: typeof CLAUDE_MODELS = [];
|
||||
@@ -531,7 +534,6 @@ export function PhaseModelSelector({
|
||||
const ocModels: ModelOption[] = [];
|
||||
|
||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||
const isCursorDisabled = disabledProviders.includes('cursor');
|
||||
const isCodexDisabled = disabledProviders.includes('codex');
|
||||
const isOpencodeDisabled = disabledProviders.includes('opencode');
|
||||
|
||||
@@ -1900,7 +1902,7 @@ export function PhaseModelSelector({
|
||||
);
|
||||
})}
|
||||
|
||||
{(groupedModels.length > 0 || standaloneCursorModels.length > 0) && (
|
||||
{!isCursorDisabled && (groupedModels.length > 0 || standaloneCursorModels.length > 0) && (
|
||||
<CommandGroup heading="Cursor Models">
|
||||
{/* Grouped models with secondary popover */}
|
||||
{groupedModels.map((group) => renderGroupedModelItem(group))}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
interface ArrayFieldEditorProps {
|
||||
values: string[];
|
||||
@@ -17,10 +18,6 @@ interface ItemWithId {
|
||||
value: string;
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function ArrayFieldEditor({
|
||||
values,
|
||||
onChange,
|
||||
@@ -30,7 +27,7 @@ export function ArrayFieldEditor({
|
||||
}: ArrayFieldEditorProps) {
|
||||
// Track items with stable IDs
|
||||
const [items, setItems] = useState<ItemWithId[]>(() =>
|
||||
values.map((value) => ({ id: generateId(), value }))
|
||||
values.map((value) => ({ id: generateUUID(), value }))
|
||||
);
|
||||
|
||||
// Track if we're making an internal change to avoid sync loops
|
||||
@@ -44,11 +41,11 @@ export function ArrayFieldEditor({
|
||||
}
|
||||
|
||||
// External change - rebuild items with new IDs
|
||||
setItems(values.map((value) => ({ id: generateId(), value })));
|
||||
setItems(values.map((value) => ({ id: generateUUID(), value })));
|
||||
}, [values]);
|
||||
|
||||
const handleAdd = () => {
|
||||
const newItems = [...items, { id: generateId(), value: '' }];
|
||||
const newItems = [...items, { id: generateUUID(), value: '' }];
|
||||
setItems(newItems);
|
||||
isInternalChange.current = true;
|
||||
onChange(newItems.map((item) => item.value));
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { ListChecks } from 'lucide-react';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import type { SpecOutput } from '@automaker/spec-parser';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
type Feature = SpecOutput['implemented_features'][number];
|
||||
|
||||
@@ -22,15 +23,11 @@ interface FeatureWithId extends Feature {
|
||||
_locationIds?: string[];
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function featureToInternal(feature: Feature): FeatureWithId {
|
||||
return {
|
||||
...feature,
|
||||
_id: generateId(),
|
||||
_locationIds: feature.file_locations?.map(() => generateId()),
|
||||
_id: generateUUID(),
|
||||
_locationIds: feature.file_locations?.map(() => generateUUID()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,7 +60,7 @@ function FeatureCard({ feature, index, onChange, onRemove }: FeatureCardProps) {
|
||||
onChange({
|
||||
...feature,
|
||||
file_locations: [...locations, ''],
|
||||
_locationIds: [...locationIds, generateId()],
|
||||
_locationIds: [...locationIds, generateUUID()],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SpecOutput } from '@automaker/spec-parser';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
type RoadmapPhase = NonNullable<SpecOutput['implementation_roadmap']>[number];
|
||||
type PhaseStatus = 'completed' | 'in_progress' | 'pending';
|
||||
@@ -21,12 +22,8 @@ interface PhaseWithId extends RoadmapPhase {
|
||||
_id: string;
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function phaseToInternal(phase: RoadmapPhase): PhaseWithId {
|
||||
return { ...phase, _id: generateId() };
|
||||
return { ...phase, _id: generateUUID() };
|
||||
}
|
||||
|
||||
function internalToPhase(internal: PhaseWithId): RoadmapPhase {
|
||||
|
||||
Reference in New Issue
Block a user