mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge recovered upstream commits including worktree enhancement
This brings back commits that were accidentally overwritten during a force push:
- fa8ae149 feat: enhance worktree listing by scanning external directories
- Plus any other changes from upstream/v0.10.0rc at that time
The merge ensures all changes are preserved while keeping the history intact.
This commit is contained in:
@@ -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<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>
|
||||
): Promise<Array<{ path: string; branch: string }>> {
|
||||
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<void> => {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
<BoardHeader
|
||||
projectName={currentProject.name}
|
||||
projectPath={currentProject.path}
|
||||
maxConcurrency={maxConcurrency}
|
||||
runningAgentsCount={runningAutoTasks.length}
|
||||
@@ -1170,6 +1167,15 @@ export function BoardView() {
|
||||
}}
|
||||
onOpenPlanDialog={() => 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 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Search Bar Row */}
|
||||
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
|
||||
<BoardSearchBar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
isCreatingSpec={isCreatingSpec}
|
||||
creatingSpecProjectPath={creatingSpecProjectPath ?? undefined}
|
||||
currentProjectPath={currentProject?.path}
|
||||
/>
|
||||
|
||||
{/* Board Background & Detail Level Controls */}
|
||||
<BoardControls
|
||||
isMounted={isMounted}
|
||||
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
||||
completedCount={completedFeatures.length}
|
||||
boardViewMode={boardViewMode}
|
||||
onBoardViewModeChange={setBoardViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* View Content - Kanban or Graph */}
|
||||
{boardViewMode === 'kanban' ? (
|
||||
<KanbanBoard
|
||||
|
||||
@@ -25,7 +25,7 @@ export function BoardControls({
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View Mode Toggle - Kanban / Graph */}
|
||||
<div
|
||||
className="flex items-center rounded-lg bg-secondary border border-border"
|
||||
|
||||
@@ -6,13 +6,14 @@ import { Label } from '@/components/ui/label';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react';
|
||||
import { UsagePopover } from '@/components/usage-popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useAppStore, BoardViewMode } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { BoardSearchBar } from './board-search-bar';
|
||||
import { BoardControls } from './board-controls';
|
||||
|
||||
interface BoardHeaderProps {
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
maxConcurrency: number;
|
||||
runningAgentsCount: number;
|
||||
@@ -21,6 +22,17 @@ interface BoardHeaderProps {
|
||||
onAutoModeToggle: (enabled: boolean) => 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 (
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Kanban Board</h1>
|
||||
<p className="text-sm text-muted-foreground">{projectName}</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<BoardSearchBar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={onSearchChange}
|
||||
isCreatingSpec={isCreatingSpec}
|
||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||
currentProjectPath={projectPath}
|
||||
/>
|
||||
<BoardControls
|
||||
isMounted={isMounted}
|
||||
onShowBoardBackground={onShowBoardBackground}
|
||||
onShowCompletedModal={onShowCompletedModal}
|
||||
completedCount={completedCount}
|
||||
boardViewMode={boardViewMode}
|
||||
onBoardViewModeChange={onBoardViewModeChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* Usage Popover - show if either provider is authenticated */}
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
|
||||
@@ -99,7 +99,7 @@ export function KanbanBoard({
|
||||
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-x-auto px-5 pb-4 relative" style={backgroundImageStyle}>
|
||||
<div className="flex-1 overflow-x-auto px-5 pt-4 pb-4 relative" style={backgroundImageStyle}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
|
||||
@@ -31,7 +31,10 @@ export function GraphControls({
|
||||
return (
|
||||
<Panel position="bottom-left" className="flex flex-col gap-2">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex flex-col gap-1 p-1.5 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
||||
<div
|
||||
className="flex flex-col gap-1 p-1.5 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
|
||||
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
|
||||
>
|
||||
{/* Zoom controls */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -110,7 +110,10 @@ export function GraphFilterControls({
|
||||
return (
|
||||
<Panel position="top-left" className="flex items-center gap-2">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
||||
<div
|
||||
className="flex items-center gap-2 p-2 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
|
||||
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
|
||||
>
|
||||
{/* Category Filter Dropdown */}
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
|
||||
@@ -44,7 +44,10 @@ const legendItems = [
|
||||
export function GraphLegend() {
|
||||
return (
|
||||
<Panel position="bottom-right" className="pointer-events-none">
|
||||
<div className="flex flex-wrap gap-3 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg pointer-events-auto text-popover-foreground">
|
||||
<div
|
||||
className="flex flex-wrap gap-3 p-2 rounded-lg backdrop-blur-sm border border-border shadow-lg pointer-events-auto text-popover-foreground"
|
||||
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
|
||||
>
|
||||
{legendItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
|
||||
@@ -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
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-[240px] max-w-[280px] rounded-xl border-2 bg-card shadow-md',
|
||||
'min-w-[240px] max-w-[280px] rounded-xl shadow-md relative',
|
||||
'transition-all duration-300',
|
||||
config.borderClass,
|
||||
selected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
|
||||
data.isRunning && 'animate-pulse-subtle',
|
||||
data.error && 'border-[var(--status-error)]',
|
||||
// Filter highlight states
|
||||
isMatched && 'graph-node-matched',
|
||||
isHighlighted && !isMatched && 'graph-node-highlighted',
|
||||
isDimmed && 'graph-node-dimmed'
|
||||
)}
|
||||
style={borderStyle}
|
||||
>
|
||||
{/* Background layer with opacity control - like KanbanCard */}
|
||||
<div
|
||||
className={cn('absolute inset-0 rounded-xl bg-card', glassmorphism && 'backdrop-blur-sm')}
|
||||
style={{ opacity: cardOpacity / 100 }}
|
||||
/>
|
||||
{/* Header with status and actions */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between px-3 py-2 rounded-t-[10px]',
|
||||
'relative flex items-center justify-between px-3 py-2 rounded-t-[10px]',
|
||||
config.bgClass
|
||||
)}
|
||||
>
|
||||
@@ -301,7 +345,7 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-3 py-2">
|
||||
<div className="relative px-3 py-2">
|
||||
{/* Category */}
|
||||
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wide">
|
||||
{data.category}
|
||||
|
||||
@@ -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<boolean>;
|
||||
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<string[]>([]);
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
|
||||
@@ -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)' }}
|
||||
/>
|
||||
|
||||
<GraphControls
|
||||
@@ -281,7 +324,10 @@ function GraphCanvasInner({
|
||||
{/* Empty state when all nodes are filtered out */}
|
||||
{filterResult.hasActiveFilter && filterResult.matchedNodeIds.size === 0 && (
|
||||
<Panel position="top-center" className="mt-20">
|
||||
<div className="flex flex-col items-center gap-3 p-6 rounded-lg bg-popover/95 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
||||
<div
|
||||
className="flex flex-col items-center gap-3 p-6 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
|
||||
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 95%, transparent)' }}
|
||||
>
|
||||
<SearchX className="w-10 h-10 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">No matching tasks</p>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user