mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 10:23:07 +00:00
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:
@@ -40,14 +40,12 @@ export function ProjectSwitcher() {
|
||||
const location = useLocation();
|
||||
const { hideWiki } = SIDEBAR_FEATURE_FLAGS;
|
||||
const isWikiActive = location.pathname === '/wiki';
|
||||
const {
|
||||
projects,
|
||||
currentProject,
|
||||
setCurrentProject,
|
||||
upsertAndSetCurrentProject,
|
||||
specCreatingForProject,
|
||||
setSpecCreatingForProject,
|
||||
} = useAppStore();
|
||||
const projects = useAppStore((s) => s.projects);
|
||||
const currentProject = useAppStore((s) => s.currentProject);
|
||||
const setCurrentProject = useAppStore((s) => s.setCurrentProject);
|
||||
const upsertAndSetCurrentProject = useAppStore((s) => s.upsertAndSetCurrentProject);
|
||||
const specCreatingForProject = useAppStore((s) => s.specCreatingForProject);
|
||||
const setSpecCreatingForProject = useAppStore((s) => s.setSpecCreatingForProject);
|
||||
const [contextMenuProject, setContextMenuProject] = useState<Project | null>(null);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(
|
||||
null
|
||||
@@ -104,6 +102,10 @@ export function ProjectSwitcher() {
|
||||
|
||||
const handleProjectClick = useCallback(
|
||||
async (project: Project) => {
|
||||
if (project.id === currentProject?.id) {
|
||||
navigate({ to: '/board' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Ensure .automaker directory structure exists before switching
|
||||
await initializeProject(project.path);
|
||||
@@ -124,7 +126,7 @@ export function ProjectSwitcher() {
|
||||
navigate({ to: '/board' });
|
||||
});
|
||||
},
|
||||
[setCurrentProject, navigate]
|
||||
[currentProject?.id, setCurrentProject, navigate]
|
||||
);
|
||||
|
||||
const handleNewProject = () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, startTransition } from 'react';
|
||||
import {
|
||||
Folder,
|
||||
ChevronDown,
|
||||
@@ -78,21 +78,22 @@ export function ProjectSelectorWithOptions({
|
||||
setShowDeleteProjectDialog,
|
||||
setShowRemoveFromAutomakerDialog,
|
||||
}: ProjectSelectorWithOptionsProps) {
|
||||
const {
|
||||
projects,
|
||||
currentProject,
|
||||
projectHistory,
|
||||
setCurrentProject,
|
||||
reorderProjects,
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
clearProjectHistory,
|
||||
} = useAppStore();
|
||||
const projects = useAppStore((s) => s.projects);
|
||||
const currentProject = useAppStore((s) => s.currentProject);
|
||||
const projectHistory = useAppStore((s) => s.projectHistory);
|
||||
const setCurrentProject = useAppStore((s) => s.setCurrentProject);
|
||||
const reorderProjects = useAppStore((s) => s.reorderProjects);
|
||||
const cyclePrevProject = useAppStore((s) => s.cyclePrevProject);
|
||||
const cycleNextProject = useAppStore((s) => s.cycleNextProject);
|
||||
const clearProjectHistory = useAppStore((s) => s.clearProjectHistory);
|
||||
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
// Wrap setCurrentProject to ensure .automaker is initialized before switching
|
||||
const setCurrentProjectWithInit = useCallback(
|
||||
async (p: Project) => {
|
||||
if (p.id === currentProject?.id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Ensure .automaker directory structure exists before switching
|
||||
await initializeProject(p.path);
|
||||
@@ -101,9 +102,12 @@ export function ProjectSelectorWithOptions({
|
||||
// Continue with switch even if initialization fails -
|
||||
// the project may already be initialized
|
||||
}
|
||||
setCurrentProject(p);
|
||||
// Defer project switch update to avoid synchronous render cascades.
|
||||
startTransition(() => {
|
||||
setCurrentProject(p);
|
||||
});
|
||||
},
|
||||
[setCurrentProject]
|
||||
[currentProject?.id, setCurrentProject]
|
||||
);
|
||||
|
||||
const {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, startTransition } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { ChevronsUpDown, Folder, Plus, FolderOpen, LogOut } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
@@ -6,6 +6,7 @@ import type { LucideIcon } from 'lucide-react';
|
||||
import { cn, isMac } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import { isElectron, type Project } from '@/lib/electron';
|
||||
import { initializeProject } from '@/lib/project-init';
|
||||
import { MACOS_ELECTRON_TOP_PADDING_CLASS } from '../constants';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
@@ -36,7 +37,8 @@ export function SidebarHeader({
|
||||
setShowRemoveFromAutomakerDialog,
|
||||
}: SidebarHeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
const { projects, setCurrentProject } = useAppStore();
|
||||
const projects = useAppStore((s) => s.projects);
|
||||
const setCurrentProject = useAppStore((s) => s.setCurrentProject);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const handleLogoClick = useCallback(() => {
|
||||
@@ -44,12 +46,29 @@ export function SidebarHeader({
|
||||
}, [navigate]);
|
||||
|
||||
const handleProjectSelect = useCallback(
|
||||
(project: Project) => {
|
||||
setCurrentProject(project);
|
||||
setDropdownOpen(false);
|
||||
navigate({ to: '/board' });
|
||||
async (project: Project) => {
|
||||
if (project.id === currentProject?.id) {
|
||||
setDropdownOpen(false);
|
||||
navigate({ to: '/board' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Ensure .automaker directory structure exists before switching
|
||||
await initializeProject(project.path);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize project during switch:', error);
|
||||
// Continue with switch even if initialization fails -
|
||||
// the project may already be initialized
|
||||
}
|
||||
|
||||
// Batch project switch + navigation to prevent multi-render cascades.
|
||||
startTransition(() => {
|
||||
setCurrentProject(project);
|
||||
setDropdownOpen(false);
|
||||
navigate({ to: '/board' });
|
||||
});
|
||||
},
|
||||
[setCurrentProject, navigate]
|
||||
[currentProject?.id, setCurrentProject, navigate]
|
||||
);
|
||||
|
||||
const getIconComponent = (project: Project): LucideIcon => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect, startTransition } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||
import { PanelLeftClose, ChevronDown } from 'lucide-react';
|
||||
@@ -281,6 +281,27 @@ export function Sidebar() {
|
||||
// Register keyboard shortcuts
|
||||
useKeyboardShortcuts(navigationShortcuts);
|
||||
|
||||
const switchProjectSafely = useCallback(
|
||||
async (targetProject: Project) => {
|
||||
// Ensure .automaker directory structure exists before switching
|
||||
const initResult = await initializeProject(targetProject.path);
|
||||
if (!initResult.success) {
|
||||
logger.error('Failed to initialize project during switch:', initResult.error);
|
||||
toast.warning(
|
||||
`Could not fully initialize project: ${initResult.error ?? 'Unknown error'}. Some features may not work correctly.`
|
||||
);
|
||||
// Continue with switch despite init failure — project may already be partially initialized
|
||||
}
|
||||
|
||||
// Batch project switch + navigation to prevent multi-render cascades.
|
||||
startTransition(() => {
|
||||
setCurrentProject(targetProject);
|
||||
navigate({ to: '/board' });
|
||||
});
|
||||
},
|
||||
[setCurrentProject, navigate]
|
||||
);
|
||||
|
||||
// Keyboard shortcuts for project switching (1-9, 0)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -305,15 +326,14 @@ export function Sidebar() {
|
||||
if (projectIndex !== null && projectIndex < projects.length) {
|
||||
const targetProject = projects[projectIndex];
|
||||
if (targetProject && targetProject.id !== currentProject?.id) {
|
||||
setCurrentProject(targetProject);
|
||||
navigate({ to: '/board' });
|
||||
void switchProjectSafely(targetProject);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [projects, currentProject, setCurrentProject, navigate]);
|
||||
}, [projects, currentProject, switchProjectSafely]);
|
||||
|
||||
const isActiveRoute = (id: string) => {
|
||||
const routePath = id === 'welcome' ? '/' : `/${id}`;
|
||||
|
||||
@@ -29,8 +29,13 @@ export interface UseModelOverrideResult {
|
||||
|
||||
/**
|
||||
* Normalize PhaseModelEntry or string to PhaseModelEntry
|
||||
* Handles undefined/null gracefully (e.g., when phaseModels from server settings
|
||||
* is missing a recently-added phase key)
|
||||
*/
|
||||
function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
|
||||
function normalizeEntry(entry: PhaseModelEntry | string | undefined | null): PhaseModelEntry {
|
||||
if (!entry) {
|
||||
return { model: 'claude-sonnet' as ModelId };
|
||||
}
|
||||
if (typeof entry === 'string') {
|
||||
return { model: entry as ModelId };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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's built-in system prompt as the base for all agent sessions.
|
||||
Automaker's prompts are appended on top. When disabled, only Automaker'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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -12,6 +12,7 @@ export type SettingsViewId =
|
||||
| 'copilot-provider'
|
||||
| 'mcp-servers'
|
||||
| 'prompts'
|
||||
| 'templates'
|
||||
| 'model-defaults'
|
||||
| 'appearance'
|
||||
| 'editor'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -188,6 +188,8 @@ interface BranchesResult {
|
||||
hasCommits: boolean;
|
||||
/** The name of the remote that the current branch is tracking (e.g. "origin"), if any */
|
||||
trackingRemote?: string;
|
||||
/** List of remote names that have a branch matching the current branch name */
|
||||
remotesWithBranch?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,6 +248,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem
|
||||
isGitRepo: true,
|
||||
hasCommits: true,
|
||||
trackingRemote: result.result?.trackingRemote,
|
||||
remotesWithBranch: result.result?.remotesWithBranch,
|
||||
};
|
||||
},
|
||||
enabled: !!worktreePath,
|
||||
|
||||
@@ -120,6 +120,17 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
return worktreeIsMain ? null : worktreeBranch || null;
|
||||
}, [hasWorktree, worktreeIsMain, worktreeBranch]);
|
||||
|
||||
// Use a ref for branchName inside refreshStatus to prevent the callback identity
|
||||
// from changing on every worktree switch. Without this, switching worktrees causes:
|
||||
// branchName changes → refreshStatus identity changes → useEffect fires →
|
||||
// API call → setAutoModeRunning → store update → re-render cascade → React error #185
|
||||
// On mobile Safari/PWA this cascade is especially problematic as it triggers
|
||||
// "A problem repeatedly occurred" crash loops.
|
||||
const branchNameRef = useRef(branchName);
|
||||
useEffect(() => {
|
||||
branchNameRef.current = branchName;
|
||||
}, [branchName]);
|
||||
|
||||
// Helper to look up project ID from path
|
||||
const getProjectIdFromPath = useCallback(
|
||||
(path: string): string | undefined => {
|
||||
@@ -199,6 +210,11 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// refreshStatus uses branchNameRef instead of branchName in its dependency array
|
||||
// to keep a stable callback identity across worktree switches. This prevents the
|
||||
// useEffect([refreshStatus]) from re-firing on every worktree change, which on
|
||||
// mobile Safari/PWA causes a cascading re-render that triggers "A problem
|
||||
// repeatedly occurred" crash loops.
|
||||
const refreshStatus = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
@@ -206,11 +222,15 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
// refreshStatus runs before the API call completes and overwrites optimistic state
|
||||
if (isTransitioningRef.current) return;
|
||||
|
||||
// Read branchName from ref to always use the latest value without
|
||||
// adding it to the dependency array (which would destabilize the callback).
|
||||
const currentBranchName = branchNameRef.current;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.status) return;
|
||||
|
||||
const result = await api.autoMode.status(currentProject.path, branchName);
|
||||
const result = await api.autoMode.status(currentProject.path, currentBranchName);
|
||||
if (result.success && result.isAutoLoopRunning !== undefined) {
|
||||
const backendIsRunning = result.isAutoLoopRunning;
|
||||
const backendRunningFeatures = result.runningFeatures ?? [];
|
||||
@@ -231,7 +251,9 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
backendRunningFeatures.length === 0);
|
||||
|
||||
if (needsSync) {
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
const worktreeDesc = currentBranchName
|
||||
? `worktree ${currentBranchName}`
|
||||
: 'main worktree';
|
||||
if (backendIsRunning !== currentIsRunning) {
|
||||
logger.info(
|
||||
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
|
||||
@@ -239,18 +261,18 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
}
|
||||
setAutoModeRunning(
|
||||
currentProject.id,
|
||||
branchName,
|
||||
currentBranchName,
|
||||
backendIsRunning,
|
||||
result.maxConcurrency,
|
||||
backendRunningFeatures
|
||||
);
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
|
||||
setAutoModeSessionForWorktree(currentProject.path, currentBranchName, backendIsRunning);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error syncing auto mode state with backend:', error);
|
||||
}
|
||||
}, [branchName, currentProject, setAutoModeRunning]);
|
||||
}, [currentProject, setAutoModeRunning]);
|
||||
|
||||
// On mount (and when refreshStatus identity changes, e.g. project switch),
|
||||
// query backend for current auto loop status and sync UI state.
|
||||
@@ -267,6 +289,18 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [refreshStatus]);
|
||||
|
||||
// When the user switches worktrees, re-sync auto mode status for the new branch.
|
||||
// Uses a longer debounce (300ms) than the mount effect (150ms) to let the worktree
|
||||
// switch settle (store update, feature re-filtering, query invalidation) before
|
||||
// triggering another API call. Without this delay, on mobile Safari the cascade of
|
||||
// store mutations from the worktree switch + refreshStatus response overwhelms React's
|
||||
// batching, causing "A problem repeatedly occurred" crash loops.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => void refreshStatus(), 300);
|
||||
return () => clearTimeout(timer);
|
||||
// branchName is the trigger; refreshStatus is stable (uses ref internally)
|
||||
}, [branchName, refreshStatus]);
|
||||
|
||||
// Periodic polling fallback when WebSocket events are stale.
|
||||
useEffect(() => {
|
||||
if (!currentProject) return;
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useSetupStore } from '@/store/setup-store';
|
||||
import {
|
||||
DEFAULT_OPENCODE_MODEL,
|
||||
DEFAULT_MAX_CONCURRENCY,
|
||||
DEFAULT_PHASE_MODELS,
|
||||
getAllOpencodeModelIds,
|
||||
getAllCursorModelIds,
|
||||
migrateCursorModelIds,
|
||||
@@ -184,6 +185,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
||||
state.enabledDynamicModelIds as GlobalSettings['enabledDynamicModelIds'],
|
||||
disabledProviders: (state.disabledProviders ?? []) as GlobalSettings['disabledProviders'],
|
||||
autoLoadClaudeMd: state.autoLoadClaudeMd as boolean,
|
||||
useClaudeCodeSystemPrompt: state.useClaudeCodeSystemPrompt as boolean,
|
||||
codexAutoLoadAgents: state.codexAutoLoadAgents as GlobalSettings['codexAutoLoadAgents'],
|
||||
codexSandboxMode: state.codexSandboxMode as GlobalSettings['codexSandboxMode'],
|
||||
codexApprovalPolicy: state.codexApprovalPolicy as GlobalSettings['codexApprovalPolicy'],
|
||||
@@ -756,7 +758,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
showQueryDevtools: settings.showQueryDevtools ?? true,
|
||||
enhancementModel: settings.enhancementModel ?? 'claude-sonnet',
|
||||
validationModel: settings.validationModel ?? 'claude-opus',
|
||||
phaseModels: settings.phaseModels ?? current.phaseModels,
|
||||
phaseModels: { ...DEFAULT_PHASE_MODELS, ...(settings.phaseModels ?? current.phaseModels) },
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel ?? 'none',
|
||||
defaultReasoningEffort: settings.defaultReasoningEffort ?? 'none',
|
||||
enabledCursorModels: allCursorModels, // Always use ALL cursor models
|
||||
@@ -771,6 +773,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
enableSubagents: settings.enableSubagents ?? true,
|
||||
subagentsSources: settings.subagentsSources ?? ['user', 'project'],
|
||||
autoLoadClaudeMd: settings.autoLoadClaudeMd ?? true,
|
||||
useClaudeCodeSystemPrompt: settings.useClaudeCodeSystemPrompt ?? true,
|
||||
skipSandboxWarning: settings.skipSandboxWarning ?? false,
|
||||
codexAutoLoadAgents: settings.codexAutoLoadAgents ?? false,
|
||||
codexSandboxMode: settings.codexSandboxMode ?? 'workspace-write',
|
||||
@@ -896,6 +899,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
enableSubagents: state.enableSubagents,
|
||||
subagentsSources: state.subagentsSources,
|
||||
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
||||
useClaudeCodeSystemPrompt: state.useClaudeCodeSystemPrompt,
|
||||
skipSandboxWarning: state.skipSandboxWarning,
|
||||
codexAutoLoadAgents: state.codexAutoLoadAgents,
|
||||
codexSandboxMode: state.codexSandboxMode,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_COPILOT_MODEL,
|
||||
DEFAULT_MAX_CONCURRENCY,
|
||||
DEFAULT_PHASE_MODELS,
|
||||
getAllOpencodeModelIds,
|
||||
getAllCursorModelIds,
|
||||
getAllGeminiModelIds,
|
||||
@@ -85,6 +86,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'enabledDynamicModelIds',
|
||||
'disabledProviders',
|
||||
'autoLoadClaudeMd',
|
||||
'useClaudeCodeSystemPrompt',
|
||||
'keyboardShortcuts',
|
||||
'mcpServers',
|
||||
'defaultEditorCommand',
|
||||
@@ -100,6 +102,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'subagentsSources',
|
||||
'promptCustomization',
|
||||
'eventHooks',
|
||||
'featureTemplates',
|
||||
'claudeCompatibleProviders',
|
||||
'claudeApiProfiles',
|
||||
'activeClaudeApiProfileId',
|
||||
@@ -727,6 +730,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
serverSettings.phaseModels.memoryExtractionModel
|
||||
),
|
||||
commitMessageModel: migratePhaseModelEntry(serverSettings.phaseModels.commitMessageModel),
|
||||
prDescriptionModel: migratePhaseModelEntry(serverSettings.phaseModels.prDescriptionModel),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -785,7 +789,10 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
|
||||
enhancementModel: serverSettings.enhancementModel,
|
||||
validationModel: serverSettings.validationModel,
|
||||
phaseModels: migratedPhaseModels ?? serverSettings.phaseModels,
|
||||
phaseModels: {
|
||||
...DEFAULT_PHASE_MODELS,
|
||||
...(migratedPhaseModels ?? serverSettings.phaseModels),
|
||||
},
|
||||
enabledCursorModels: allCursorModels, // Always use ALL cursor models
|
||||
cursorDefaultModel: sanitizedCursorDefault,
|
||||
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
|
||||
@@ -797,6 +804,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||
disabledProviders: serverSettings.disabledProviders ?? [],
|
||||
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? true,
|
||||
useClaudeCodeSystemPrompt: serverSettings.useClaudeCodeSystemPrompt ?? true,
|
||||
keyboardShortcuts: {
|
||||
...currentAppState.keyboardShortcuts,
|
||||
...(serverSettings.keyboardShortcuts as unknown as Partial<
|
||||
@@ -836,6 +844,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
recentFolders: serverSettings.recentFolders ?? [],
|
||||
// Event hooks
|
||||
eventHooks: serverSettings.eventHooks ?? [],
|
||||
// Feature templates
|
||||
featureTemplates: serverSettings.featureTemplates ?? [],
|
||||
// Codex CLI Settings
|
||||
codexAutoLoadAgents: serverSettings.codexAutoLoadAgents ?? false,
|
||||
codexSandboxMode: serverSettings.codexSandboxMode ?? 'workspace-write',
|
||||
|
||||
@@ -779,7 +779,8 @@ export interface ElectronAPI {
|
||||
generate: (
|
||||
projectPath: string,
|
||||
prompt: string,
|
||||
model?: string
|
||||
model?: string,
|
||||
branchName?: string
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
stop: () => Promise<{ success: boolean; error?: string }>;
|
||||
status: (projectPath: string) => Promise<{
|
||||
|
||||
@@ -2899,9 +2899,10 @@ export class HttpApiClient implements ElectronAPI {
|
||||
generate: (
|
||||
projectPath: string,
|
||||
prompt: string,
|
||||
model?: string
|
||||
model?: string,
|
||||
branchName?: string
|
||||
): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post('/api/backlog-plan/generate', { projectPath, prompt, model }),
|
||||
this.post('/api/backlog-plan/generate', { projectPath, prompt, model, branchName }),
|
||||
|
||||
stop: (): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post('/api/backlog-plan/stop', {}),
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
ServerLogLevel,
|
||||
ParsedTask,
|
||||
PlanSpec,
|
||||
FeatureTemplate,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
getAllCursorModelIds,
|
||||
@@ -341,6 +342,7 @@ const initialState: AppState = {
|
||||
copilotDefaultModel: DEFAULT_COPILOT_MODEL,
|
||||
disabledProviders: [],
|
||||
autoLoadClaudeMd: false,
|
||||
useClaudeCodeSystemPrompt: true,
|
||||
skipSandboxWarning: false,
|
||||
mcpServers: [],
|
||||
defaultEditorCommand: null,
|
||||
@@ -355,6 +357,7 @@ const initialState: AppState = {
|
||||
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>,
|
||||
promptCustomization: {},
|
||||
eventHooks: [],
|
||||
featureTemplates: DEFAULT_GLOBAL_SETTINGS.featureTemplates ?? [],
|
||||
claudeCompatibleProviders: [],
|
||||
claudeApiProfiles: [],
|
||||
activeClaudeApiProfileId: null,
|
||||
@@ -1393,6 +1396,15 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
logger.error('Failed to sync autoLoadClaudeMd:', error);
|
||||
}
|
||||
},
|
||||
setUseClaudeCodeSystemPrompt: async (enabled) => {
|
||||
set({ useClaudeCodeSystemPrompt: enabled });
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.settings.updateGlobal({ useClaudeCodeSystemPrompt: enabled });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync useClaudeCodeSystemPrompt:', error);
|
||||
}
|
||||
},
|
||||
setSkipSandboxWarning: async (skip) => {
|
||||
set({ skipSandboxWarning: skip });
|
||||
try {
|
||||
@@ -1437,6 +1449,69 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Feature Template actions
|
||||
setFeatureTemplates: async (templates) => {
|
||||
set({ featureTemplates: templates });
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.settings.updateGlobal({ featureTemplates: templates });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync feature templates:', error);
|
||||
}
|
||||
},
|
||||
addFeatureTemplate: async (template) => {
|
||||
set((state) => ({
|
||||
featureTemplates: [...state.featureTemplates, template],
|
||||
}));
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.settings.updateGlobal({ featureTemplates: get().featureTemplates });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync feature templates:', error);
|
||||
}
|
||||
},
|
||||
updateFeatureTemplate: async (id, updates) => {
|
||||
set((state) => ({
|
||||
featureTemplates: state.featureTemplates.map((t) => (t.id === id ? { ...t, ...updates } : t)),
|
||||
}));
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.settings.updateGlobal({ featureTemplates: get().featureTemplates });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync feature templates:', error);
|
||||
}
|
||||
},
|
||||
deleteFeatureTemplate: async (id) => {
|
||||
set((state) => ({
|
||||
featureTemplates: state.featureTemplates.filter((t) => t.id !== id),
|
||||
}));
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.settings.updateGlobal({ featureTemplates: get().featureTemplates });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync feature templates:', error);
|
||||
}
|
||||
},
|
||||
reorderFeatureTemplates: async (templateIds) => {
|
||||
set((state) => {
|
||||
const templateMap = new Map(state.featureTemplates.map((t) => [t.id, t]));
|
||||
const reordered: FeatureTemplate[] = [];
|
||||
templateIds.forEach((id, index) => {
|
||||
const template = templateMap.get(id);
|
||||
if (template) {
|
||||
reordered.push({ ...template, order: index });
|
||||
}
|
||||
});
|
||||
return { featureTemplates: reordered };
|
||||
});
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.settings.updateGlobal({ featureTemplates: get().featureTemplates });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync feature templates:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Claude-Compatible Provider actions (new system)
|
||||
addClaudeCompatibleProvider: async (provider) => {
|
||||
set((state) => ({
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
SidebarStyle,
|
||||
ThinkingLevel,
|
||||
ReasoningEffort,
|
||||
FeatureTemplate,
|
||||
} from '@automaker/types';
|
||||
|
||||
import type {
|
||||
@@ -234,6 +235,7 @@ export interface AppState {
|
||||
|
||||
// Claude Agent SDK Settings
|
||||
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
||||
useClaudeCodeSystemPrompt: boolean; // Use Claude Code's built-in system prompt as the base
|
||||
skipSandboxWarning: boolean; // Skip the sandbox environment warning dialog on startup
|
||||
|
||||
// MCP Servers
|
||||
@@ -265,6 +267,9 @@ export interface AppState {
|
||||
// Event Hooks
|
||||
eventHooks: EventHook[]; // Event hooks for custom commands or webhooks
|
||||
|
||||
// Feature Templates
|
||||
featureTemplates: FeatureTemplate[]; // Feature templates for quick task creation
|
||||
|
||||
// Claude-Compatible Providers (new system)
|
||||
claudeCompatibleProviders: ClaudeCompatibleProvider[]; // Providers that expose models to dropdowns
|
||||
|
||||
@@ -620,6 +625,7 @@ export interface AppActions {
|
||||
|
||||
// Claude Agent SDK Settings actions
|
||||
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
||||
setUseClaudeCodeSystemPrompt: (enabled: boolean) => Promise<void>;
|
||||
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
|
||||
|
||||
// Editor Configuration actions
|
||||
@@ -640,6 +646,13 @@ export interface AppActions {
|
||||
// Event Hook actions
|
||||
setEventHooks: (hooks: EventHook[]) => Promise<void>;
|
||||
|
||||
// Feature Template actions
|
||||
setFeatureTemplates: (templates: FeatureTemplate[]) => Promise<void>;
|
||||
addFeatureTemplate: (template: FeatureTemplate) => Promise<void>;
|
||||
updateFeatureTemplate: (id: string, updates: Partial<FeatureTemplate>) => Promise<void>;
|
||||
deleteFeatureTemplate: (id: string) => Promise<void>;
|
||||
reorderFeatureTemplates: (templateIds: string[]) => Promise<void>;
|
||||
|
||||
// Claude-Compatible Provider actions (new system)
|
||||
addClaudeCompatibleProvider: (provider: ClaudeCompatibleProvider) => Promise<void>;
|
||||
updateClaudeCompatibleProvider: (
|
||||
|
||||
Reference in New Issue
Block a user