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:
Shirone
2026-01-21 17:46:22 +01:00
61 changed files with 4752 additions and 213 deletions

View File

@@ -0,0 +1,426 @@
'use client';
import { useEffect, useRef, useCallback, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import {
Terminal,
ArrowDown,
Square,
RefreshCw,
AlertCircle,
Clock,
GitBranch,
CheckCircle2,
XCircle,
FlaskConical,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
import { useTestLogs } from '@/hooks/use-test-logs';
import { useIsMobile } from '@/hooks/use-media-query';
import type { TestRunStatus } from '@/types/electron';
// ============================================================================
// Types
// ============================================================================
export interface TestLogsPanelProps {
/** Whether the panel is open */
open: boolean;
/** Callback when the panel is closed */
onClose: () => void;
/** Path to the worktree to show test logs for */
worktreePath: string | null;
/** Branch name for display */
branch?: string;
/** Specific session ID to fetch logs for (optional) */
sessionId?: string;
/** Callback to stop the running tests */
onStopTests?: () => void;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Get status indicator based on test run status
*/
function getStatusIndicator(status: TestRunStatus | null): {
text: string;
className: string;
icon?: React.ReactNode;
} {
switch (status) {
case 'running':
return {
text: 'Running',
className: 'bg-blue-500/10 text-blue-500',
icon: <span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />,
};
case 'pending':
return {
text: 'Pending',
className: 'bg-amber-500/10 text-amber-500',
icon: <Clock className="w-3 h-3" />,
};
case 'passed':
return {
text: 'Passed',
className: 'bg-green-500/10 text-green-500',
icon: <CheckCircle2 className="w-3 h-3" />,
};
case 'failed':
return {
text: 'Failed',
className: 'bg-red-500/10 text-red-500',
icon: <XCircle className="w-3 h-3" />,
};
case 'cancelled':
return {
text: 'Cancelled',
className: 'bg-yellow-500/10 text-yellow-500',
icon: <AlertCircle className="w-3 h-3" />,
};
case 'error':
return {
text: 'Error',
className: 'bg-red-500/10 text-red-500',
icon: <AlertCircle className="w-3 h-3" />,
};
default:
return {
text: 'Idle',
className: 'bg-muted text-muted-foreground',
};
}
}
/**
* Format duration in milliseconds to human-readable string
*/
function formatDuration(ms: number | null): string | null {
if (ms === null) return null;
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60000);
const seconds = ((ms % 60000) / 1000).toFixed(0);
return `${minutes}m ${seconds}s`;
}
/**
* Format timestamp to localized time string
*/
function formatTime(timestamp: string | null): string | null {
if (!timestamp) return null;
try {
const date = new Date(timestamp);
return date.toLocaleTimeString();
} catch {
return null;
}
}
// ============================================================================
// Inner Content Component
// ============================================================================
interface TestLogsPanelContentProps {
worktreePath: string | null;
branch?: string;
sessionId?: string;
onStopTests?: () => void;
}
function TestLogsPanelContent({
worktreePath,
branch,
sessionId,
onStopTests,
}: TestLogsPanelContentProps) {
const xtermRef = useRef<XtermLogViewerRef>(null);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const lastLogsLengthRef = useRef(0);
const lastSessionIdRef = useRef<string | null>(null);
const {
logs,
isLoading,
error,
status,
sessionId: currentSessionId,
command,
testFile,
startedAt,
exitCode,
duration,
isRunning,
fetchLogs,
} = useTestLogs({
worktreePath,
sessionId,
autoSubscribe: true,
});
// Write logs to xterm when they change
useEffect(() => {
if (!xtermRef.current || !logs) return;
// If session changed, reset the terminal and write all content
if (lastSessionIdRef.current !== currentSessionId) {
lastSessionIdRef.current = currentSessionId;
lastLogsLengthRef.current = 0;
xtermRef.current.write(logs);
lastLogsLengthRef.current = logs.length;
return;
}
// If logs got shorter (e.g., cleared), rewrite all
if (logs.length < lastLogsLengthRef.current) {
xtermRef.current.write(logs);
lastLogsLengthRef.current = logs.length;
return;
}
// Append only the new content
if (logs.length > lastLogsLengthRef.current) {
const newContent = logs.slice(lastLogsLengthRef.current);
xtermRef.current.append(newContent);
lastLogsLengthRef.current = logs.length;
}
}, [logs, currentSessionId]);
// Reset auto-scroll when session changes
useEffect(() => {
if (currentSessionId !== lastSessionIdRef.current) {
setAutoScrollEnabled(true);
lastLogsLengthRef.current = 0;
}
}, [currentSessionId]);
// Scroll to bottom handler
const scrollToBottom = useCallback(() => {
xtermRef.current?.scrollToBottom();
setAutoScrollEnabled(true);
}, []);
const statusIndicator = getStatusIndicator(status);
const formattedStartTime = formatTime(startedAt);
const formattedDuration = formatDuration(duration);
const lineCount = logs ? logs.split('\n').length : 0;
return (
<>
{/* Header */}
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2 text-base">
<FlaskConical className="w-4 h-4 text-primary" />
<span>Test Runner</span>
{status && (
<span
className={cn(
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium',
statusIndicator.className
)}
>
{statusIndicator.icon}
{statusIndicator.text}
</span>
)}
{formattedDuration && !isRunning && (
<span className="text-xs text-muted-foreground font-mono">{formattedDuration}</span>
)}
</DialogTitle>
<div className="flex items-center gap-1.5">
{isRunning && onStopTests && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={onStopTests}
>
<Square className="w-3 h-3 mr-1.5 fill-current" />
Stop
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => fetchLogs()}
title="Refresh logs"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</div>
</div>
{/* Info bar */}
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
{branch && (
<span className="inline-flex items-center gap-1.5">
<GitBranch className="w-3 h-3" />
<span className="font-medium text-foreground/80">{branch}</span>
</span>
)}
{command && (
<span className="inline-flex items-center gap-1.5">
<span className="text-muted-foreground/60">Command</span>
<span className="font-mono text-primary truncate max-w-[200px]">{command}</span>
</span>
)}
{testFile && (
<span className="inline-flex items-center gap-1.5">
<span className="text-muted-foreground/60">File</span>
<span className="font-mono truncate max-w-[150px]">{testFile}</span>
</span>
)}
{formattedStartTime && (
<span className="inline-flex items-center gap-1.5">
<Clock className="w-3 h-3" />
{formattedStartTime}
</span>
)}
</div>
</DialogHeader>
{/* Error displays */}
{error && (
<div className="shrink-0 px-4 py-2 bg-destructive/5 border-b border-destructive/20">
<div className="flex items-center gap-2 text-xs text-destructive">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
<span>{error}</span>
</div>
</div>
)}
{/* Log content area */}
<div className="flex-1 min-h-0 overflow-hidden bg-zinc-950" data-testid="test-logs-content">
{isLoading && !logs ? (
<div className="flex items-center justify-center h-full min-h-[300px] text-muted-foreground">
<Spinner size="md" className="mr-2" />
<span className="text-sm">Loading logs...</span>
</div>
) : !logs && !isRunning && !status ? (
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
<Terminal className="w-10 h-10 mb-3 opacity-20" />
<p className="text-sm">No test run active</p>
<p className="text-xs mt-1 opacity-60">Start a test run to see logs here</p>
</div>
) : isRunning && !logs ? (
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
<Spinner size="xl" className="mb-3" />
<p className="text-sm">Waiting for output...</p>
<p className="text-xs mt-1 opacity-60">Logs will appear as tests generate output</p>
</div>
) : (
<XtermLogViewer
ref={xtermRef}
className="h-full"
minHeight={280}
autoScroll={autoScrollEnabled}
onScrollAwayFromBottom={() => setAutoScrollEnabled(false)}
onScrollToBottom={() => setAutoScrollEnabled(true)}
/>
)}
</div>
{/* Footer status bar */}
<div className="shrink-0 flex items-center justify-between px-4 py-2 bg-muted/30 border-t border-border/50 text-xs text-muted-foreground">
<div className="flex items-center gap-3">
<span className="font-mono">{lineCount > 0 ? `${lineCount} lines` : 'No output'}</span>
{exitCode !== null && (
<span className={cn('font-mono', exitCode === 0 ? 'text-green-500' : 'text-red-500')}>
Exit: {exitCode}
</span>
)}
</div>
{!autoScrollEnabled && logs && (
<button
onClick={scrollToBottom}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded hover:bg-muted transition-colors text-primary"
>
<ArrowDown className="w-3 h-3" />
Scroll to bottom
</button>
)}
{autoScrollEnabled && logs && (
<span className="inline-flex items-center gap-1.5 opacity-60">
<ArrowDown className="w-3 h-3" />
Auto-scroll
</span>
)}
</div>
</>
);
}
// ============================================================================
// Main Component
// ============================================================================
/**
* Panel component for displaying test runner logs with ANSI color rendering
* and real-time streaming support.
*
* Features:
* - Real-time log streaming via WebSocket
* - Full ANSI color code rendering via xterm.js
* - Auto-scroll to bottom (can be paused by scrolling up)
* - Test status indicators (pending, running, passed, failed, etc.)
* - Dialog on desktop, Sheet on mobile
* - Quick actions (stop tests, refresh logs)
*/
export function TestLogsPanel({
open,
onClose,
worktreePath,
branch,
sessionId,
onStopTests,
}: TestLogsPanelProps) {
const isMobile = useIsMobile();
if (!worktreePath) return null;
// Mobile: use Sheet (bottom drawer)
if (isMobile) {
return (
<Sheet open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<SheetContent side="bottom" className="h-[80vh] p-0 flex flex-col">
<SheetHeader className="sr-only">
<SheetTitle>Test Logs</SheetTitle>
</SheetHeader>
<TestLogsPanelContent
worktreePath={worktreePath}
branch={branch}
sessionId={sessionId}
onStopTests={onStopTests}
/>
</SheetContent>
</Sheet>
);
}
// Desktop: use Dialog
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden"
data-testid="test-logs-panel"
compact
>
<TestLogsPanelContent
worktreePath={worktreePath}
branch={branch}
sessionId={sessionId}
onStopTests={onStopTests}
/>
</DialogContent>
</Dialog>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ export type ProjectSettingsViewId =
| 'identity'
| 'theme'
| 'worktrees'
| 'testing'
| 'claude'
| 'data'
| 'danger';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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