Add quick-add feature with improved workflows (#802)

* Changes from feature/quick-add

* feat: Clarify system prompt and improve error handling across services. Address PR Feedback

* feat: Improve PR description parsing and refactor event handling

* feat: Add context options to pipeline orchestrator initialization

* fix: Deduplicate React and handle CJS interop for use-sync-external-store

Resolve "Cannot read properties of null (reading 'useState')" errors by
deduplicating React/react-dom and ensuring use-sync-external-store is
bundled together with React to prevent CJS packages from resolving to
different React instances.
This commit is contained in:
gsxdsm
2026-02-22 20:48:09 -08:00
committed by GitHub
parent 9305ecc242
commit e7504b247f
70 changed files with 3141 additions and 560 deletions

View File

@@ -27,10 +27,14 @@ class DialogAwarePointerSensor extends PointerSensor {
},
];
}
import { useAppStore, Feature } from '@/store/app-store';
import { useAppStore, Feature, type ModelAlias, type ThinkingLevel } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/types';
import type {
BacklogPlanResult,
FeatureStatusWithPipeline,
FeatureTemplate,
} from '@automaker/types';
import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import {
@@ -58,6 +62,7 @@ import {
FollowUpDialog,
PlanApprovalDialog,
MergeRebaseDialog,
QuickAddDialog,
} from './board-view/dialogs';
import type { DependencyLinkType } from './board-view/dialogs';
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
@@ -75,6 +80,7 @@ import type {
StashPopConflictInfo,
StashApplyConflictInfo,
} from './board-view/worktree-panel/types';
import { BoardErrorBoundary } from './board-view/board-error-boundary';
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
import {
useBoardFeatures,
@@ -124,6 +130,7 @@ export function BoardView() {
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
setPipelineConfig,
featureTemplates,
} = useAppStore(
useShallow((state) => ({
currentProject: state.currentProject,
@@ -142,8 +149,11 @@ export function BoardView() {
isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch: state.getPrimaryWorktreeBranch,
setPipelineConfig: state.setPipelineConfig,
featureTemplates: state.featureTemplates,
}))
);
// Also get keyboard shortcuts for the add feature shortcut
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
// Fetch pipeline config via React Query
const { data: pipelineConfig } = usePipelineConfig(currentProject?.path);
const queryClient = useQueryClient();
@@ -165,6 +175,7 @@ export function BoardView() {
} = useBoardFeatures({ currentProject });
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
const [showAddDialog, setShowAddDialog] = useState(false);
const [showQuickAddDialog, setShowQuickAddDialog] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const [showOutputModal, setShowOutputModal] = useState(false);
const [outputFeature, setOutputFeature] = useState<Feature | null>(null);
@@ -418,7 +429,7 @@ export function BoardView() {
(branchName: string) => {
const affectedIds = hookFeatures.filter((f) => f.branchName === branchName).map((f) => f.id);
if (affectedIds.length === 0) return;
const updates: Partial<Feature> = { branchName: null };
const updates: Partial<Feature> = { branchName: undefined };
batchUpdateFeatures(affectedIds, updates);
for (const id of affectedIds) {
persistFeatureUpdate(id, updates).catch((err: unknown) => {
@@ -642,6 +653,15 @@ export function BoardView() {
);
}, [hookFeatures, worktrees]);
// Recovery handler for BoardErrorBoundary: reset worktree selection to main
// so the board can re-render without the stale worktree state that caused the crash.
const handleBoardRecover = useCallback(() => {
if (!currentProject) return;
const mainWorktree = worktrees.find((w) => w.isMain);
const mainBranch = mainWorktree?.branch || 'main';
setCurrentWorktree(currentProject.path, null, mainBranch);
}, [currentProject, worktrees, setCurrentWorktree]);
// Helper function to add and select a worktree
const addAndSelectWorktree = useCallback(
(worktreeResult: { path: string; branch: string }) => {
@@ -992,6 +1012,87 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation]
);
// Handler for Quick Add - creates a feature with minimal data using defaults
const handleQuickAdd = useCallback(
async (
description: string,
modelEntry: { model: string; thinkingLevel?: string; reasoningEffort?: string }
) => {
// Generate a title from the first line of the description
const title = description.split('\n')[0].substring(0, 100);
await handleAddFeature({
title,
description,
category: '',
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: resolveModelString(modelEntry.model) as ModelAlias,
thinkingLevel: (modelEntry.thinkingLevel as ThinkingLevel) || 'none',
branchName: addFeatureUseSelectedWorktreeBranch ? selectedWorktreeBranch : '',
priority: 2,
planningMode: useAppStore.getState().defaultPlanningMode ?? 'skip',
requirePlanApproval: useAppStore.getState().defaultRequirePlanApproval ?? false,
dependencies: [],
workMode: addFeatureUseSelectedWorktreeBranch ? 'custom' : 'current',
});
},
[
handleAddFeature,
defaultSkipTests,
addFeatureUseSelectedWorktreeBranch,
selectedWorktreeBranch,
]
);
// Handler for Quick Add & Start - creates and immediately starts a feature
const handleQuickAddAndStart = useCallback(
async (
description: string,
modelEntry: { model: string; thinkingLevel?: string; reasoningEffort?: string }
) => {
// Generate a title from the first line of the description
const title = description.split('\n')[0].substring(0, 100);
await handleAddAndStartFeature({
title,
description,
category: '',
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: resolveModelString(modelEntry.model) as ModelAlias,
thinkingLevel: (modelEntry.thinkingLevel as ThinkingLevel) || 'none',
branchName: addFeatureUseSelectedWorktreeBranch ? selectedWorktreeBranch : '',
priority: 2,
planningMode: useAppStore.getState().defaultPlanningMode ?? 'skip',
requirePlanApproval: useAppStore.getState().defaultRequirePlanApproval ?? false,
dependencies: [],
workMode: addFeatureUseSelectedWorktreeBranch ? 'custom' : 'current',
initialStatus: 'in_progress',
});
},
[
handleAddAndStartFeature,
defaultSkipTests,
addFeatureUseSelectedWorktreeBranch,
selectedWorktreeBranch,
]
);
// Handler for template selection - creates a feature from a template
const handleTemplateSelect = useCallback(
async (template: FeatureTemplate) => {
const modelEntry = template.model ||
useAppStore.getState().defaultFeatureModel || { model: 'claude-opus' };
// Start the template immediately (same behavior as clicking "Make")
await handleQuickAddAndStart(template.prompt, modelEntry);
},
[handleQuickAddAndStart]
);
// Handler for managing PR comments - opens the PR Comment Resolution dialog
const handleAddressPRComments = useCallback((worktree: WorktreeInfo, prInfo: PRInfo) => {
setPRCommentDialogPRInfo({
@@ -1561,147 +1662,159 @@ export function BoardView() {
onViewModeChange={setViewMode}
/>
{/* DndContext wraps both WorktreePanel and main content area to enable drag-to-worktree */}
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{/* Worktree Panel - conditionally rendered based on visibility setting */}
{(worktreePanelVisibleByProject[currentProject.path] ?? true) && (
<WorktreePanel
refreshTrigger={worktreeRefreshKey}
projectPath={currentProject.path}
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
onDeleteWorktree={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowDeleteWorktreeDialog(true);
}}
onCommit={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCommitWorktreeDialog(true);
}}
onCreatePR={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreatePRDialog(true);
}}
onCreateBranch={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
}}
onAddressPRComments={handleAddressPRComments}
onAutoAddressPRComments={handleAutoAddressPRComments}
onResolveConflicts={handleResolveConflicts}
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
onBranchSwitchConflict={handleBranchSwitchConflict}
onStashPopConflict={handleStashPopConflict}
onStashApplyConflict={handleStashApplyConflict}
onBranchDeletedDuringMerge={(branchName) => {
batchResetBranchFeatures(branchName);
setWorktreeRefreshKey((k) => k + 1);
}}
onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasksAllWorktrees}
branchCardCounts={branchCardCounts}
features={hookFeatures.map((f) => ({
id: f.id,
branchName: f.branchName,
}))}
/>
)}
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* View Content - Kanban Board or List View */}
{isListView ? (
<ListView
columnFeaturesMap={columnFeaturesMap}
allFeatures={hookFeatures}
sortConfig={sortConfig}
onSortChange={setSortColumn}
actionHandlers={{
onEdit: (feature) => setEditingFeature(feature),
onDelete: (featureId) => handleDeleteFeature(featureId),
onViewOutput: handleViewOutput,
onVerify: handleVerifyFeature,
onResume: handleResumeFeature,
onForceStop: handleForceStopFeature,
onManualVerify: handleManualVerify,
onFollowUp: handleOpenFollowUp,
onImplement: handleStartImplementation,
onComplete: handleCompleteFeature,
onViewPlan: (feature) => setViewPlanFeature(feature),
onApprovePlan: handleOpenApprovalDialog,
onSpawnTask: (feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
},
onDuplicate: (feature) => handleDuplicateFeature(feature, false),
onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true),
onDuplicateAsChildMultiple: (feature) => setDuplicateMultipleFeature(feature),
{/* BoardErrorBoundary catches render errors during worktree switches (e.g. React
error #185 re-render cascades on mobile Safari PWA) and provides a recovery UI
that resets to main branch instead of crashing the entire page. */}
<BoardErrorBoundary onRecover={handleBoardRecover}>
{/* DndContext wraps both WorktreePanel and main content area to enable drag-to-worktree */}
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{/* Worktree Panel - conditionally rendered based on visibility setting */}
{(worktreePanelVisibleByProject[currentProject.path] ?? true) && (
<WorktreePanel
refreshTrigger={worktreeRefreshKey}
projectPath={currentProject.path}
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
onDeleteWorktree={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowDeleteWorktreeDialog(true);
}}
runningAutoTasks={runningAutoTasksAllWorktrees}
pipelineConfig={pipelineConfig}
onAddFeature={() => setShowAddDialog(true)}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onRowClick={(feature) => {
if (feature.status === 'backlog') {
setEditingFeature(feature);
} else {
handleViewOutput(feature);
}
onCommit={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCommitWorktreeDialog(true);
}}
className="transition-opacity duration-200"
/>
) : (
<KanbanBoard
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
onCreatePR={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreatePRDialog(true);
}}
onDuplicate={(feature) => handleDuplicateFeature(feature, false)}
onDuplicateAsChild={(feature) => handleDuplicateFeature(feature, true)}
onDuplicateAsChildMultiple={(feature) => setDuplicateMultipleFeature(feature)}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasksAllWorktrees}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
onAddFeature={() => setShowAddDialog(true)}
onShowCompletedModal={() => setShowCompletedModal(true)}
completedCount={completedFeatures.length}
pipelineConfig={pipelineConfig ?? null}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
isDragging={activeFeature !== null}
onAiSuggest={() => setShowPlanDialog(true)}
className="transition-opacity duration-200"
onCreateBranch={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
}}
onAddressPRComments={handleAddressPRComments}
onAutoAddressPRComments={handleAutoAddressPRComments}
onResolveConflicts={handleResolveConflicts}
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
onBranchSwitchConflict={handleBranchSwitchConflict}
onStashPopConflict={handleStashPopConflict}
onStashApplyConflict={handleStashApplyConflict}
onBranchDeletedDuringMerge={(branchName) => {
batchResetBranchFeatures(branchName);
setWorktreeRefreshKey((k) => k + 1);
}}
onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasksAllWorktrees}
branchCardCounts={branchCardCounts}
features={hookFeatures.map((f) => ({
id: f.id,
branchName: f.branchName,
}))}
/>
)}
</div>
</DndContext>
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* View Content - Kanban Board or List View */}
{isListView ? (
<ListView
columnFeaturesMap={columnFeaturesMap}
allFeatures={hookFeatures}
sortConfig={sortConfig}
onSortChange={setSortColumn}
actionHandlers={{
onEdit: (feature) => setEditingFeature(feature),
onDelete: (featureId) => handleDeleteFeature(featureId),
onViewOutput: handleViewOutput,
onVerify: handleVerifyFeature,
onResume: handleResumeFeature,
onForceStop: handleForceStopFeature,
onManualVerify: handleManualVerify,
onFollowUp: handleOpenFollowUp,
onImplement: handleStartImplementation,
onComplete: handleCompleteFeature,
onViewPlan: (feature) => setViewPlanFeature(feature),
onApprovePlan: handleOpenApprovalDialog,
onSpawnTask: (feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
},
onDuplicate: (feature) => handleDuplicateFeature(feature, false),
onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true),
onDuplicateAsChildMultiple: (feature) => setDuplicateMultipleFeature(feature),
}}
runningAutoTasks={runningAutoTasksAllWorktrees}
pipelineConfig={pipelineConfig}
onAddFeature={() => setShowAddDialog(true)}
onQuickAdd={() => setShowQuickAddDialog(true)}
onTemplateSelect={handleTemplateSelect}
templates={featureTemplates}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onRowClick={(feature) => {
if (feature.status === 'backlog') {
setEditingFeature(feature);
} else {
handleViewOutput(feature);
}
}}
className="transition-opacity duration-200"
/>
) : (
<KanbanBoard
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
onDuplicate={(feature) => handleDuplicateFeature(feature, false)}
onDuplicateAsChild={(feature) => handleDuplicateFeature(feature, true)}
onDuplicateAsChildMultiple={(feature) => setDuplicateMultipleFeature(feature)}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasksAllWorktrees}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
onAddFeature={() => setShowAddDialog(true)}
onQuickAdd={() => setShowQuickAddDialog(true)}
onTemplateSelect={handleTemplateSelect}
templates={featureTemplates}
addFeatureShortcut={keyboardShortcuts.addFeature}
onShowCompletedModal={() => setShowCompletedModal(true)}
completedCount={completedFeatures.length}
pipelineConfig={pipelineConfig ?? null}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
isDragging={activeFeature !== null}
onAiSuggest={() => setShowPlanDialog(true)}
className="transition-opacity duration-200"
/>
)}
</div>
</DndContext>
</BoardErrorBoundary>
{/* Selection Action Bar */}
{isSelectionMode && (
@@ -1797,6 +1910,14 @@ export function BoardView() {
forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch}
/>
{/* Quick Add Dialog */}
<QuickAddDialog
open={showQuickAddDialog}
onOpenChange={setShowQuickAddDialog}
onAdd={handleQuickAdd}
onAddAndStart={handleQuickAddAndStart}
/>
{/* Dependency Link Dialog */}
<DependencyLinkDialog
open={Boolean(pendingDependencyLink)}
@@ -2022,14 +2143,19 @@ export function BoardView() {
// cascade into React error #185.
batchResetBranchFeatures(deletedWorktree.branch);
// 5. Do NOT trigger setWorktreeRefreshKey here. The optimistic
// cache update (step 3) already removed the worktree from
// both the Zustand store and React Query cache. Incrementing
// the refresh key would cause invalidateQueries → server
// refetch, and if the server's .worktrees/ directory scan
// finds remnants of the deleted worktree, it would re-add
// it to the dropdown. The 30-second polling interval in
// WorktreePanel will eventually reconcile with the server.
// 5. Schedule a deferred refetch to reconcile with the server.
// The server has already completed the deletion, so this
// refetch will return data without the deleted worktree.
// This protects against stale in-flight polling responses
// that may slip through the cancelQueries window and
// overwrite the optimistic update above.
const projectPathForRefetch = currentProject.path;
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: queryKeys.worktrees.all(projectPathForRefetch),
});
}, 1500);
setSelectedWorktreeForAction(null);
// 6. Force-sync settings immediately so the reset worktree

