From fa8ae149d359ee620d98719da7595f4e75d2a851 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 10 Jan 2026 15:41:35 -0500 Subject: [PATCH] feat: enhance worktree listing by scanning external directories - Implemented a new function to scan the .worktrees directory for worktrees that may exist outside of git's management, allowing for better detection of externally created or corrupted worktrees. - Updated the /list endpoint to include discovered worktrees in the response, improving the accuracy of the worktree listing. - Added logging for discovered worktrees to aid in debugging and tracking. - Cleaned up and organized imports in the list.ts file for better maintainability. --- .../server/src/routes/worktree/routes/list.ts | 102 ++++++++++++++++++ apps/ui/src/components/views/board-view.tsx | 32 ++---- .../views/board-view/board-controls.tsx | 2 +- .../views/board-view/board-header.tsx | 45 ++++++-- .../components/kanban-card/summary-dialog.tsx | 1 + .../views/board-view/kanban-board.tsx | 2 +- .../graph-view/components/graph-controls.tsx | 5 +- .../components/graph-filter-controls.tsx | 5 +- .../graph-view/components/graph-legend.tsx | 5 +- .../views/graph-view/components/task-node.tsx | 54 +++++++++- .../views/graph-view/graph-canvas.tsx | 52 ++++++++- .../views/graph-view/graph-view.tsx | 3 +- .../views/graph-view/hooks/use-graph-nodes.ts | 21 +++- apps/ui/src/styles/global.css | 9 +- apps/ui/src/styles/themes/dark.css | 2 +- 15 files changed, 294 insertions(+), 46 deletions(-) diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index 93d93dad..bc70a341 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -2,18 +2,23 @@ * POST /list endpoint - List all git worktrees * * Returns actual git worktrees from `git worktree list`. + * Also scans .worktrees/ directory to discover worktrees that may have been + * created externally or whose git state was corrupted. * Does NOT include tracked branches - only real worktrees with separate directories. */ import type { Request, Response } from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; +import path from 'path'; import * as secureFs from '../../../lib/secure-fs.js'; import { isGitRepo } from '@automaker/git-utils'; import { getErrorMessage, logError, normalizePath } from '../common.js'; import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js'; +import { createLogger } from '@automaker/utils'; const execAsync = promisify(exec); +const logger = createLogger('Worktree'); interface WorktreeInfo { path: string; @@ -35,6 +40,87 @@ async function getCurrentBranch(cwd: string): Promise { } } +/** + * Scan the .worktrees directory to discover worktrees that may exist on disk + * but are not registered with git (e.g., created externally or corrupted state). + */ +async function scanWorktreesDirectory( + projectPath: string, + knownWorktreePaths: Set +): Promise> { + const discovered: Array<{ path: string; branch: string }> = []; + const worktreesDir = path.join(projectPath, '.worktrees'); + + try { + // Check if .worktrees directory exists + await secureFs.access(worktreesDir); + } catch { + // .worktrees directory doesn't exist + return discovered; + } + + try { + const entries = await secureFs.readdir(worktreesDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const worktreePath = path.join(worktreesDir, entry.name); + const normalizedPath = normalizePath(worktreePath); + + // Skip if already known from git worktree list + if (knownWorktreePaths.has(normalizedPath)) continue; + + // Check if this is a valid git repository + const gitPath = path.join(worktreePath, '.git'); + try { + const gitStat = await secureFs.stat(gitPath); + + // Git worktrees have a .git FILE (not directory) that points to the parent repo + // Regular repos have a .git DIRECTORY + if (gitStat.isFile() || gitStat.isDirectory()) { + // Try to get the branch name + const branch = await getCurrentBranch(worktreePath); + if (branch) { + logger.info( + `Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${branch})` + ); + discovered.push({ + path: normalizedPath, + branch, + }); + } else { + // Try to get branch from HEAD if branch --show-current fails (detached HEAD) + try { + const { stdout: headRef } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); + const headBranch = headRef.trim(); + if (headBranch && headBranch !== 'HEAD') { + logger.info( + `Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})` + ); + discovered.push({ + path: normalizedPath, + branch: headBranch, + }); + } + } catch { + // Can't determine branch, skip this directory + } + } + } + } catch { + // Not a git repo, skip + } + } + } catch (error) { + logger.warn(`Failed to scan .worktrees directory: ${getErrorMessage(error)}`); + } + + return discovered; +} + export function createListHandler() { return async (req: Request, res: Response): Promise => { try { @@ -116,6 +202,22 @@ export function createListHandler() { } } + // Scan .worktrees directory to discover worktrees that exist on disk + // but are not registered with git (e.g., created externally) + const knownPaths = new Set(worktrees.map((w) => w.path)); + const discoveredWorktrees = await scanWorktreesDirectory(projectPath, knownPaths); + + // Add discovered worktrees to the list + for (const discovered of discoveredWorktrees) { + worktrees.push({ + path: discovered.path, + branch: discovered.branch, + isMain: false, + isCurrent: discovered.branch === currentBranch, + hasWorktree: true, + }); + } + // Read all worktree metadata to get PR info const allMetadata = await readAllWorktreeMetadata(projectPath); diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 810d1a91..41932a43 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -40,8 +40,6 @@ import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; import { useWindowState } from '@/hooks/use-window-state'; // Board-view specific imports import { BoardHeader } from './board-view/board-header'; -import { BoardSearchBar } from './board-view/board-search-bar'; -import { BoardControls } from './board-view/board-controls'; import { KanbanBoard } from './board-view/kanban-board'; import { GraphView } from './graph-view'; import { @@ -1155,7 +1153,6 @@ export function BoardView() { > {/* Header */} setShowPlanDialog(true)} isMounted={isMounted} + searchQuery={searchQuery} + onSearchChange={setSearchQuery} + isCreatingSpec={isCreatingSpec} + creatingSpecProjectPath={creatingSpecProjectPath} + onShowBoardBackground={() => setShowBoardBackgroundModal(true)} + onShowCompletedModal={() => setShowCompletedModal(true)} + completedCount={completedFeatures.length} + boardViewMode={boardViewMode} + onBoardViewModeChange={setBoardViewMode} /> {/* Worktree Panel - conditionally rendered based on visibility setting */} @@ -1208,26 +1214,6 @@ export function BoardView() { {/* Main Content Area */}
- {/* Search Bar Row */} -
- - - {/* Board Background & Detail Level Controls */} - setShowBoardBackgroundModal(true)} - onShowCompletedModal={() => setShowCompletedModal(true)} - completedCount={completedFeatures.length} - boardViewMode={boardViewMode} - onBoardViewModeChange={setBoardViewMode} - /> -
{/* View Content - Kanban or Graph */} {boardViewMode === 'kanban' ? ( -
+
{/* View Mode Toggle - Kanban / Graph */}
void; onOpenPlanDialog: () => void; isMounted: boolean; + // Search bar props + searchQuery: string; + onSearchChange: (query: string) => void; + isCreatingSpec: boolean; + creatingSpecProjectPath?: string; + // Board controls props + onShowBoardBackground: () => void; + onShowCompletedModal: () => void; + completedCount: number; + boardViewMode: BoardViewMode; + onBoardViewModeChange: (mode: BoardViewMode) => void; } // Shared styles for header control containers @@ -28,7 +40,6 @@ const controlContainerClass = 'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border'; export function BoardHeader({ - projectName, projectPath, maxConcurrency, runningAgentsCount, @@ -37,6 +48,15 @@ export function BoardHeader({ onAutoModeToggle, onOpenPlanDialog, isMounted, + searchQuery, + onSearchChange, + isCreatingSpec, + creatingSpecProjectPath, + onShowBoardBackground, + onShowCompletedModal, + completedCount, + boardViewMode, + onBoardViewModeChange, }: BoardHeaderProps) { const [showAutoModeSettings, setShowAutoModeSettings] = useState(false); const apiKeys = useAppStore((state) => state.apiKeys); @@ -84,9 +104,22 @@ export function BoardHeader({ return (
-
-

Kanban Board

-

{projectName}

+
+ +
{/* Usage Popover - show if either provider is authenticated */} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx index db9f579d..11e98663 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx @@ -35,6 +35,7 @@ export function SummaryDialog({ data-testid={`summary-dialog-${feature.id}`} onPointerDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} > diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 1fd39843..4601a70c 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -99,7 +99,7 @@ export function KanbanBoard({ const { columnWidth, containerStyle } = useResponsiveKanban(columns.length); return ( -
+
-
+
{/* Zoom controls */} diff --git a/apps/ui/src/components/views/graph-view/components/graph-filter-controls.tsx b/apps/ui/src/components/views/graph-view/components/graph-filter-controls.tsx index f1564e81..af7513ea 100644 --- a/apps/ui/src/components/views/graph-view/components/graph-filter-controls.tsx +++ b/apps/ui/src/components/views/graph-view/components/graph-filter-controls.tsx @@ -110,7 +110,10 @@ export function GraphFilterControls({ return ( -
+
{/* Category Filter Dropdown */} diff --git a/apps/ui/src/components/views/graph-view/components/graph-legend.tsx b/apps/ui/src/components/views/graph-view/components/graph-legend.tsx index f7c9cb55..545378ef 100644 --- a/apps/ui/src/components/views/graph-view/components/graph-legend.tsx +++ b/apps/ui/src/components/views/graph-view/components/graph-legend.tsx @@ -44,7 +44,10 @@ const legendItems = [ export function GraphLegend() { return ( -
+
{legendItems.map((item) => { const Icon = item.icon; return ( diff --git a/apps/ui/src/components/views/graph-view/components/task-node.tsx b/apps/ui/src/components/views/graph-view/components/task-node.tsx index ac0132a3..f21d7272 100644 --- a/apps/ui/src/components/views/graph-view/components/task-node.tsx +++ b/apps/ui/src/components/views/graph-view/components/task-node.tsx @@ -75,6 +75,24 @@ const priorityConfig = { 3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' }, }; +// Helper function to get border style with opacity (like KanbanCard does) +function getCardBorderStyle( + enabled: boolean, + opacity: number, + borderColor: string +): React.CSSProperties { + if (!enabled) { + return { borderWidth: '0px', borderColor: 'transparent' }; + } + if (opacity !== 100) { + return { + borderWidth: '2px', + borderColor: `color-mix(in oklch, ${borderColor} ${opacity}%, transparent)`, + }; + } + return { borderWidth: '2px' }; +} + export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) { // Handle pipeline statuses by treating them like in_progress const status = data.status || 'backlog'; @@ -91,6 +109,28 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps // Task is stopped if it's in_progress but not actively running const isStopped = data.status === 'in_progress' && !data.isRunning; + // Background/theme settings with defaults + const cardOpacity = data.cardOpacity ?? 100; + const glassmorphism = data.cardGlassmorphism ?? true; + const cardBorderEnabled = data.cardBorderEnabled ?? true; + const cardBorderOpacity = data.cardBorderOpacity ?? 100; + + // Get the border color based on status and error state + const borderColor = data.error + ? 'var(--status-error)' + : config.borderClass.includes('border-border') + ? 'var(--border)' + : config.borderClass.includes('status-in-progress') + ? 'var(--status-in-progress)' + : config.borderClass.includes('status-waiting') + ? 'var(--status-waiting)' + : config.borderClass.includes('status-success') + ? 'var(--status-success)' + : 'var(--border)'; + + // Get computed border style + const borderStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity, borderColor); + return ( <> {/* Target handle (left side - receives dependencies) */} @@ -109,22 +149,26 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
+ {/* Background layer with opacity control - like KanbanCard */} +
{/* Header with status and actions */}
@@ -301,7 +345,7 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
{/* Content */} -
+
{/* Category */} {data.category} diff --git a/apps/ui/src/components/views/graph-view/graph-canvas.tsx b/apps/ui/src/components/views/graph-view/graph-canvas.tsx index 06d32dce..642daaf6 100644 --- a/apps/ui/src/components/views/graph-view/graph-canvas.tsx +++ b/apps/ui/src/components/views/graph-view/graph-canvas.tsx @@ -15,7 +15,8 @@ import { } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import { Feature } from '@/store/app-store'; +import { Feature, useAppStore } from '@/store/app-store'; +import { themeOptions } from '@/config/theme-options'; import { TaskNode, DependencyEdge, @@ -47,6 +48,13 @@ const edgeTypes: any = { dependency: DependencyEdge, }; +interface BackgroundSettings { + cardOpacity: number; + cardGlassmorphism: boolean; + cardBorderEnabled: boolean; + cardBorderOpacity: number; +} + interface GraphCanvasProps { features: Feature[]; runningAutoTasks: string[]; @@ -56,6 +64,7 @@ interface GraphCanvasProps { nodeActionCallbacks?: NodeActionCallbacks; onCreateDependency?: (sourceId: string, targetId: string) => Promise; backgroundStyle?: React.CSSProperties; + backgroundSettings?: BackgroundSettings; className?: string; } @@ -68,11 +77,42 @@ function GraphCanvasInner({ nodeActionCallbacks, onCreateDependency, backgroundStyle, + backgroundSettings, className, }: GraphCanvasProps) { const [isLocked, setIsLocked] = useState(false); const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR'); + // Determine React Flow color mode based on current theme + const effectiveTheme = useAppStore((state) => state.getEffectiveTheme()); + const [systemColorMode, setSystemColorMode] = useState<'dark' | 'light'>(() => { + if (typeof window === 'undefined') return 'dark'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + }); + + useEffect(() => { + if (effectiveTheme !== 'system') return; + if (typeof window === 'undefined') return; + + const mql = window.matchMedia('(prefers-color-scheme: dark)'); + const update = () => setSystemColorMode(mql.matches ? 'dark' : 'light'); + update(); + + // Safari < 14 fallback + if (mql.addEventListener) { + mql.addEventListener('change', update); + return () => mql.removeEventListener('change', update); + } + // eslint-disable-next-line deprecation/deprecation + mql.addListener(update); + // eslint-disable-next-line deprecation/deprecation + return () => mql.removeListener(update); + }, [effectiveTheme]); + + const themeOption = themeOptions.find((t) => t.value === effectiveTheme); + const colorMode = + effectiveTheme === 'system' ? systemColorMode : themeOption?.isDark ? 'dark' : 'light'; + // Filter state (category, status, and negative toggle are local to graph view) const [selectedCategories, setSelectedCategories] = useState([]); const [selectedStatuses, setSelectedStatuses] = useState([]); @@ -98,6 +138,7 @@ function GraphCanvasInner({ runningAutoTasks, filterResult, actionCallbacks: nodeActionCallbacks, + backgroundSettings, }); // Apply layout @@ -234,6 +275,7 @@ function GraphCanvasInner({ isValidConnection={isValidConnection} nodeTypes={nodeTypes} edgeTypes={edgeTypes} + colorMode={colorMode} fitView fitViewOptions={{ padding: 0.2 }} minZoom={0.1} @@ -256,7 +298,8 @@ function GraphCanvasInner({ nodeStrokeWidth={3} zoomable pannable - className="!bg-popover/90 !border-border rounded-lg shadow-lg" + className="border-border! rounded-lg shadow-lg" + style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }} /> -
+

No matching tasks

diff --git a/apps/ui/src/components/views/graph-view/graph-view.tsx b/apps/ui/src/components/views/graph-view/graph-view.tsx index fbb33960..34959f91 100644 --- a/apps/ui/src/components/views/graph-view/graph-view.tsx +++ b/apps/ui/src/components/views/graph-view/graph-view.tsx @@ -44,7 +44,7 @@ export function GraphView({ const { currentProject } = useAppStore(); // Use the same background hook as the board view - const { backgroundImageStyle } = useBoardBackground({ currentProject }); + const { backgroundImageStyle, backgroundSettings } = useBoardBackground({ currentProject }); // Filter features by current worktree (same logic as board view) const filteredFeatures = useMemo(() => { @@ -213,6 +213,7 @@ export function GraphView({ nodeActionCallbacks={nodeActionCallbacks} onCreateDependency={handleCreateDependency} backgroundStyle={backgroundImageStyle} + backgroundSettings={backgroundSettings} className="h-full" />
diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts index d9b340a9..3e9e41e0 100644 --- a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts +++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts @@ -18,6 +18,11 @@ export interface TaskNodeData extends Feature { isMatched?: boolean; isHighlighted?: boolean; isDimmed?: boolean; + // Background/theme settings + cardOpacity?: number; + cardGlassmorphism?: boolean; + cardBorderEnabled?: boolean; + cardBorderOpacity?: number; // Action callbacks onViewLogs?: () => void; onViewDetails?: () => void; @@ -48,11 +53,19 @@ export interface NodeActionCallbacks { onDeleteDependency?: (sourceId: string, targetId: string) => void; } +interface BackgroundSettings { + cardOpacity: number; + cardGlassmorphism: boolean; + cardBorderEnabled: boolean; + cardBorderOpacity: number; +} + interface UseGraphNodesProps { features: Feature[]; runningAutoTasks: string[]; filterResult?: GraphFilterResult; actionCallbacks?: NodeActionCallbacks; + backgroundSettings?: BackgroundSettings; } /** @@ -64,6 +77,7 @@ export function useGraphNodes({ runningAutoTasks, filterResult, actionCallbacks, + backgroundSettings, }: UseGraphNodesProps) { const { nodes, edges } = useMemo(() => { const nodeList: TaskNode[] = []; @@ -102,6 +116,11 @@ export function useGraphNodes({ isMatched, isHighlighted, isDimmed, + // Background/theme settings + cardOpacity: backgroundSettings?.cardOpacity, + cardGlassmorphism: backgroundSettings?.cardGlassmorphism, + cardBorderEnabled: backgroundSettings?.cardBorderEnabled, + cardBorderOpacity: backgroundSettings?.cardBorderOpacity, // Action callbacks (bound to this feature's ID) onViewLogs: actionCallbacks?.onViewLogs ? () => actionCallbacks.onViewLogs!(feature.id) @@ -163,7 +182,7 @@ export function useGraphNodes({ }); return { nodes: nodeList, edges: edgeList }; - }, [features, runningAutoTasks, filterResult, actionCallbacks]); + }, [features, runningAutoTasks, filterResult, actionCallbacks, backgroundSettings]); return { nodes, edges }; } diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index 70d6a0f6..54a32c4a 100644 --- a/apps/ui/src/styles/global.css +++ b/apps/ui/src/styles/global.css @@ -298,7 +298,14 @@ } } -.light { +/* IMPORTANT: + * Theme classes like `.light` are applied to `:root` (html). + * Some third-party libraries (e.g. React Flow) also add `.light`/`.dark` classes + * to nested containers. If we define CSS variables on `.light` broadly, those + * nested containers will override the app theme and cause "white cards" in dark themes. + * Scoping to `:root.light` ensures only the root theme toggle controls variables. + */ +:root.light { /* Explicit light mode - same as root but ensures it overrides any dark defaults */ --background: oklch(1 0 0); /* White */ --background-50: oklch(1 0 0 / 0.5); diff --git a/apps/ui/src/styles/themes/dark.css b/apps/ui/src/styles/themes/dark.css index 9b23ca8d..5a702c25 100644 --- a/apps/ui/src/styles/themes/dark.css +++ b/apps/ui/src/styles/themes/dark.css @@ -1,6 +1,6 @@ /* Dark Theme */ -.dark { +:root.dark { /* Deep dark backgrounds - zinc-950 family */ --background: oklch(0.04 0 0); /* zinc-950 */ --background-50: oklch(0.04 0 0 / 0.5); /* zinc-950/50 */