Add orphaned features management routes and UI integration (#819)

* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* refactor(auto-mode): enhance orphaned feature detection and improve project initialization

- Updated detectOrphanedFeatures method to accept preloaded features, reducing redundant disk reads.
- Improved project initialization by creating required directories and files in parallel for better performance.
- Adjusted planning mode handling in UI components to clarify approval requirements for different modes.
- Added refresh functionality for file editor tabs to ensure content consistency with disk state.

These changes enhance performance, maintainability, and user experience across the application.

* feat(orphaned-features): add orphaned features management routes and UI integration

- Introduced new routes for managing orphaned features, including listing, resolving, and bulk resolving.
- Updated the UI to include an Orphaned Features section in project settings and navigation.
- Enhanced the execution service to support new orphaned feature functionalities.

These changes improve the application's capability to handle orphaned features effectively, enhancing user experience and project management.

* fix: Normalize line endings and resolve stale dirty states in file editor

* chore: Update .gitignore and enhance orphaned feature handling

- Added a blank line in .gitignore for better readability.
- Introduced a hash to worktree paths in orphaned feature resolution to prevent conflicts.
- Added validation for target branch existence during orphaned feature resolution.
- Improved prompt formatting in execution service for clarity.
- Enhanced error handling in project selector for project initialization failures.
- Refactored orphaned features section to improve state management and UI responsiveness.

These changes improve code maintainability and user experience when managing orphaned features.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
gsxdsm
2026-02-27 22:14:41 -08:00
committed by GitHub
parent 0196911d59
commit 1c0e460dd1
36 changed files with 2048 additions and 406 deletions

View File

@@ -297,9 +297,9 @@ export function AddFeatureDialog({
prefilledCategory,
]);
// Clear requirePlanApproval when planning mode is skip or lite
// Clear requirePlanApproval when planning mode is skip (lite supports approval)
useEffect(() => {
if (planningMode === 'skip' || planningMode === 'lite') {
if (planningMode === 'skip') {
setRequirePlanApproval(false);
}
}, [planningMode]);
@@ -634,14 +634,14 @@ export function AddFeatureDialog({
id="add-feature-require-approval"
checked={requirePlanApproval}
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
disabled={planningMode === 'skip' || planningMode === 'lite'}
disabled={planningMode === 'skip'}
data-testid="add-feature-planning-require-approval-checkbox"
/>
<Label
htmlFor="add-feature-require-approval"
className={cn(
'text-xs font-normal',
planningMode === 'skip' || planningMode === 'lite'
planningMode === 'skip'
? 'cursor-not-allowed text-muted-foreground'
: 'cursor-pointer'
)}

View File

@@ -24,9 +24,9 @@ import {
import { getFirstNonEmptySummary } from '@/lib/summary-selection';
import { useAgentOutput, useFeature } from '@/hooks/queries';
import { cn } from '@/lib/utils';
import { MODAL_CONSTANTS } from '@/components/views/board-view/dialogs/agent-output-modal.constants';
import type { AutoModeEvent } from '@/types/electron';
import type { BacklogPlanEvent } from '@automaker/types';
import { MODAL_CONSTANTS } from './agent-output-modal.constants';
interface AgentOutputModalProps {
open: boolean;
@@ -43,7 +43,7 @@ interface AgentOutputModalProps {
branchName?: string;
}
type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes';
type ViewMode = (typeof MODAL_CONSTANTS.VIEW_MODES)[keyof typeof MODAL_CONSTANTS.VIEW_MODES];
/**
* Renders a single phase entry card with header and content.
@@ -164,11 +164,11 @@ export function AgentOutputModal({
const isBacklogPlan = featureId.startsWith('backlog-plan:');
// Resolve project path - prefer prop, fallback to window.__currentProject
const resolvedProjectPath = projectPathProp || window.__currentProject?.path || '';
const resolvedProjectPath = projectPathProp || window.__currentProject?.path || undefined;
// Track additional content from WebSocket events (appended to query data)
const [streamedContent, setStreamedContent] = useState<string>('');
// Track view mode state
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
const [streamedContent, setStreamedContent] = useState<string>('');
// Use React Query for initial output loading
const {
@@ -221,7 +221,8 @@ export function AgentOutputModal({
}, [normalizedSummary]);
// Determine the effective view mode - default to summary if available, otherwise parsed
const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed');
const effectiveViewMode =
viewMode ?? (summary ? MODAL_CONSTANTS.VIEW_MODES.SUMMARY : MODAL_CONSTANTS.VIEW_MODES.PARSED);
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const useWorktrees = useAppStore((state) => state.useWorktrees);
@@ -486,7 +487,8 @@ export function AgentOutputModal({
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
const isAtBottom =
scrollHeight - scrollTop - clientHeight < MODAL_CONSTANTS.AUTOSCROLL_THRESHOLD;
autoScrollRef.current = isAtBottom;
};
@@ -511,7 +513,7 @@ export function AgentOutputModal({
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
className="w-full max-h-[85dvh] max-w-[calc(100%-2rem)] sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] rounded-xl flex flex-col"
className="w-full max-h-[85dvh] max-w-[calc(100%-2rem)] sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] md:w-[90vw] md:max-w-[1200px] md:max-h-[85vh] rounded-xl flex flex-col"
data-testid="agent-output-modal"
>
<DialogHeader className="shrink-0">
@@ -593,7 +595,9 @@ export function AgentOutputModal({
)}
{effectiveViewMode === 'changes' ? (
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
<div
className={`flex-1 min-h-0 ${MODAL_CONSTANTS.COMPONENT_HEIGHTS.SMALL_MIN} ${MODAL_CONSTANTS.COMPONENT_HEIGHTS.SMALL_MAX} overflow-y-auto scrollbar-visible`}
>
{resolvedProjectPath ? (
<GitDiffPanel
projectPath={resolvedProjectPath}
@@ -658,7 +662,7 @@ export function AgentOutputModal({
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto bg-popover border border-border/50 rounded-lg p-4 font-mono text-xs scrollbar-visible"
className={`flex-1 min-h-0 ${MODAL_CONSTANTS.COMPONENT_HEIGHTS.SMALL_MIN} ${MODAL_CONSTANTS.COMPONENT_HEIGHTS.SMALL_MAX} overflow-y-auto bg-popover border border-border/50 rounded-lg p-4 font-mono text-xs scrollbar-visible`}
>
{isLoading && !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">

View File

@@ -192,9 +192,9 @@ export function EditFeatureDialog({
}
}, [feature, allFeatures]);
// Clear requirePlanApproval when planning mode is skip or lite
// Clear requirePlanApproval when planning mode is skip (lite supports approval)
useEffect(() => {
if (planningMode === 'skip' || planningMode === 'lite') {
if (planningMode === 'skip') {
setRequirePlanApproval(false);
}
}, [planningMode]);
@@ -485,14 +485,14 @@ export function EditFeatureDialog({
id="edit-feature-require-approval"
checked={requirePlanApproval}
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
disabled={planningMode === 'skip' || planningMode === 'lite'}
disabled={planningMode === 'skip'}
data-testid="edit-feature-require-approval-checkbox"
/>
<Label
htmlFor="edit-feature-require-approval"
className={cn(
'text-xs font-normal',
planningMode === 'skip' || planningMode === 'lite'
planningMode === 'skip'
? 'cursor-not-allowed text-muted-foreground'
: 'cursor-pointer'
)}

View File

@@ -199,9 +199,9 @@ export function MassEditDialog({
}
}, [open, selectedFeatures]);
// Clear requirePlanApproval when planning mode is skip or lite
// Clear requirePlanApproval when planning mode is skip (lite supports approval)
useEffect(() => {
if (planningMode === 'skip' || planningMode === 'lite') {
if (planningMode === 'skip') {
setRequirePlanApproval(false);
}
}, [planningMode]);

View File

@@ -87,8 +87,8 @@ export function PlanningModeSelect({
}: PlanningModeSelectProps) {
const selectedMode = modes.find((m) => m.value === mode);
// Disable approval checkbox for skip/lite modes since they don't use planning
const isApprovalDisabled = disabled || mode === 'skip' || mode === 'lite';
// Disable approval checkbox for skip mode (lite supports approval)
const isApprovalDisabled = disabled || mode === 'skip';
const selectDropdown = (
<Select

View File

@@ -25,9 +25,18 @@ interface EditorTabsProps {
/** Get a file icon color based on extension */
function getFileColor(fileName: string): string {
const dotIndex = fileName.lastIndexOf('.');
// Files without an extension (no dot, or dotfile with dot at position 0)
const ext = dotIndex > 0 ? fileName.slice(dotIndex + 1).toLowerCase() : '';
const name = fileName.toLowerCase();
// Handle dotfiles and extensionless files by name first
if (name.startsWith('.env')) return 'text-yellow-600';
if (name === 'dockerfile' || name.startsWith('dockerfile.')) return 'text-blue-300';
if (name === 'makefile' || name === 'gnumakefile') return 'text-orange-300';
if (name === '.gitignore' || name === '.dockerignore' || name === '.npmignore')
return 'text-gray-400';
const dotIndex = name.lastIndexOf('.');
const ext = dotIndex > 0 ? name.slice(dotIndex + 1) : '';
switch (ext) {
case 'ts':
case 'tsx':
@@ -71,7 +80,9 @@ function getFileColor(fileName: string): string {
case 'zsh':
return 'text-green-300';
default:
return 'text-muted-foreground';
// Very faint dot for unknown file types so it's not confused
// with the filled dirty-indicator dot
return 'text-muted-foreground/30';
}
}

View File

@@ -1,5 +1,15 @@
/**
* Normalize line endings to `\n` so that comparisons match CodeMirror's
* internal representation. CodeMirror always converts `\r\n` and `\r` to
* `\n`, so raw disk content with Windows/old-Mac line endings would
* otherwise cause a false dirty state.
*/
export function normalizeLineEndings(text: string): string {
return text.indexOf('\r') !== -1 ? text.replace(/\r\n?/g, '\n') : text;
}
export function computeIsDirty(content: string, originalContent: string): boolean {
return content !== originalContent;
return normalizeLineEndings(content) !== normalizeLineEndings(originalContent);
}
export function updateTabWithContent<

View File

@@ -37,6 +37,7 @@ import {
type FileTreeNode,
type EnhancedGitFileStatus,
} from './use-file-editor-store';
import { normalizeLineEndings } from './file-editor-dirty-utils';
import { FileTree } from './components/file-tree';
import { CodeEditor, getLanguageName, type CodeEditorHandle } from './components/code-editor';
import { EditorTabs } from './components/editor-tabs';
@@ -169,6 +170,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
closeAllTabs,
setActiveTab,
markTabSaved,
refreshTabContent,
setMarkdownViewMode,
setMobileBrowserVisible,
activeFileGitDetails,
@@ -360,6 +362,30 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
const existing = tabs.find((t) => t.filePath === filePath);
if (existing) {
setActiveTab(existing.id);
// If the tab is showing as dirty, re-read from disk to verify that the
// stored content actually differs from what is on disk. This fixes stale
// isDirty=true state that can be persisted to localStorage (e.g. the file
// was saved externally, or the tab schema changed).
// We only do this when the tab IS dirty to avoid a race condition where a
// concurrent save clears isDirty and then our stale disk read would wrongly
// set it back to true.
if (!existing.isBinary && !existing.isTooLarge && existing.isDirty) {
try {
const api = getElectronAPI();
const result = await api.readFile(filePath);
if (result.success && result.content !== undefined && !result.content.includes('\0')) {
// Re-check isDirty after the async read: a concurrent save may have
// already cleared it. Only refresh if the tab is still dirty.
const { tabs: currentTabs } = useFileEditorStore.getState();
const currentTab = currentTabs.find((t) => t.id === existing.id);
if (currentTab?.isDirty) {
refreshTabContent(existing.id, result.content);
}
}
} catch {
// Non-critical: if we can't re-read the file, keep the persisted state
}
}
return;
}
@@ -428,11 +454,15 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
return;
}
// Normalize line endings to match CodeMirror's internal representation
// (\r\n → \n). This prevents a false dirty state when CodeMirror reports
// its already-normalized content back via onChange.
const normalizedContent = normalizeLineEndings(result.content);
openTab({
filePath,
fileName,
content: result.content,
originalContent: result.content,
content: normalizedContent,
originalContent: normalizedContent,
isDirty: false,
scrollTop: 0,
cursorLine: 1,
@@ -446,7 +476,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
logger.error('Failed to open file:', error);
}
},
[tabs, setActiveTab, openTab, maxFileSize]
[tabs, setActiveTab, openTab, refreshTabContent, maxFileSize]
);
// ─── Mobile-aware file select ────────────────────────────────
@@ -703,6 +733,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
model: string;
thinkingLevel: string;
reasoningEffort: string;
providerId?: string;
skipTests: boolean;
branchName: string;
planningMode: string;
@@ -1204,6 +1235,37 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
};
}, [effectivePath, loadTree, loadGitStatus]);
// ─── Refresh persisted tabs from disk ──────────────────────
// After mount, re-read all persisted (non-binary, non-large) tabs from disk
// to sync originalContent with the actual file state. This clears stale
// isDirty flags caused by external file changes or serialization artifacts.
const hasRefreshedTabsRef = useRef(false);
useEffect(() => {
if (!effectivePath || hasRefreshedTabsRef.current) return;
const { tabs: currentTabs, refreshTabContent: refresh } = useFileEditorStore.getState();
if (currentTabs.length === 0) return;
hasRefreshedTabsRef.current = true;
const refreshAll = async () => {
const api = getElectronAPI();
for (const tab of currentTabs) {
if (tab.isBinary || tab.isTooLarge) continue;
try {
const result = await api.readFile(tab.filePath);
if (result.success && result.content !== undefined && !result.content.includes('\0')) {
refresh(tab.id, result.content);
}
} catch {
// File may no longer exist — leave tab state as-is
}
}
};
refreshAll();
}, [effectivePath]);
// Open initial path if provided
useEffect(() => {
if (initialPath) {

View File

@@ -1,6 +1,10 @@
import { create } from 'zustand';
import { persist, type StorageValue } from 'zustand/middleware';
import { updateTabWithContent, markTabAsSaved } from './file-editor-dirty-utils';
import {
updateTabWithContent,
markTabAsSaved,
normalizeLineEndings,
} from './file-editor-dirty-utils';
export interface FileTreeNode {
name: string;
@@ -128,6 +132,8 @@ interface FileEditorState {
markTabSaved: (tabId: string, content: string) => void;
updateTabScroll: (tabId: string, scrollTop: number) => void;
updateTabCursor: (tabId: string, line: number, col: number) => void;
/** Re-sync an existing tab's originalContent and isDirty state from freshly-read disk content */
refreshTabContent: (tabId: string, diskContent: string) => void;
setMarkdownViewMode: (mode: MarkdownViewMode) => void;
@@ -273,6 +279,24 @@ export const useFileEditorStore = create<FileEditorState>()(
});
},
refreshTabContent: (tabId, diskContent) => {
set({
tabs: get().tabs.map((t) => {
if (t.id !== tabId) return t;
// Normalize line endings so the baseline matches CodeMirror's
// internal representation (\r\n → \n). Without this, files with
// Windows line endings would always appear dirty.
const normalizedDisk = normalizeLineEndings(diskContent);
// If the editor content matches the freshly-read disk content, the file
// is clean (any previous isDirty was a stale persisted value).
// Otherwise keep the user's in-progress edits but update originalContent
// so isDirty is calculated against the actual on-disk baseline.
const isDirty = normalizeLineEndings(t.content) !== normalizedDisk;
return { ...t, originalContent: normalizedDisk, isDirty };
}),
});
},
updateTabScroll: (tabId, scrollTop) => {
set({
tabs: get().tabs.map((t) => (t.id === tabId ? { ...t, scrollTop } : t)),
@@ -321,7 +345,7 @@ export const useFileEditorStore = create<FileEditorState>()(
}),
{
name: STORE_NAME,
version: 1,
version: 2,
// Only persist tab session state, not transient data (git status, file tree, drag state)
partialize: (state) =>
({
@@ -338,11 +362,30 @@ export const useFileEditorStore = create<FileEditorState>()(
try {
const parsed = JSON.parse(raw) as StorageValue<PersistedFileEditorState>;
if (!parsed?.state) return null;
// Normalize tabs: ensure originalContent is always a string. Tabs persisted
// before originalContent was added to the schema have originalContent=undefined,
// which causes isDirty=true on any content comparison. Default to content so
// the tab starts in a clean state.
// Also recalculate isDirty from content vs originalContent rather than trusting
// the persisted value, which can become stale (e.g. file saved externally,
// CodeMirror normalization, or schema migration).
const normalizedTabs = (parsed.state.tabs ?? []).map((tab) => {
const originalContent = normalizeLineEndings(
tab.originalContent ?? tab.content ?? ''
);
const content = tab.content ?? '';
return {
...tab,
originalContent,
isDirty: normalizeLineEndings(content) !== originalContent,
};
});
// Convert arrays back to Sets
return {
...parsed,
state: {
...parsed.state,
tabs: normalizedTabs,
expandedFolders: new Set(parsed.state.expandedFolders ?? []),
},
} as unknown as StorageValue<FileEditorState>;
@@ -385,6 +428,22 @@ export const useFileEditorStore = create<FileEditorState>()(
state.expandedFolders = state.expandedFolders ?? new Set<string>();
state.markdownViewMode = state.markdownViewMode ?? 'split';
}
// Always ensure each tab has a valid originalContent field.
// Tabs persisted before originalContent was added to the schema would have
// originalContent=undefined, which causes isDirty=true on any onChange call
// (content !== undefined is always true). Fix by defaulting to content so the
// tab starts in a clean state; any genuine unsaved changes will be re-detected
// when the user next edits the file.
if (Array.isArray((state as Record<string, unknown>).tabs)) {
(state as Record<string, unknown>).tabs = (
(state as Record<string, unknown>).tabs as Array<Record<string, unknown>>
).map((tab: Record<string, unknown>) => {
if (tab.originalContent === undefined || tab.originalContent === null) {
return { ...tab, originalContent: tab.content ?? '' };
}
return tab;
});
}
return state as unknown as FileEditorState;
},
}

View File

@@ -7,6 +7,7 @@ import {
Workflow,
Database,
Terminal,
Unlink,
} from 'lucide-react';
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
@@ -23,5 +24,6 @@ export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
{ id: 'theme', label: 'Theme', icon: Palette },
{ id: 'claude', label: 'Models', icon: Workflow },
{ id: 'data', label: 'Data', icon: Database },
{ id: 'orphaned', label: 'Orphaned Features', icon: Unlink },
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
];

View File

@@ -9,6 +9,7 @@ export type ProjectSettingsViewId =
| 'commands-scripts'
| 'claude'
| 'data'
| 'orphaned'
| 'danger';
interface UseProjectSettingsViewOptions {

View File

@@ -0,0 +1,658 @@
import { useState, useCallback, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Unlink,
Search,
Trash2,
GitBranch,
ArrowRight,
AlertTriangle,
CheckSquare,
MinusSquare,
Square,
} from 'lucide-react';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import type { Project } from '@/lib/electron';
import type { Feature } from '@automaker/types';
interface OrphanedFeatureInfo {
feature: Feature;
missingBranch: string;
}
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
isCurrent: boolean;
hasWorktree: boolean;
}
interface OrphanedFeaturesSectionProps {
project: Project;
}
export function OrphanedFeaturesSection({ project }: OrphanedFeaturesSectionProps) {
const [scanning, setScanning] = useState(false);
const [scanned, setScanned] = useState(false);
const [orphanedFeatures, setOrphanedFeatures] = useState<OrphanedFeatureInfo[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [resolvingIds, setResolvingIds] = useState<Set<string>>(new Set());
const [deleteConfirm, setDeleteConfirm] = useState<{
featureIds: string[];
labels: string[];
} | null>(null);
const [moveDialog, setMoveDialog] = useState<{
featureIds: string[];
labels: string[];
} | null>(null);
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
const [selectedBranch, setSelectedBranch] = useState<string>('__main__');
const [loadingWorktrees, setLoadingWorktrees] = useState(false);
const allSelected = orphanedFeatures.length > 0 && selectedIds.size === orphanedFeatures.length;
const someSelected = selectedIds.size > 0 && selectedIds.size < orphanedFeatures.length;
const hasSelection = selectedIds.size > 0;
const selectedLabels = useMemo(() => {
return orphanedFeatures
.filter((o) => selectedIds.has(o.feature.id))
.map((o) => o.feature.title || o.feature.description?.slice(0, 60) || o.feature.id);
}, [orphanedFeatures, selectedIds]);
const toggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const toggleSelectAll = useCallback(() => {
if (allSelected) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(orphanedFeatures.map((o) => o.feature.id)));
}
}, [allSelected, orphanedFeatures]);
const scanForOrphans = useCallback(async () => {
setScanning(true);
setSelectedIds(new Set());
try {
const api = getHttpApiClient();
const result = await api.features.getOrphaned(project.path);
if (result.success && result.orphanedFeatures) {
setOrphanedFeatures(result.orphanedFeatures);
setScanned(true);
if (result.orphanedFeatures.length === 0) {
toast.success('No orphaned features found');
} else {
toast.info(`Found ${result.orphanedFeatures.length} orphaned feature(s)`);
}
} else {
toast.error('Failed to scan for orphaned features', {
description: result.error,
});
}
} catch (error) {
toast.error('Failed to scan for orphaned features', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setScanning(false);
}
}, [project.path]);
const loadWorktrees = useCallback(async () => {
setLoadingWorktrees(true);
try {
const api = getHttpApiClient();
const result = await api.worktree.listAll(project.path);
if (result.success && result.worktrees) {
setWorktrees(result.worktrees);
}
} catch {
// Non-fatal
} finally {
setLoadingWorktrees(false);
}
}, [project.path]);
const resolveOrphan = useCallback(
async (
featureId: string,
action: 'delete' | 'create-worktree' | 'move-to-branch',
targetBranch?: string | null
) => {
setResolvingIds((prev) => new Set(prev).add(featureId));
try {
const api = getHttpApiClient();
const result = await api.features.resolveOrphaned(
project.path,
featureId,
action,
targetBranch
);
if (result.success) {
setOrphanedFeatures((prev) => prev.filter((o) => o.feature.id !== featureId));
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(featureId);
return next;
});
const messages: Record<string, string> = {
deleted: 'Feature deleted',
'worktree-created': 'Worktree created successfully',
moved: 'Feature moved successfully',
};
toast.success(messages[result.action ?? action] ?? 'Resolved');
} else {
toast.error('Failed to resolve orphaned feature', {
description: result.error,
});
}
} catch (error) {
toast.error('Failed to resolve orphaned feature', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setResolvingIds((prev) => {
const next = new Set(prev);
next.delete(featureId);
return next;
});
}
},
[project.path]
);
const bulkResolve = useCallback(
async (
featureIds: string[],
action: 'delete' | 'create-worktree' | 'move-to-branch',
targetBranch?: string | null
) => {
const ids = new Set(featureIds);
setResolvingIds((prev) => new Set([...prev, ...ids]));
try {
const api = getHttpApiClient();
const result = await api.features.bulkResolveOrphaned(
project.path,
featureIds,
action,
targetBranch
);
if (result.success || (result.resolvedCount && result.resolvedCount > 0)) {
const resolvedIds = new Set(
result.results?.filter((r) => r.success).map((r) => r.featureId) ?? featureIds
);
setOrphanedFeatures((prev) => prev.filter((o) => !resolvedIds.has(o.feature.id)));
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of resolvedIds) {
next.delete(id);
}
return next;
});
const actionLabel =
action === 'delete'
? 'deleted'
: action === 'create-worktree'
? 'moved to worktrees'
: 'moved';
if (result.failedCount && result.failedCount > 0) {
toast.warning(
`${result.resolvedCount} feature(s) ${actionLabel}, ${result.failedCount} failed`
);
} else {
toast.success(`${result.resolvedCount} feature(s) ${actionLabel}`);
}
} else {
toast.error('Failed to resolve orphaned features', {
description: result.error,
});
}
} catch (error) {
toast.error('Failed to resolve orphaned features', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setResolvingIds((prev) => {
const next = new Set(prev);
for (const id of featureIds) {
next.delete(id);
}
return next;
});
setDeleteConfirm(null);
setMoveDialog(null);
}
},
[project.path]
);
const openMoveDialog = useCallback(
async (featureIds: string[], labels: string[]) => {
setMoveDialog({ featureIds, labels });
setSelectedBranch('__main__');
await loadWorktrees();
},
[loadWorktrees]
);
const handleMoveConfirm = useCallback(() => {
if (!moveDialog) return;
const targetBranch = selectedBranch === '__main__' ? null : selectedBranch;
if (moveDialog.featureIds.length === 1) {
resolveOrphan(moveDialog.featureIds[0], 'move-to-branch', targetBranch);
} else {
bulkResolve(moveDialog.featureIds, 'move-to-branch', targetBranch);
}
setMoveDialog(null);
}, [moveDialog, selectedBranch, resolveOrphan, bulkResolve]);
const isBulkResolving = resolvingIds.size > 0;
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'
)}
>
{/* Header */}
<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-amber-500/20 to-amber-600/10 flex items-center justify-center border border-amber-500/20">
<Unlink className="w-5 h-5 text-amber-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Orphaned Features
</h2>
{scanned && orphanedFeatures.length > 0 && (
<span className="ml-auto inline-flex items-center rounded-full bg-amber-500/15 px-2.5 py-0.5 text-xs font-medium text-amber-500 border border-amber-500/25">
{orphanedFeatures.length} found
</span>
)}
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Detect features whose git branches no longer exist. You can delete them, create a new
worktree, or move them to an existing branch.
</p>
</div>
<div className="p-6 space-y-6">
{/* Scan Button */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-medium text-foreground">Scan for Orphaned Features</h3>
<p className="text-xs text-muted-foreground mt-1">
Check all features for missing git branches.
</p>
</div>
<Button
variant="outline"
onClick={scanForOrphans}
loading={scanning}
className="gap-2"
data-testid="scan-orphaned-features-button"
>
<Search className="w-4 h-4" />
{scanning ? 'Scanning...' : scanned ? 'Rescan' : 'Scan for Orphans'}
</Button>
</div>
{/* Results */}
{scanned && (
<>
<div className="border-t border-border/50" />
{orphanedFeatures.length === 0 ? (
<div className="text-center py-6">
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-emerald-500/10 flex items-center justify-center">
<GitBranch className="w-6 h-6 text-emerald-500" />
</div>
<p className="text-sm font-medium text-foreground">All clear</p>
<p className="text-xs text-muted-foreground mt-1">
No orphaned features detected.
</p>
</div>
) : (
<div className="space-y-3">
{/* Selection toolbar */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3">
<button
onClick={toggleSelectAll}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
data-testid="select-all-orphans"
>
{allSelected ? (
<CheckSquare className="w-4 h-4 text-brand-500" />
) : someSelected ? (
<MinusSquare className="w-4 h-4 text-brand-500" />
) : (
<Square className="w-4 h-4" />
)}
<span>
{allSelected ? 'Deselect all' : `Select all (${orphanedFeatures.length})`}
</span>
</button>
{hasSelection && (
<span className="text-xs text-muted-foreground">
{selectedIds.size} selected
</span>
)}
</div>
{/* Bulk actions */}
{hasSelection && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
const ids = Array.from(selectedIds);
bulkResolve(ids, 'create-worktree');
}}
disabled={isBulkResolving}
className="gap-1.5 text-xs"
data-testid="bulk-create-worktree"
>
<GitBranch className="w-3.5 h-3.5" />
Create Worktrees ({selectedIds.size})
</Button>
<Button
variant="outline"
size="sm"
onClick={() => openMoveDialog(Array.from(selectedIds), selectedLabels)}
disabled={isBulkResolving}
className="gap-1.5 text-xs"
data-testid="bulk-move-to-branch"
>
<ArrowRight className="w-3.5 h-3.5" />
Move ({selectedIds.size})
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setDeleteConfirm({
featureIds: Array.from(selectedIds),
labels: selectedLabels,
})
}
disabled={isBulkResolving}
className="gap-1.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10 hover:border-destructive/30"
data-testid="bulk-delete-orphans"
>
<Trash2 className="w-3.5 h-3.5" />
Delete ({selectedIds.size})
</Button>
</div>
)}
</div>
{/* Feature list */}
<div className="space-y-2">
{orphanedFeatures.map(({ feature, missingBranch }) => {
const isResolving = resolvingIds.has(feature.id);
const isSelected = selectedIds.has(feature.id);
return (
<div
key={feature.id}
className={cn(
'rounded-xl border p-4',
'bg-gradient-to-r from-card/60 to-card/40',
'transition-all duration-200',
isResolving && 'opacity-60',
isSelected ? 'border-brand-500/40 bg-brand-500/5' : 'border-border/50'
)}
>
<div className="flex items-start gap-3">
{/* Checkbox */}
<div className="pt-0.5">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelect(feature.id)}
disabled={isResolving}
data-testid={`select-orphan-${feature.id}`}
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate">
{feature.title || feature.description?.slice(0, 80) || feature.id}
</p>
<p className="text-xs text-muted-foreground mt-1 flex items-center gap-1.5">
<AlertTriangle className="w-3 h-3 text-amber-500 shrink-0" />
Missing branch:{' '}
<code className="px-1.5 py-0.5 rounded bg-muted/50 font-mono text-[11px]">
{missingBranch}
</code>
</p>
</div>
</div>
{/* Per-item actions */}
<div className="flex items-center gap-2 mt-3 ml-7 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => resolveOrphan(feature.id, 'create-worktree')}
disabled={isResolving}
loading={isResolving}
className="gap-1.5 text-xs"
data-testid={`create-worktree-${feature.id}`}
>
<GitBranch className="w-3.5 h-3.5" />
Create Worktree
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
openMoveDialog(
[feature.id],
[feature.title || feature.description?.slice(0, 60) || feature.id]
)
}
disabled={isResolving}
className="gap-1.5 text-xs"
data-testid={`move-orphan-${feature.id}`}
>
<ArrowRight className="w-3.5 h-3.5" />
Move to Branch
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setDeleteConfirm({
featureIds: [feature.id],
labels: [
feature.title ||
feature.description?.slice(0, 60) ||
feature.id,
],
})
}
disabled={isResolving}
className="gap-1.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10 hover:border-destructive/30"
data-testid={`delete-orphan-${feature.id}`}
>
<Trash2 className="w-3.5 h-3.5" />
Delete
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
</>
)}
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<Trash2 className="w-5 h-5" />
Delete{' '}
{deleteConfirm && deleteConfirm.featureIds.length > 1
? `${deleteConfirm.featureIds.length} Orphaned Features`
: 'Orphaned Feature'}
</DialogTitle>
<DialogDescription>
{deleteConfirm && deleteConfirm.featureIds.length > 1 ? (
<>
Are you sure you want to permanently delete these{' '}
{deleteConfirm.featureIds.length} features?
<span className="block mt-2 max-h-32 overflow-y-auto space-y-1">
{deleteConfirm.labels.map((label, i) => (
<span key={i} className="block text-sm font-medium text-foreground">
&bull; {label}
</span>
))}
</span>
</>
) : (
<>
Are you sure you want to permanently delete this feature?
<span className="block mt-2 font-medium text-foreground">
&quot;{deleteConfirm?.labels[0]}&quot;
</span>
</>
)}
<span className="block mt-2 text-destructive font-medium">
This action cannot be undone.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setDeleteConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
loading={isBulkResolving}
onClick={() => {
if (deleteConfirm) {
if (deleteConfirm.featureIds.length === 1) {
resolveOrphan(deleteConfirm.featureIds[0], 'delete');
setDeleteConfirm(null);
} else {
bulkResolve(deleteConfirm.featureIds, 'delete');
}
}
}}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
{deleteConfirm && deleteConfirm.featureIds.length > 1
? ` (${deleteConfirm.featureIds.length})`
: ''}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Move to Branch Dialog */}
<Dialog open={!!moveDialog} onOpenChange={(open) => !open && setMoveDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ArrowRight className="w-5 h-5 text-brand-500" />
Move to Branch
</DialogTitle>
<DialogDescription>
{moveDialog && moveDialog.featureIds.length > 1 ? (
<>
Select where to move {moveDialog.featureIds.length} features. The branch reference
will be updated and the features will be set to pending.
</>
) : (
<>
Select where to move this feature. The branch reference will be updated and the
feature will be set to pending.
</>
)}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<label className="text-sm font-medium text-foreground mb-2 block">Target Branch</label>
<Select
value={selectedBranch}
onValueChange={setSelectedBranch}
disabled={loadingWorktrees}
>
<SelectTrigger className="w-full" data-testid="move-target-branch-select">
<SelectValue placeholder="Select a branch..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__main__">Main worktree (clear branch reference)</SelectItem>
{worktrees
.filter((w) => !w.isMain && w.branch)
.map((w) => (
<SelectItem key={w.branch} value={w.branch}>
{w.branch}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-2">
{selectedBranch === '__main__'
? 'The branch reference will be cleared and the feature will use the main worktree.'
: `The feature will be associated with the "${selectedBranch}" branch.`}
</p>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setMoveDialog(null)}>
Cancel
</Button>
<Button loading={isBulkResolving} onClick={handleMoveConfirm}>
<ArrowRight className="w-4 h-4 mr-2" />
Move
{moveDialog && moveDialog.featureIds.length > 1
? ` (${moveDialog.featureIds.length})`
: ''}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -8,6 +8,7 @@ import { WorktreePreferencesSection } from './worktree-preferences-section';
import { CommandsAndScriptsSection } from './commands-and-scripts-section';
import { ProjectModelsSection } from './project-models-section';
import { DataManagementSection } from './data-management-section';
import { OrphanedFeaturesSection } from './orphaned-features-section';
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
import { RemoveFromAutomakerDialog } from '../settings-view/components/remove-from-automaker-dialog';
@@ -109,6 +110,8 @@ export function ProjectSettingsView() {
return <ProjectModelsSection project={currentProject} />;
case 'data':
return <DataManagementSection project={currentProject} />;
case 'orphaned':
return <OrphanedFeaturesSection project={currentProject} />;
case 'danger':
return (
<DangerZoneSection