View File

@@ -0,0 +1,74 @@
import { Component, type ReactNode, type ErrorInfo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
const logger = createLogger('BoardErrorBoundary');
interface Props {
children: ReactNode;
/** Called when the user clicks "Recover" - should reset worktree to main */
onRecover?: () => void;
}
interface State {
hasError: boolean;
error: Error | null;
}
/**
* Error boundary for the board's content area (WorktreePanel + KanbanBoard/ListView).
*
* Catches render errors caused by stale worktree state during worktree switches
* (e.g. re-render cascades that trigger React error #185 on mobile Safari PWA).
* Instead of crashing the entire page, this shows a recovery UI that resets
* the worktree selection to main and retries rendering.
*/
export class BoardErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
logger.error('Board content crashed:', {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
});
}
handleRecover = () => {
this.setState({ hasError: false, error: null });
this.props.onRecover?.();
};
render() {
if (this.state.hasError) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 p-6 text-center">
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertCircle className="w-6 h-6 text-destructive" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-foreground">Board crashed</h3>
<p className="text-sm text-muted-foreground max-w-sm">
A rendering error occurred, possibly during a worktree switch. Click recover to reset
to the main branch and retry.
</p>
</div>
<Button variant="outline" size="sm" onClick={this.handleRecover} className="gap-2">
<RefreshCw className="w-4 h-4" />
Recover
</Button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,188 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Plus, ChevronDown, Zap, FileText } from 'lucide-react';
import type { FeatureTemplate } from '@automaker/types';
import { cn } from '@/lib/utils';
interface AddFeatureButtonProps {
/** Handler for the primary "Add Feature" action (opens full dialog) */
onAddFeature: () => void;
/** Handler for Quick Add submission */
onQuickAdd: () => void;
/** Handler for template selection */
onTemplateSelect: (template: FeatureTemplate) => void;
/** Available templates (filtered to enabled ones) */
templates: FeatureTemplate[];
/** Whether to show as a small icon button or full button */
compact?: boolean;
/** Whether the button should take full width */
fullWidth?: boolean;
/** Additional className */
className?: string;
/** Test ID prefix */
testIdPrefix?: string;
/** Shortcut text to display (optional) */
shortcut?: string;
}
export function AddFeatureButton({
onAddFeature,
onQuickAdd,
onTemplateSelect,
templates,
compact = false,
fullWidth = false,
className,
testIdPrefix = 'add-feature',
shortcut,
}: AddFeatureButtonProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
// Filter to only enabled templates and sort by order
const enabledTemplates = templates
.filter((t) => t.enabled !== false)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const handleTemplateClick = (template: FeatureTemplate) => {
setDropdownOpen(false);
onTemplateSelect(template);
};
if (compact) {
// Compact mode: Three small icon segments
return (
<div className={cn('flex', className)}>
{/* Segment 1: Add Feature */}
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0 rounded-r-none"
onClick={onAddFeature}
title="Add Feature"
data-testid={`${testIdPrefix}-button`}
>
<Plus className="w-3.5 h-3.5" />
</Button>
{/* Segment 2: Quick Add */}
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0 rounded-none border-l border-primary-foreground/20"
onClick={onQuickAdd}
title="Quick Add"
data-testid={`${testIdPrefix}-quick-add-button`}
>
<Zap className="w-3 h-3" />
</Button>
{/* Segment 3: Templates dropdown */}
{enabledTemplates.length > 0 && (
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="default"
size="sm"
className="h-6 w-4 p-0 rounded-l-none border-l border-primary-foreground/20"
title="Templates"
data-testid={`${testIdPrefix}-dropdown-trigger`}
>
<ChevronDown className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={4}>
{enabledTemplates.map((template) => (
<DropdownMenuItem
key={template.id}
onClick={() => handleTemplateClick(template)}
data-testid={`template-menu-item-${template.id}`}
>
<FileText className="w-4 h-4 mr-2" />
<span className="truncate max-w-[200px]">{template.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}
// Full mode: Three-segment button
return (
<div className={cn('flex justify-center', fullWidth && 'w-full', className)}>
{/* Segment 1: Add Feature */}
<Button
variant="default"
size="sm"
className={cn('h-8 text-xs px-3 rounded-r-none', fullWidth && 'flex-1')}
onClick={onAddFeature}
data-testid={`${testIdPrefix}-button`}
>
<Plus className="w-3.5 h-3.5 mr-1.5" />
Add Feature
{shortcut && (
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1 py-0.5 rounded">
{shortcut}
</span>
)}
</Button>
{/* Segment 2: Quick Add */}
<Button
variant="default"
size="sm"
className={cn(
'h-8 text-xs px-2.5 rounded-none border-l border-primary-foreground/20',
fullWidth && 'flex-shrink-0'
)}
onClick={onQuickAdd}
data-testid={`${testIdPrefix}-quick-add-button`}
>
<Zap className="w-3.5 h-3.5 mr-1" />
Quick
</Button>
{/* Segment 3: Templates dropdown */}
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="default"
size="sm"
className={cn(
'h-8 rounded-l-none border-l border-primary-foreground/20',
enabledTemplates.length > 0 ? 'px-1.5' : 'w-7 p-0',
fullWidth && 'flex-shrink-0'
)}
aria-label="Templates"
title="Templates"
data-testid={`${testIdPrefix}-dropdown-trigger`}
>
<FileText className="w-3.5 h-3.5 mr-0.5" />
<ChevronDown className="w-2.5 h-2.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4}>
{enabledTemplates.length > 0 ? (
enabledTemplates.map((template) => (
<DropdownMenuItem
key={template.id}
onClick={() => handleTemplateClick(template)}
data-testid={`template-menu-item-${template.id}`}
>
<FileText className="w-4 h-4 mr-2" />
<span className="truncate max-w-[200px]">{template.name}</span>
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled className="text-muted-foreground">
No templates configured
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -5,12 +5,13 @@ import { Button } from '@/components/ui/button';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { useAppStore, formatShortcut } from '@/store/app-store';
import type { Feature } from '@/store/app-store';
import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types';
import type { PipelineConfig, FeatureStatusWithPipeline, FeatureTemplate } from '@automaker/types';
import { ListHeader } from './list-header';
import { ListRow, sortFeatures } from './list-row';
import { createRowActionHandlers, type RowActionHandlers } from './row-actions';
import { getStatusOrder } from './status-badge';
import { getColumnsWithPipeline } from '../../constants';
import { AddFeatureButton } from '../add-feature-button';
import type { SortConfig, SortColumn } from '../../hooks/use-list-view-state';
/** Empty set constant to avoid creating new instances on each render */
@@ -65,6 +66,12 @@ export interface ListViewProps {
pipelineConfig?: PipelineConfig | null;
/** Callback to add a new feature */
onAddFeature?: () => void;
/** Callback for quick add */
onQuickAdd?: () => void;
/** Callback for template selection */
onTemplateSelect?: (template: FeatureTemplate) => void;
/** Available feature templates */
templates?: FeatureTemplate[];
/** Whether selection mode is enabled */
isSelectionMode?: boolean;
/** Set of selected feature IDs */
@@ -125,7 +132,22 @@ const StatusGroupHeader = memo(function StatusGroupHeader({
/**
* EmptyState displays a message when there are no features
*/
const EmptyState = memo(function EmptyState({ onAddFeature }: { onAddFeature?: () => void }) {
const EmptyState = memo(function EmptyState({
onAddFeature,
onQuickAdd,
onTemplateSelect,
templates,
shortcut,
}: {
onAddFeature?: () => void;
onQuickAdd?: () => void;
onTemplateSelect?: (template: FeatureTemplate) => void;
templates?: FeatureTemplate[];
shortcut?: string;
}) {
// Only show AddFeatureButton if all required handlers are provided
const canShowSplitButton = onAddFeature && onQuickAdd && onTemplateSelect;
return (
<div
className={cn(
@@ -135,12 +157,21 @@ const EmptyState = memo(function EmptyState({ onAddFeature }: { onAddFeature?: (
data-testid="list-view-empty"
>
<p className="text-sm mb-4">No features to display</p>
{onAddFeature && (
{canShowSplitButton ? (
<AddFeatureButton
onAddFeature={onAddFeature}
onQuickAdd={onQuickAdd}
onTemplateSelect={onTemplateSelect}
templates={templates || []}
shortcut={shortcut}
testIdPrefix="list-view-empty-add-feature"
/>
) : onAddFeature ? (
<Button variant="default" size="sm" onClick={onAddFeature}>
<Plus className="w-4 h-4 mr-2" />
Add Feature
</Button>
)}
) : null}
</div>
);
});
@@ -190,6 +221,9 @@ export const ListView = memo(function ListView({
runningAutoTasks,
pipelineConfig = null,
onAddFeature,
onQuickAdd,
onTemplateSelect,
templates = [],
isSelectionMode = false,
selectedFeatureIds = EMPTY_SET,
onToggleFeatureSelection,
@@ -388,7 +422,13 @@ export const ListView = memo(function ListView({
if (totalFeatures === 0) {
return (
<div className={cn('flex flex-col h-full bg-background', className)} data-testid="list-view">
<EmptyState onAddFeature={onAddFeature} />
<EmptyState
onAddFeature={onAddFeature}
onQuickAdd={onQuickAdd}
onTemplateSelect={onTemplateSelect}
templates={templates}
shortcut={formatShortcut(addFeatureShortcut, true)}
/>
</div>
);
}
@@ -452,21 +492,17 @@ export const ListView = memo(function ListView({
</div>
{/* Footer with Add Feature button, styled like board view */}
{onAddFeature && (
{onAddFeature && onQuickAdd && onTemplateSelect && (
<div className="border-t border-border px-4 py-2">
<Button
variant="default"
size="sm"
onClick={onAddFeature}
className="w-full h-9 text-sm"
data-testid="list-view-add-feature"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
{formatShortcut(addFeatureShortcut, true)}
</span>
</Button>
<AddFeatureButton
onAddFeature={onAddFeature}
onQuickAdd={onQuickAdd}
onTemplateSelect={onTemplateSelect}
templates={templates}
fullWidth
shortcut={formatShortcut(addFeatureShortcut, true)}
testIdPrefix="list-view-add-feature"
/>
</div>
)}
</div>

View File

@@ -29,7 +29,10 @@ import { useAppStore } from '@/store/app-store';
/**
* Normalize PhaseModelEntry or string to PhaseModelEntry
*/
function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
function normalizeEntry(entry: PhaseModelEntry | string | undefined | null): PhaseModelEntry {
if (!entry) {
return { model: 'claude-sonnet' as ModelAlias };
}
if (typeof entry === 'string') {
return { model: entry as ModelAlias | CursorModelId };
}
@@ -110,7 +113,12 @@ export function BacklogPlanDialog({
// Use model override if set, otherwise use global default (extract model string from PhaseModelEntry)
const effectiveModelEntry = modelOverride || normalizeEntry(phaseModels.backlogPlanningModel);
const effectiveModel = effectiveModelEntry.model;
const result = await api.backlogPlan.generate(projectPath, prompt, effectiveModel);
const result = await api.backlogPlan.generate(
projectPath,
prompt,
effectiveModel,
currentBranch
);
if (!result.success) {
logger.error('Backlog plan generation failed to start', {
error: result.error,
@@ -131,7 +139,15 @@ export function BacklogPlanDialog({
});
setPrompt('');
onClose();
}, [projectPath, prompt, modelOverride, phaseModels, setIsGeneratingPlan, onClose]);
}, [
projectPath,
prompt,
modelOverride,
phaseModels,
setIsGeneratingPlan,
onClose,
currentBranch,
]);
const handleApply = useCallback(async () => {
if (!pendingPlanResult) return;

View File

@@ -10,12 +10,27 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { GitBranch, AlertCircle, ChevronDown, ChevronRight, Globe, RefreshCw } from 'lucide-react';
import {
GitBranch,
AlertCircle,
ChevronDown,
ChevronRight,
Globe,
RefreshCw,
Cloud,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import { toast } from 'sonner';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
/**
* Parse git/worktree error messages and return user-friendly versions
@@ -113,10 +128,19 @@ export function CreateWorktreeDialog({
// allow free-form branch entry via allowCreate as a fallback.
const [branchFetchError, setBranchFetchError] = useState<string | null>(null);
// Remote selection state
const [selectedRemote, setSelectedRemote] = useState<string>('local');
const [availableRemotes, setAvailableRemotes] = useState<Array<{ name: string; url: string }>>(
[]
);
const [remoteBranches, setRemoteBranches] = useState<
Map<string, Array<{ name: string; fullRef: string }>>
>(new Map());
// AbortController ref so in-flight branch fetches can be cancelled when the dialog closes
const branchFetchAbortRef = useRef<AbortController | null>(null);
// Fetch available branches (local + remote) when the base branch section is expanded
// Fetch available branches and remotes when the base branch section is expanded
const fetchBranches = useCallback(
async (signal?: AbortSignal) => {
if (!projectPath) return;
@@ -125,13 +149,16 @@ export function CreateWorktreeDialog({
try {
const api = getHttpApiClient();
// Fetch branches using the project path (use listBranches on the project root).
// Pass the AbortSignal so controller.abort() cancels the in-flight HTTP request.
const branchResult = await api.worktree.listBranches(projectPath, true, signal);
// Fetch both branches and remotes in parallel
const [branchResult, remotesResult] = await Promise.all([
api.worktree.listBranches(projectPath, true, signal),
api.worktree.listRemotes(projectPath),
]);
// If the fetch was aborted while awaiting, bail out to avoid stale state writes
if (signal?.aborted) return;
// Process branches
if (branchResult.success && branchResult.result) {
setBranchFetchError(null);
setAvailableBranches(
@@ -147,6 +174,30 @@ export function CreateWorktreeDialog({
setBranchFetchError(message);
setAvailableBranches([{ name: 'main', isRemote: false }]);
}
// Process remotes
if (remotesResult.success && remotesResult.result) {
const remotes = remotesResult.result.remotes;
setAvailableRemotes(
remotes.map((r: { name: string; url: string; branches: unknown[] }) => ({
name: r.name,
url: r.url,
}))
);
// Build remote branches map for filtering
const branchesMap = new Map<string, Array<{ name: string; fullRef: string }>>();
remotes.forEach(
(r: {
name: string;
url: string;
branches: Array<{ name: string; fullRef: string }>;
}) => {
branchesMap.set(r.name, r.branches || []);
}
);
setRemoteBranches(branchesMap);
}
} catch (err) {
// If aborted, don't update state
if (signal?.aborted) return;
@@ -160,6 +211,8 @@ export function CreateWorktreeDialog({
// and enable free-form entry (allowCreate) so the user can still type
// any branch name when the remote list is unavailable.
setAvailableBranches([{ name: 'main', isRemote: false }]);
setAvailableRemotes([]);
setRemoteBranches(new Map());
} finally {
if (!signal?.aborted) {
setIsLoadingBranches(false);
@@ -198,27 +251,30 @@ export function CreateWorktreeDialog({
setAvailableBranches([]);
setBranchFetchError(null);
setIsLoadingBranches(false);
setSelectedRemote('local');
setAvailableRemotes([]);
setRemoteBranches(new Map());
}
}, [open]);
// Build branch name list for the autocomplete, with local branches first then remote
// Build branch name list for the autocomplete, filtered by selected remote
const branchNames = useMemo(() => {
const local: string[] = [];
const remote: string[] = [];
for (const b of availableBranches) {
if (b.isRemote) {
// Skip bare remote refs without a branch name (e.g. "origin" by itself)
if (!b.name.includes('/')) continue;
remote.push(b.name);
} else {
local.push(b.name);
}
// If "local" is selected, show only local branches
if (selectedRemote === 'local') {
return availableBranches.filter((b) => !b.isRemote).map((b) => b.name);
}
// Local branches first, then remote branches
return [...local, ...remote];
}, [availableBranches]);
// If a specific remote is selected, show only branches from that remote
const remoteBranchList = remoteBranches.get(selectedRemote);
if (remoteBranchList) {
return remoteBranchList.map((b) => b.fullRef);
}
// Fallback: filter from available branches by remote prefix
return availableBranches
.filter((b) => b.isRemote && b.name.startsWith(`${selectedRemote}/`))
.map((b) => b.name);
}, [availableBranches, selectedRemote, remoteBranches]);
// Determine if the selected base branch is a remote branch.
// Also detect manually entered remote-style names (e.g. "origin/feature")
@@ -418,6 +474,47 @@ export function CreateWorktreeDialog({
</div>
)}
{/* Remote Selector */}
<div className="grid gap-1.5">
<Label htmlFor="remote-select" className="text-xs text-muted-foreground">
Source
</Label>
<Select
value={selectedRemote}
onValueChange={(value) => {
setSelectedRemote(value);
// Clear base branch when switching remotes
setBaseBranch('');
}}
disabled={isLoadingBranches}
>
<SelectTrigger id="remote-select" className="h-8">
<SelectValue placeholder="Select source..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">
<div className="flex items-center gap-2">
<GitBranch className="w-3.5 h-3.5" />
<span>Local Branches</span>
</div>
</SelectItem>
{availableRemotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
<div className="flex items-center gap-2">
<Cloud className="w-3.5 h-3.5" />
<span>{remote.name}</span>
{remote.url && (
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
({remote.url})
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<BranchAutocomplete
value={baseBranch}
onChange={(value) => {
@@ -425,9 +522,13 @@ export function CreateWorktreeDialog({
setError(null);
}}
branches={branchNames}
placeholder="Select base branch (default: HEAD)..."
placeholder={
selectedRemote === 'local'
? 'Select local branch (default: HEAD)...'
: `Select branch from ${selectedRemote}...`
}
disabled={isLoadingBranches}
allowCreate={!!branchFetchError}
allowCreate={!!branchFetchError || selectedRemote === 'local'}
/>
{isRemoteBaseBranch && (

View File

@@ -1,4 +1,5 @@
export { AddFeatureDialog } from './add-feature-dialog';
export { QuickAddDialog } from './quick-add-dialog';
export { AgentOutputModal } from './agent-output-modal';
export { BacklogPlanDialog } from './backlog-plan-dialog';
export { CompletedFeaturesModal } from './completed-features-modal';

View File

@@ -0,0 +1,139 @@
import { useState, useRef, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Textarea } from '@/components/ui/textarea';
import { Play, Plus } from 'lucide-react';
import type { PhaseModelEntry } from '@automaker/types';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { useAppStore } from '@/store/app-store';
interface QuickAddDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (description: string, modelEntry: PhaseModelEntry) => void;
onAddAndStart: (description: string, modelEntry: PhaseModelEntry) => void;
}
export function QuickAddDialog({ open, onOpenChange, onAdd, onAddAndStart }: QuickAddDialogProps) {
const [description, setDescription] = useState('');
const [descriptionError, setDescriptionError] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Get default feature model from store
const defaultFeatureModel = useAppStore((s) => s.defaultFeatureModel);
const currentProject = useAppStore((s) => s.currentProject);
// Use project-level default feature model if set, otherwise fall back to global
const effectiveDefaultFeatureModel = currentProject?.defaultFeatureModel ?? defaultFeatureModel;
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>(
effectiveDefaultFeatureModel || { model: 'claude-opus' }
);
// Reset form when dialog opens (in useEffect to avoid state mutation during render)
useEffect(() => {
if (open) {
setDescription('');
setDescriptionError(false);
setModelEntry(effectiveDefaultFeatureModel || { model: 'claude-opus' });
}
}, [open, effectiveDefaultFeatureModel]);
const handleSubmit = (actionFn: (description: string, modelEntry: PhaseModelEntry) => void) => {
if (!description.trim()) {
setDescriptionError(true);
textareaRef.current?.focus();
return;
}
actionFn(description.trim(), modelEntry);
setDescription('');
setDescriptionError(false);
onOpenChange(false);
};
const handleAdd = () => handleSubmit(onAdd);
const handleAddAndStart = () => handleSubmit(onAddAndStart);
const handleDescriptionChange = (value: string) => {
setDescription(value);
if (value.trim()) {
setDescriptionError(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
compact
className="sm:max-w-md"
data-testid="quick-add-dialog"
onOpenAutoFocus={(e) => {
e.preventDefault();
textareaRef.current?.focus();
}}
>
<DialogHeader>
<DialogTitle>Quick Add Feature</DialogTitle>
<DialogDescription>
Create a new feature with minimal configuration. All other settings use defaults.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{/* Description Input */}
<div className="space-y-2">
<label htmlFor="quick-add-description" className="text-sm font-medium">
Description
</label>
<Textarea
ref={textareaRef}
id="quick-add-description"
value={description}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Describe what you want to build..."
className={
descriptionError ? 'border-destructive focus-visible:ring-destructive' : ''
}
rows={3}
data-testid="quick-add-description-input"
/>
{descriptionError && (
<p className="text-xs text-destructive">Description is required</p>
)}
</div>
{/* Model Selection */}
<PhaseModelSelector value={modelEntry} onChange={setModelEntry} compact align="end" />
</div>
<DialogFooter className="gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="secondary" onClick={handleAdd} data-testid="quick-add-button">
<Plus className="w-4 h-4 mr-2" />
Add
</Button>
<HotkeyButton
onClick={handleAddAndStart}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
data-testid="quick-add-and-start-button"
>
<Play className="w-4 h-4 mr-2" />
Make
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -13,11 +13,20 @@ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { Button } from '@/components/ui/button';
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
import { Feature, useAppStore, formatShortcut } from '@/store/app-store';
import { Archive, Settings2, CheckSquare, GripVertical, Plus, CheckCircle2 } from 'lucide-react';
import {
Archive,
Settings2,
CheckSquare,
GripVertical,
Plus,
CheckCircle2,
Zap,
} from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { getColumnsWithPipeline, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
import type { PipelineConfig, FeatureTemplate } from '@automaker/types';
import { AddFeatureButton } from './components/add-feature-button';
import { cn } from '@/lib/utils';
interface KanbanBoardProps {
activeFeature: Feature | null;
@@ -53,6 +62,10 @@ interface KanbanBoardProps {
runningAutoTasks: string[];
onArchiveAllVerified: () => void;
onAddFeature: () => void;
onQuickAdd: () => void;
onTemplateSelect: (template: FeatureTemplate) => void;
templates: FeatureTemplate[];
addFeatureShortcut?: string;
onShowCompletedModal: () => void;
completedCount: number;
pipelineConfig: PipelineConfig | null;
@@ -292,6 +305,10 @@ export function KanbanBoard({
runningAutoTasks,
onArchiveAllVerified,
onAddFeature,
onQuickAdd,
onTemplateSelect,
templates,
addFeatureShortcut: addFeatureShortcutProp,
onShowCompletedModal,
completedCount,
pipelineConfig,
@@ -311,7 +328,7 @@ export function KanbanBoard({
// Get the keyboard shortcut for adding features
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
const addFeatureShortcut = addFeatureShortcutProp || keyboardShortcuts.addFeature || 'N';
// Use responsive column widths based on window size
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
@@ -408,16 +425,28 @@ export function KanbanBoard({
</div>
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0"
onClick={onAddFeature}
title="Add Feature"
data-testid="add-feature-button"
>
<Plus className="w-3.5 h-3.5" />
</Button>
<div className="flex items-center">
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0 rounded-r-none"
onClick={onAddFeature}
title="Add Feature"
data-testid="add-feature-button"
>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0 rounded-l-none border-l border-primary-foreground/20"
onClick={onQuickAdd}
title="Quick Add Feature"
data-testid="quick-add-feature-button"
>
<Zap className="w-3.5 h-3.5" />
</Button>
</div>
<Button
variant="ghost"
size="sm"
@@ -494,19 +523,14 @@ export function KanbanBoard({
}
footerAction={
column.id === 'backlog' ? (
<Button
variant="default"
size="sm"
className="w-full h-9 text-sm"
onClick={onAddFeature}
data-testid="add-feature-floating-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
{formatShortcut(addFeatureShortcut, true)}
</span>
</Button>
<AddFeatureButton
onAddFeature={onAddFeature}
onQuickAdd={onQuickAdd}
onTemplateSelect={onTemplateSelect}
templates={templates}
fullWidth
shortcut={formatShortcut(addFeatureShortcut, true)}
/>
) : undefined
}
>

View File

@@ -147,6 +147,8 @@ interface WorktreeActionsDropdownProps {
onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Set tracking branch to a specific remote */
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch?: string[];
}
/**
@@ -182,7 +184,9 @@ function RemoteActionMenuItem({
<Icon className="w-3.5 h-3.5 mr-2" />
{remote.name}
{trackingRemote === remote.name && (
<span className="ml-auto text-[10px] text-muted-foreground mr-1">tracking</span>
<span className="ml-auto text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded mr-2">
tracking
</span>
)}
</DropdownMenuItem>
<DropdownMenuSubTrigger
@@ -282,6 +286,7 @@ export function WorktreeActionsDropdown({
onSync,
onSyncWithRemote,
onSetTracking,
remotesWithBranch,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors();
@@ -326,6 +331,21 @@ export function WorktreeActionsDropdown({
? 'Repository has no commits yet'
: null;
// Check if the branch exists on remotes other than the tracking remote.
// This indicates the branch was pushed to a different remote than the one being tracked,
// so the ahead/behind counts may be misleading.
const otherRemotesWithBranch = useMemo(() => {
if (!remotesWithBranch || remotesWithBranch.length === 0) return [];
if (!trackingRemote) return remotesWithBranch;
return remotesWithBranch.filter((r) => r !== trackingRemote);
}, [remotesWithBranch, trackingRemote]);
// True when branch exists on a different remote but NOT on the tracking remote
const isOnDifferentRemote =
otherRemotesWithBranch.length > 0 &&
trackingRemote &&
!remotesWithBranch?.includes(trackingRemote);
// Determine if the changes/PR section has any visible items
// Show Create PR when no existing PR is linked
const showCreatePR = !hasPR;
@@ -783,11 +803,17 @@ export function WorktreeActionsDropdown({
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
{isGitOpsAvailable && behindCount > 0 && (
{isGitOpsAvailable && !isOnDifferentRemote && behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind
</span>
)}
{isGitOpsAvailable && isOnDifferentRemote && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-blue-500/20 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 rounded">
<Globe className="w-2.5 h-2.5" />
on {otherRemotesWithBranch.join(', ')}
</span>
)}
</DropdownMenuItem>
<DropdownMenuSubTrigger
className={cn(
@@ -832,11 +858,17 @@ export function WorktreeActionsDropdown({
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
{isGitOpsAvailable && behindCount > 0 && (
{isGitOpsAvailable && !isOnDifferentRemote && behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind
</span>
)}
{isGitOpsAvailable && isOnDifferentRemote && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-blue-500/20 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 rounded">
<Globe className="w-2.5 h-2.5" />
on {otherRemotesWithBranch.join(', ')}
</span>
)}
</DropdownMenuItem>
)}
</TooltipWrapper>
@@ -856,7 +888,9 @@ export function WorktreeActionsDropdown({
}
}}
disabled={
isPushing || (hasRemoteBranch && aheadCount === 0) || !isGitOpsAvailable
isPushing ||
(hasRemoteBranch && !isOnDifferentRemote && aheadCount === 0) ||
!isGitOpsAvailable
}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
@@ -874,21 +908,33 @@ export function WorktreeActionsDropdown({
local only
</span>
)}
{isGitOpsAvailable && hasRemoteBranch && aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
)}
{isGitOpsAvailable && hasRemoteBranch && trackingRemote && (
<span
className={cn(
'text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded',
aheadCount > 0 ? 'ml-1' : 'ml-auto'
)}
>
{trackingRemote}
{isGitOpsAvailable && hasRemoteBranch && isOnDifferentRemote && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-blue-500/20 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 rounded">
<Globe className="w-2.5 h-2.5" />
on {otherRemotesWithBranch.join(', ')}
</span>
)}
{isGitOpsAvailable &&
hasRemoteBranch &&
!isOnDifferentRemote &&
aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
)}
{isGitOpsAvailable &&
hasRemoteBranch &&
!isOnDifferentRemote &&
trackingRemote && (
<span
className={cn(
'text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded',
aheadCount > 0 ? 'ml-1' : 'ml-auto'
)}
>
{trackingRemote}
</span>
)}
</DropdownMenuItem>
<DropdownMenuSubTrigger
className={cn(
@@ -932,7 +978,11 @@ export function WorktreeActionsDropdown({
onPush(worktree);
}
}}
disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !isGitOpsAvailable}
disabled={
isPushing ||
(hasRemoteBranch && !isOnDifferentRemote && aheadCount === 0) ||
!isGitOpsAvailable
}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
@@ -946,12 +996,18 @@ export function WorktreeActionsDropdown({
local only
</span>
)}
{isGitOpsAvailable && hasRemoteBranch && aheadCount > 0 && (
{isGitOpsAvailable && hasRemoteBranch && isOnDifferentRemote && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-blue-500/20 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 rounded">
<Globe className="w-2.5 h-2.5" />
on {otherRemotesWithBranch.join(', ')}
</span>
)}
{isGitOpsAvailable && hasRemoteBranch && !isOnDifferentRemote && aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
)}
{isGitOpsAvailable && hasRemoteBranch && trackingRemote && (
{isGitOpsAvailable && hasRemoteBranch && !isOnDifferentRemote && trackingRemote && (
<span
className={cn(
'text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded',

View File

@@ -146,6 +146,8 @@ export interface WorktreeDropdownProps {
onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Set tracking branch to a specific remote */
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch?: string[];
}
/**
@@ -242,6 +244,7 @@ export function WorktreeDropdown({
onSync,
onSyncWithRemote,
onSetTracking,
remotesWithBranch,
}: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
@@ -565,6 +568,7 @@ export function WorktreeDropdown({
onSync={onSync}
onSyncWithRemote={onSyncWithRemote}
onSetTracking={onSetTracking}
remotesWithBranch={remotesWithBranch}
/>
)}
</div>

View File

@@ -116,6 +116,8 @@ interface WorktreeTabProps {
onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Set tracking branch to a specific remote */
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch?: string[];
}
export function WorktreeTab({
@@ -193,6 +195,7 @@ export function WorktreeTab({
onSync,
onSyncWithRemote,
onSetTracking,
remotesWithBranch,
}: WorktreeTabProps) {
// Make the worktree tab a drop target for feature cards
const { setNodeRef, isOver } = useDroppable({
@@ -566,6 +569,7 @@ export function WorktreeTab({
onSync={onSync}
onSyncWithRemote={onSyncWithRemote}
onSetTracking={onSetTracking}
remotesWithBranch={remotesWithBranch}
/>
</div>
);

View File

@@ -17,6 +17,8 @@ export interface UseBranchesReturn {
trackingRemote: string | undefined;
/** Per-worktree tracking remote lookup — avoids stale values when multiple panels share the hook */
getTrackingRemote: (worktreePath: string) => string | undefined;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch: string[];
isLoadingBranches: boolean;
branchFilter: string;
setBranchFilter: (filter: string) => void;
@@ -49,6 +51,7 @@ export function useBranches(): UseBranchesReturn {
const behindCount = branchData?.behindCount ?? 0;
const hasRemoteBranch = branchData?.hasRemoteBranch ?? false;
const trackingRemote = branchData?.trackingRemote;
const remotesWithBranch = branchData?.remotesWithBranch ?? [];
// Per-worktree tracking remote cache: keeps results from previous fetchBranches()
// calls so multiple WorktreePanel instances don't all share a single stale value.
@@ -119,6 +122,7 @@ export function useBranches(): UseBranchesReturn {
hasRemoteBranch,
trackingRemote,
getTrackingRemote,
remotesWithBranch,
isLoadingBranches,
branchFilter,
setBranchFilter,

View File

@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef } from 'react';
import { useEffect, useCallback, useRef, startTransition } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useAppStore } from '@/store/app-store';
import { useWorktrees as useWorktreesQuery } from '@/hooks/queries';
@@ -93,7 +93,15 @@ export function useWorktrees({
// Fallback to "main" only if worktrees haven't loaded yet
const mainWorktree = worktrees.find((w) => w.isMain);
const mainBranch = mainWorktree?.branch || 'main';
setCurrentWorktree(projectPath, null, mainBranch);
// Note: Zustand uses useSyncExternalStore so setCurrentWorktree updates
// are flushed synchronously. The real guard against React error #185 is
// dependency isolation — currentWorktree is intentionally excluded from
// the validation effect deps below (via currentWorktreeRef) so we don't
// create a feedback loop. startTransition may still help batch unrelated
// React state updates but does NOT defer or prevent Zustand-driven cascades.
startTransition(() => {
setCurrentWorktree(projectPath, null, mainBranch);
});
}
}
}, [worktrees, projectPath, setCurrentWorktree]);
@@ -109,7 +117,16 @@ export function useWorktrees({
if (isSameWorktree) return;
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
// Note: Zustand uses useSyncExternalStore so setCurrentWorktree updates are
// flushed synchronously — startTransition does NOT prevent Zustand-driven
// cascades. The actual protection against React error #185 is dependency
// isolation via currentWorktreeRef (currentWorktree is excluded from the
// validation effect's dependency array). startTransition may still help
// batch unrelated concurrent React state updates but should not be relied
// upon for Zustand update ordering.
startTransition(() => {
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
});
// Defer feature query invalidation so the store update and client-side
// re-filtering happen in the current render cycle first. The features
@@ -121,7 +138,7 @@ export function useWorktrees({
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(projectPath),
});
}, 0);
}, 100);
},
[projectPath, setCurrentWorktree, queryClient, currentWorktreePath]
);

View File

@@ -101,6 +101,7 @@ export function WorktreePanel({
hasRemoteBranch,
trackingRemote,
getTrackingRemote,
remotesWithBranch,
isLoadingBranches,
branchFilter,
setBranchFilter,
@@ -466,20 +467,8 @@ export function WorktreePanel({
const isMobile = useIsMobile();
// Periodic interval check (30 seconds) to detect branch changes on disk
// Reduced polling to lessen repeated worktree list calls while keeping UI reasonably fresh
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
fetchWorktrees({ silent: true });
}, 30000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [fetchWorktrees]);
// NOTE: Periodic polling is handled by React Query's refetchInterval
// in hooks/queries/use-worktrees.ts (30s). No separate setInterval needed.
// Prune stale tracking-remote cache entries and remotes cache when worktrees change
useEffect(() => {
@@ -967,6 +956,7 @@ export function WorktreePanel({
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
remotesWithBranch={remotesWithBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -1214,6 +1204,7 @@ export function WorktreePanel({
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
remotesWithBranch={remotesWithBranch}
remotesCache={remotesCache}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
@@ -1358,6 +1349,7 @@ export function WorktreePanel({
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
remotesWithBranch={remotesWithBranch}
/>
)}
</div>
@@ -1449,6 +1441,7 @@ export function WorktreePanel({
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
remotesWithBranch={remotesWithBranch}
/>
);
})}

View File

@@ -30,6 +30,7 @@ import {
import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts';
import { EventHooksSection } from './settings-view/event-hooks';
import { TemplatesSection } from './settings-view/templates/templates-section';
import { ImportExportDialog } from './settings-view/components/import-export-dialog';
import type { Theme } from './settings-view/shared/types';
@@ -65,6 +66,12 @@ export function SettingsView() {
setSkipSandboxWarning,
defaultMaxTurns,
setDefaultMaxTurns,
featureTemplates,
setFeatureTemplates,
addFeatureTemplate,
updateFeatureTemplate,
deleteFeatureTemplate,
reorderFeatureTemplates,
} = useAppStore();
// Global theme (project-specific themes are managed in Project Settings)
@@ -142,6 +149,16 @@ export function SettingsView() {
onPromptCustomizationChange={setPromptCustomization}
/>
);
case 'templates':
return (
<TemplatesSection
templates={featureTemplates}
onAddTemplate={addFeatureTemplate}
onUpdateTemplate={updateFeatureTemplate}
onDeleteTemplate={deleteFeatureTemplate}
onReorderTemplates={reorderFeatureTemplates}
/>
);
case 'model-defaults':
return <ModelDefaultsSection />;
case 'appearance':

View File

@@ -1,17 +1,20 @@
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { FileCode } from 'lucide-react';
import { FileCode, Terminal } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ClaudeMdSettingsProps {
autoLoadClaudeMd: boolean;
onAutoLoadClaudeMdChange: (enabled: boolean) => void;
useClaudeCodeSystemPrompt: boolean;
onUseClaudeCodeSystemPromptChange: (enabled: boolean) => void;
}
/**
* ClaudeMdSettings Component
*
* UI controls for Claude Agent SDK settings including:
* - Using Claude Code's built-in system prompt as the base
* - Auto-loading of project instructions from .claude/CLAUDE.md files
*
* Usage:
@@ -19,12 +22,16 @@ interface ClaudeMdSettingsProps {
* <ClaudeMdSettings
* autoLoadClaudeMd={autoLoadClaudeMd}
* onAutoLoadClaudeMdChange={setAutoLoadClaudeMd}
* useClaudeCodeSystemPrompt={useClaudeCodeSystemPrompt}
* onUseClaudeCodeSystemPromptChange={setUseClaudeCodeSystemPrompt}
* />
* ```
*/
export function ClaudeMdSettings({
autoLoadClaudeMd,
onAutoLoadClaudeMdChange,
useClaudeCodeSystemPrompt,
onUseClaudeCodeSystemPromptChange,
}: ClaudeMdSettingsProps) {
return (
<div
@@ -39,17 +46,38 @@ export function ClaudeMdSettings({
<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">
<FileCode className="w-5 h-5 text-brand-500" />
<Terminal className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
CLAUDE.md Integration
</h2>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Claude Agent SDK</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure automatic loading of project-specific instructions.
Configure Claude Code system prompt and project instructions.
</p>
</div>
<div className="p-6">
<div className="p-6 space-y-2">
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="use-claude-code-system-prompt"
checked={useClaudeCodeSystemPrompt}
onCheckedChange={(checked) => onUseClaudeCodeSystemPromptChange(checked === true)}
className="mt-1"
data-testid="use-claude-code-system-prompt-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="use-claude-code-system-prompt"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Terminal className="w-4 h-4 text-brand-500" />
Use Claude Code System Prompt
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Use Claude Code&apos;s built-in system prompt as the base for all agent sessions.
Automaker&apos;s prompts are appended on top. When disabled, only Automaker&apos;s
custom system prompt is used.
</p>
</div>
</div>
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="auto-load-claude-md"

View File

@@ -114,7 +114,7 @@ export function ImportExportDialog({ open, onOpenChange }: ImportExportDialogPro
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
<DialogContent className="max-w-[calc(100%-2rem)] sm:max-w-3xl lg:max-w-4xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Import / Export Settings</DialogTitle>
<DialogDescription>
@@ -125,7 +125,7 @@ export function ImportExportDialog({ open, onOpenChange }: ImportExportDialogPro
<div className="flex-1 flex flex-col gap-4 min-h-0 mt-4">
{/* Action Buttons */}
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Button
variant="outline"

View File

@@ -16,7 +16,7 @@ interface KeyboardMapDialogProps {
export function KeyboardMapDialog({ open, onOpenChange }: KeyboardMapDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogContent className="bg-popover border-border max-w-[calc(100%-2rem)] sm:max-w-3xl lg:max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Keyboard className="w-5 h-5 text-brand-500" />

View File

@@ -17,6 +17,7 @@ import {
Code2,
Webhook,
FileCode2,
FileText,
} from 'lucide-react';
import {
AnthropicIcon,
@@ -49,6 +50,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
{ id: 'model-defaults', label: 'Model Defaults', icon: Workflow },
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
{ id: 'templates', label: 'Templates', icon: FileText },
{ id: 'api-keys', label: 'API Keys', icon: Key },
{
id: 'providers',

View File

@@ -100,8 +100,8 @@ export function FeatureDefaultsSection({
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-brand-500/10">
<Cpu className="w-5 h-5 text-brand-500" />
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0 space-y-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<Label className="text-foreground font-medium">Default Model</Label>
<PhaseModelSelector
value={defaultFeatureModel}
@@ -124,8 +124,8 @@ export function FeatureDefaultsSection({
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-orange-500/10">
<RotateCcw className="w-5 h-5 text-orange-500" />
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0 space-y-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<Label htmlFor="default-max-turns" className="text-foreground font-medium">
Max Agent Turns
</Label>
@@ -187,14 +187,17 @@ export function FeatureDefaultsSection({
{defaultPlanningMode === 'spec' && <FileText className="w-5 h-5 text-purple-500" />}
{defaultPlanningMode === 'full' && <ScrollText className="w-5 h-5 text-amber-500" />}
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0 space-y-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<Label className="text-foreground font-medium">Default Planning Mode</Label>
<Select
value={defaultPlanningMode}
onValueChange={(v: string) => onDefaultPlanningModeChange(v as PlanningMode)}
>
<SelectTrigger className="w-[160px] h-8" data-testid="default-planning-mode-select">
<SelectTrigger
className="w-full sm:w-[160px] h-8"
data-testid="default-planning-mode-select"
>
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@@ -12,6 +12,7 @@ export type SettingsViewId =
| 'copilot-provider'
| 'mcp-servers'
| 'prompts'
| 'templates'
| 'model-defaults'
| 'appearance'
| 'editor'

View File

@@ -41,7 +41,7 @@ export function AddEditServerDialog({
}: AddEditServerDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="mcp-server-dialog">
<DialogContent className="sm:max-w-lg" data-testid="mcp-server-dialog">
<DialogHeader>
<DialogTitle>{editingServer ? 'Edit MCP Server' : 'Add MCP Server'}</DialogTitle>
<DialogDescription>

View File

@@ -254,7 +254,7 @@ export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogContent className="max-w-[calc(100%-2rem)] sm:max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Bulk Replace Models</DialogTitle>
<DialogDescription>

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Workflow, RotateCcw, Replace, Sparkles, Brain } from 'lucide-react';
import { Workflow, RotateCcw, Replace, Brain } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
@@ -135,31 +135,13 @@ function FeatureDefaultModelSection() {
</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>
<PhaseModelSelector
label="Default Feature Model"
description="Model and thinking level used when creating new feature cards"
value={defaultValue}
onChange={setDefaultFeatureModel}
align="end"
/>
</div>
</div>
);
@@ -201,30 +183,30 @@ function DefaultThinkingLevelSection() {
{/* Default Thinking Level (Claude models) */}
<div
className={cn(
'flex items-center justify-between p-4 rounded-xl',
'flex flex-col sm:flex-row sm:items-center justify-between gap-3 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-purple-500/10 flex items-center justify-center">
<div className="flex items-center gap-3 flex-1">
<div className="w-8 h-8 rounded-lg bg-purple-500/10 flex items-center justify-center shrink-0">
<Brain className="w-4 h-4 text-purple-500" />
</div>
<div>
<div className="min-w-0">
<h4 className="text-sm font-medium text-foreground">Default Thinking Level</h4>
<p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground truncate">
Applied to Claude models when quick-selected
</p>
</div>
</div>
<div className="flex items-center gap-1.5 flex-wrap justify-end">
<div className="flex items-center gap-1 flex-wrap justify-start sm:justify-end">
{THINKING_LEVEL_OPTIONS.map((option) => (
<button
key={option.id}
onClick={() => setDefaultThinkingLevel(option.id)}
className={cn(
'px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all',
'border',
'px-2 py-1 sm:px-2.5 sm:py-1.5 rounded-lg text-xs font-medium transition-all',
'border whitespace-nowrap',
defaultThinkingLevel === option.id
? 'bg-primary text-primary-foreground border-primary shadow-sm'
: 'bg-background border-border/50 text-muted-foreground hover:bg-accent hover:text-foreground'
@@ -240,30 +222,30 @@ function DefaultThinkingLevelSection() {
{/* Default Reasoning Effort (Codex models) */}
<div
className={cn(
'flex items-center justify-between p-4 rounded-xl',
'flex flex-col sm:flex-row sm:items-center justify-between gap-3 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-blue-500/10 flex items-center justify-center">
<div className="flex items-center gap-3 flex-1">
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center shrink-0">
<Brain className="w-4 h-4 text-blue-500" />
</div>
<div>
<div className="min-w-0">
<h4 className="text-sm font-medium text-foreground">Default Reasoning Effort</h4>
<p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground truncate">
Applied to Codex/OpenAI models when quick-selected
</p>
</div>
</div>
<div className="flex items-center gap-1.5 flex-wrap justify-end">
<div className="flex items-center gap-1 flex-wrap justify-start sm:justify-end">
{REASONING_EFFORT_LEVELS.map((option) => (
<button
key={option.id}
onClick={() => setDefaultReasoningEffort(option.id)}
className={cn(
'px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all',
'border',
'px-2 py-1 sm:px-2.5 sm:py-1.5 rounded-lg text-xs font-medium transition-all',
'border whitespace-nowrap',
defaultReasoningEffort === option.id
? 'bg-primary text-primary-foreground border-primary shadow-sm'
: 'bg-background border-border/50 text-muted-foreground hover:bg-accent hover:text-foreground'

View File

@@ -2210,7 +2210,7 @@ export function PhaseModelSelector({
aria-expanded={open}
disabled={disabled}
className={cn(
'w-[280px] justify-between h-9 px-3 bg-background/50 border-border/50 hover:bg-background/80 hover:text-foreground',
'w-full sm:w-[280px] justify-between h-11 rounded-xl border-border px-3 bg-background/50 hover:bg-background/80 hover:text-foreground',
triggerClassName
)}
>
@@ -2237,8 +2237,8 @@ export function PhaseModelSelector({
// The popover content (shared between both modes)
const popoverContent = (
<PopoverContent
className="w-[320px] p-0"
align={align}
className="w-[min(calc(100vw-2rem),320px)] p-0"
align={isMobile ? 'start' : align}
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
onPointerDownOutside={(e) => {
@@ -2431,13 +2431,13 @@ export function PhaseModelSelector({
return (
<div
className={cn(
'flex items-center justify-between p-4 rounded-xl',
'flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-4 rounded-xl',
'bg-accent/20 border border-border/30',
'hover:bg-accent/30 transition-colors'
)}
>
{/* Label and Description */}
<div className="flex-1 pr-4">
<div className="w-full min-w-0 sm:flex-1 sm:pr-4">
<h4 className="text-sm font-medium text-foreground">{label}</h4>
<p className="text-xs text-muted-foreground">{description}</p>
</div>

View File

@@ -12,7 +12,13 @@ import { ProviderToggle } from './provider-toggle';
import { Info } from 'lucide-react';
export function ClaudeSettingsTab() {
const { apiKeys, autoLoadClaudeMd, setAutoLoadClaudeMd } = useAppStore();
const {
apiKeys,
autoLoadClaudeMd,
setAutoLoadClaudeMd,
useClaudeCodeSystemPrompt,
setUseClaudeCodeSystemPrompt,
} = useAppStore();
const { claudeAuthStatus } = useSetupStore();
// Use CLI status hook
@@ -53,6 +59,8 @@ export function ClaudeSettingsTab() {
<ClaudeMdSettings
autoLoadClaudeMd={autoLoadClaudeMd}
onAutoLoadClaudeMdChange={setAutoLoadClaudeMd}
useClaudeCodeSystemPrompt={useClaudeCodeSystemPrompt}
onUseClaudeCodeSystemPromptChange={setUseClaudeCodeSystemPrompt}
/>
{/* Skills Configuration */}

View File

@@ -0,0 +1,431 @@
import { useState, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { GripVertical, Plus, Pencil, Trash2, FileText, Lock, MoreHorizontal } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { FeatureTemplate, PhaseModelEntry } from '@automaker/types';
import { PhaseModelSelector } from '../model-defaults/phase-model-selector';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface TemplatesSectionProps {
templates: FeatureTemplate[];
onAddTemplate: (template: FeatureTemplate) => Promise<void>;
onUpdateTemplate: (id: string, updates: Partial<FeatureTemplate>) => Promise<void>;
onDeleteTemplate: (id: string) => Promise<void>;
onReorderTemplates: (templateIds: string[]) => Promise<void>;
}
interface TemplateFormData {
name: string;
prompt: string;
model?: PhaseModelEntry;
}
const MAX_NAME_LENGTH = 50;
function generateId(): string {
return `template-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
function SortableTemplateItem({
template,
onEdit,
onToggleEnabled,
onDelete,
}: {
template: FeatureTemplate;
onEdit: () => void;
onToggleEnabled: () => void;
onDelete: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: template.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const isEnabled = template.enabled !== false;
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-3 p-3 rounded-lg border border-border/50 bg-card/50',
'transition-all duration-200',
isDragging && 'opacity-50 shadow-lg',
!isEnabled && 'opacity-60'
)}
>
{/* Drag Handle */}
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground p-1"
data-testid={`template-drag-handle-${template.id}`}
>
<GripVertical className="w-4 h-4" />
</button>
{/* Template Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="font-medium truncate">{template.name}</span>
{template.isBuiltIn && (
<span title="Built-in template">
<Lock className="w-3 h-3 text-muted-foreground" />
</span>
)}
{!isEnabled && (
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
Disabled
</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate mt-0.5">{template.prompt}</p>
</div>
{/* Actions */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit} data-testid={`template-edit-${template.id}`}>
<Pencil className="w-4 h-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={onToggleEnabled}
data-testid={`template-toggle-${template.id}`}
>
<Checkbox checked={isEnabled} className="w-4 h-4 mr-2 pointer-events-none" />
{isEnabled ? 'Disable' : 'Enable'}
</DropdownMenuItem>
{!template.isBuiltIn && (
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
data-testid={`template-delete-${template.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export function TemplatesSection({
templates,
onAddTemplate,
onUpdateTemplate,
onDeleteTemplate,
onReorderTemplates,
}: TemplatesSectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<FeatureTemplate | null>(null);
const [formData, setFormData] = useState<TemplateFormData>({
name: '',
prompt: '',
});
const [nameError, setNameError] = useState(false);
const [promptError, setPromptError] = useState(false);
const defaultFeatureModel = useAppStore((s) => s.defaultFeatureModel);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleAddNew = () => {
setEditingTemplate(null);
setFormData({
name: '',
prompt: '',
model: undefined,
});
setNameError(false);
setPromptError(false);
setDialogOpen(true);
};
const handleEdit = (template: FeatureTemplate) => {
setEditingTemplate(template);
setFormData({
name: template.name,
prompt: template.prompt,
model: template.model,
});
setNameError(false);
setPromptError(false);
setDialogOpen(true);
};
const handleToggleEnabled = async (template: FeatureTemplate) => {
await onUpdateTemplate(template.id, { enabled: template.enabled === false ? true : false });
};
const handleDelete = async (template: FeatureTemplate) => {
if (template.isBuiltIn) {
toast.error('Built-in templates cannot be deleted');
return;
}
await onDeleteTemplate(template.id);
toast.success('Template deleted');
};
const handleSave = async () => {
// Validate
let hasError = false;
if (!formData.name.trim()) {
setNameError(true);
hasError = true;
}
if (!formData.prompt.trim()) {
setPromptError(true);
hasError = true;
}
if (hasError) return;
if (editingTemplate) {
// Update existing
await onUpdateTemplate(editingTemplate.id, {
name: formData.name.trim(),
prompt: formData.prompt.trim(),
model: formData.model,
});
toast.success('Template updated');
} else {
// Create new
const newTemplate: FeatureTemplate = {
id: generateId(),
name: formData.name.trim(),
prompt: formData.prompt.trim(),
model: formData.model,
isBuiltIn: false,
enabled: true,
order: Math.max(...templates.map((t) => t.order ?? 0), -1) + 1,
};
await onAddTemplate(newTemplate);
toast.success('Template created');
}
setDialogOpen(false);
};
// Memoized sorted copy — avoids mutating the Zustand-managed templates array
const sortedTemplates = useMemo(
() => [...templates].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
[templates]
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = sortedTemplates.findIndex((t) => t.id === active.id);
const newIndex = sortedTemplates.findIndex((t) => t.id === over.id);
const reordered = arrayMove(sortedTemplates, oldIndex, newIndex);
onReorderTemplates(reordered.map((t) => t.id));
}
};
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 justify-between mb-2">
<div className="flex items-center gap-3">
<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">
<FileText className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Feature Templates
</h2>
</div>
<Button
variant="default"
size="sm"
onClick={handleAddNew}
data-testid="add-template-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Template
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Create reusable task templates for quick feature creation from the Add Feature dropdown.
</p>
</div>
<div className="p-6">
{templates.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No templates yet</p>
<p className="text-xs mt-1">Create your first template to get started</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedTemplates.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{sortedTemplates.map((template) => (
<SortableTemplateItem
key={template.id}
template={template}
onEdit={() => handleEdit(template)}
onToggleEnabled={() => handleToggleEnabled(template)}
onDelete={() => handleDelete(template)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
{/* Add/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg" data-testid="template-dialog">
<DialogHeader>
<DialogTitle>{editingTemplate ? 'Edit Template' : 'Create Template'}</DialogTitle>
<DialogDescription>
{editingTemplate
? 'Update the template details below.'
: 'Create a new template for quick feature creation.'}
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="template-name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="template-name"
value={formData.name}
onChange={(e) => {
setFormData({ ...formData, name: e.target.value });
if (e.target.value.trim()) setNameError(false);
}}
placeholder="e.g., Run tests and fix issues"
maxLength={MAX_NAME_LENGTH}
className={nameError ? 'border-destructive' : ''}
data-testid="template-name-input"
/>
<div className="flex justify-between text-xs text-muted-foreground">
{nameError && <span className="text-destructive">Name is required</span>}
<span className="ml-auto">
{formData.name.length}/{MAX_NAME_LENGTH}
</span>
</div>
</div>
{/* Prompt */}
<div className="space-y-2">
<Label htmlFor="template-prompt">
Prompt <span className="text-destructive">*</span>
</Label>
<Textarea
id="template-prompt"
value={formData.prompt}
onChange={(e) => {
setFormData({ ...formData, prompt: e.target.value });
if (e.target.value.trim()) setPromptError(false);
}}
placeholder="Describe the task the AI should perform..."
rows={4}
className={promptError ? 'border-destructive' : ''}
data-testid="template-prompt-input"
/>
{promptError && <p className="text-xs text-destructive">Prompt is required</p>}
</div>
{/* Model (optional) */}
<div className="space-y-2">
<Label htmlFor="template-model">Preferred Model (optional)</Label>
<PhaseModelSelector
value={formData.model ?? defaultFeatureModel}
onChange={(entry) => setFormData({ ...formData, model: entry })}
compact
align="end"
/>
<p className="text-xs text-muted-foreground">
If set, this model will be pre-selected when using this template.
</p>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave} data-testid="template-save-button">
{editingTemplate ? 'Save Changes' : 'Create Template'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}