mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 10:23:07 +00:00
Merge remote-tracking branch 'upstream/v0.15.0rc' into feat/duplicate-festure
# Conflicts: # apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx # apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx # apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts
This commit is contained in:
@@ -59,24 +59,19 @@ export function TaskProgressPanel({
|
||||
const planSpec = feature.planSpec;
|
||||
const planTasks = planSpec.tasks; // Already guarded by the if condition above
|
||||
const currentId = planSpec.currentTaskId;
|
||||
const completedCount = planSpec.tasksCompleted || 0;
|
||||
|
||||
// Convert planSpec tasks to TaskInfo with proper status
|
||||
// Convert planSpec tasks to TaskInfo using their persisted status
|
||||
// planTasks is guaranteed to be defined due to the if condition check
|
||||
const initialTasks: TaskInfo[] = (planTasks as ParsedTask[]).map(
|
||||
(t: ParsedTask, index: number) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
filePath: t.filePath,
|
||||
phase: t.phase,
|
||||
status:
|
||||
index < completedCount
|
||||
? ('completed' as const)
|
||||
: t.id === currentId
|
||||
? ('in_progress' as const)
|
||||
: ('pending' as const),
|
||||
})
|
||||
);
|
||||
const initialTasks: TaskInfo[] = (planTasks as ParsedTask[]).map((t: ParsedTask) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
filePath: t.filePath,
|
||||
phase: t.phase,
|
||||
status:
|
||||
t.id === currentId
|
||||
? ('in_progress' as const)
|
||||
: (t.status as TaskInfo['status']) || ('pending' as const),
|
||||
}));
|
||||
|
||||
setTasks(initialTasks);
|
||||
setCurrentTaskId(currentId || null);
|
||||
@@ -113,16 +108,12 @@ export function TaskProgressPanel({
|
||||
const existingIndex = prev.findIndex((t) => t.id === taskEvent.taskId);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// Update status to in_progress and mark previous as completed
|
||||
return prev.map((t, idx) => {
|
||||
// Update only the started task to in_progress
|
||||
// Do NOT assume previous tasks are completed - rely on actual task_complete events
|
||||
return prev.map((t) => {
|
||||
if (t.id === taskEvent.taskId) {
|
||||
return { ...t, status: 'in_progress' as const };
|
||||
}
|
||||
// If we are moving to a task that is further down the list, assume previous ones are completed
|
||||
// This is a heuristic, but usually correct for sequential execution
|
||||
if (idx < existingIndex && t.status !== 'completed') {
|
||||
return { ...t, status: 'completed' as const };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
}
|
||||
@@ -151,6 +142,24 @@ export function TaskProgressPanel({
|
||||
setCurrentTaskId(null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auto_mode_task_status':
|
||||
if ('taskId' in event && 'status' in event) {
|
||||
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_status' }>;
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === taskEvent.taskId
|
||||
? { ...t, status: taskEvent.status as TaskInfo['status'] }
|
||||
: t
|
||||
)
|
||||
);
|
||||
if (taskEvent.status === 'in_progress') {
|
||||
setCurrentTaskId(taskEvent.taskId);
|
||||
} else if (taskEvent.status === 'completed') {
|
||||
setCurrentTaskId((current) => (current === taskEvent.taskId ? null : current));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
import { useClaudeUsage, useCodexUsage } from '@/hooks/queries';
|
||||
import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils';
|
||||
|
||||
// Error codes for distinguishing failure modes
|
||||
const ERROR_CODES = {
|
||||
@@ -146,13 +147,28 @@ export function UsagePopover() {
|
||||
return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' };
|
||||
};
|
||||
|
||||
// Helper component for the progress bar
|
||||
const ProgressBar = ({ percentage, colorClass }: { percentage: number; colorClass: string }) => (
|
||||
<div className="h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
|
||||
// Helper component for the progress bar with optional pace indicator
|
||||
const ProgressBar = ({
|
||||
percentage,
|
||||
colorClass,
|
||||
pacePercentage,
|
||||
}: {
|
||||
percentage: number;
|
||||
colorClass: string;
|
||||
pacePercentage?: number | null;
|
||||
}) => (
|
||||
<div className="relative h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full transition-all duration-500', colorClass)}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
{pacePercentage != null && pacePercentage > 0 && pacePercentage < 100 && (
|
||||
<div
|
||||
className="absolute top-0 h-full w-0.5 bg-foreground/60"
|
||||
style={{ left: `${pacePercentage}%` }}
|
||||
title={`Expected: ${Math.round(pacePercentage)}%`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -163,6 +179,7 @@ export function UsagePopover() {
|
||||
resetText,
|
||||
isPrimary = false,
|
||||
stale = false,
|
||||
pacePercentage,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -170,6 +187,7 @@ export function UsagePopover() {
|
||||
resetText?: string;
|
||||
isPrimary?: boolean;
|
||||
stale?: boolean;
|
||||
pacePercentage?: number | null;
|
||||
}) => {
|
||||
const isValidPercentage =
|
||||
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
|
||||
@@ -177,6 +195,10 @@ export function UsagePopover() {
|
||||
|
||||
const status = getStatusInfo(safePercentage);
|
||||
const StatusIcon = status.icon;
|
||||
const paceLabel =
|
||||
isValidPercentage && pacePercentage != null
|
||||
? getPaceStatusLabel(safePercentage, pacePercentage)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -211,15 +233,28 @@ export function UsagePopover() {
|
||||
<ProgressBar
|
||||
percentage={safePercentage}
|
||||
colorClass={isValidPercentage ? status.bg : 'bg-muted-foreground/30'}
|
||||
pacePercentage={pacePercentage}
|
||||
/>
|
||||
{resetText && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
{paceLabel ? (
|
||||
<p
|
||||
className={cn(
|
||||
'text-[10px] font-medium',
|
||||
safePercentage > (pacePercentage ?? 0) ? 'text-orange-500' : 'text-green-500'
|
||||
)}
|
||||
>
|
||||
{paceLabel}
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{resetText && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{resetText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -384,6 +419,7 @@ export function UsagePopover() {
|
||||
percentage={claudeUsage.sonnetWeeklyPercentage}
|
||||
resetText={claudeUsage.sonnetResetText}
|
||||
stale={isClaudeStale}
|
||||
pacePercentage={getExpectedWeeklyPacePercentage(claudeUsage.weeklyResetTime)}
|
||||
/>
|
||||
<UsageCard
|
||||
title="Weekly"
|
||||
@@ -391,6 +427,7 @@ export function UsagePopover() {
|
||||
percentage={claudeUsage.weeklyPercentage}
|
||||
resetText={claudeUsage.weeklyResetText}
|
||||
stale={isClaudeStale}
|
||||
pacePercentage={getExpectedWeeklyPacePercentage(claudeUsage.weeklyResetTime)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -594,7 +594,7 @@ export function BoardView() {
|
||||
} = useBoardActions({
|
||||
currentProject,
|
||||
features: hookFeatures,
|
||||
runningAutoTasks,
|
||||
runningAutoTasks: runningAutoTasksAllWorktrees,
|
||||
loadFeatures,
|
||||
persistFeatureCreate,
|
||||
persistFeatureUpdate,
|
||||
@@ -883,7 +883,15 @@ export function BoardView() {
|
||||
|
||||
// Capture existing feature IDs before adding
|
||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
||||
await handleAddFeature(featureData);
|
||||
try {
|
||||
await handleAddFeature(featureData);
|
||||
} catch (error) {
|
||||
logger.error('Failed to create PR comments feature:', error);
|
||||
toast.error('Failed to create feature', {
|
||||
description: error instanceof Error ? error.message : 'An error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the newly created feature by looking for an ID that wasn't in the original set
|
||||
const latestFeatures = useAppStore.getState().features;
|
||||
@@ -914,7 +922,7 @@ export function BoardView() {
|
||||
|
||||
// Create the feature
|
||||
const featureData = {
|
||||
title: `Resolve Merge Conflicts`,
|
||||
title: `Resolve Merge Conflicts: ${remoteBranch} → ${worktree.branch}`,
|
||||
category: 'Maintenance',
|
||||
description,
|
||||
images: [],
|
||||
@@ -931,7 +939,15 @@ export function BoardView() {
|
||||
|
||||
// Capture existing feature IDs before adding
|
||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
||||
await handleAddFeature(featureData);
|
||||
try {
|
||||
await handleAddFeature(featureData);
|
||||
} catch (error) {
|
||||
logger.error('Failed to create resolve conflicts feature:', error);
|
||||
toast.error('Failed to create feature', {
|
||||
description: error instanceof Error ? error.message : 'An error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the newly created feature by looking for an ID that wasn't in the original set
|
||||
const latestFeatures = useAppStore.getState().features;
|
||||
@@ -973,7 +989,15 @@ export function BoardView() {
|
||||
|
||||
// Capture existing feature IDs before adding
|
||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
||||
await handleAddFeature(featureData);
|
||||
try {
|
||||
await handleAddFeature(featureData);
|
||||
} catch (error) {
|
||||
logger.error('Failed to create merge conflict resolution feature:', error);
|
||||
toast.error('Failed to create feature', {
|
||||
description: error instanceof Error ? error.message : 'An error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the newly created feature by looking for an ID that wasn't in the original set
|
||||
const latestFeatures = useAppStore.getState().features;
|
||||
@@ -996,7 +1020,15 @@ export function BoardView() {
|
||||
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
|
||||
// Capture existing feature IDs before adding
|
||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
||||
await handleAddFeature(featureData);
|
||||
try {
|
||||
await handleAddFeature(featureData);
|
||||
} catch (error) {
|
||||
logger.error('Failed to create feature:', error);
|
||||
toast.error('Failed to create feature', {
|
||||
description: error instanceof Error ? error.message : 'An error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the newly created feature by looking for an ID that wasn't in the original set
|
||||
const latestFeatures = useAppStore.getState().features;
|
||||
@@ -1093,7 +1125,7 @@ export function BoardView() {
|
||||
} = useBoardDragDrop({
|
||||
features: hookFeatures,
|
||||
currentProject,
|
||||
runningAutoTasks,
|
||||
runningAutoTasks: runningAutoTasksAllWorktrees,
|
||||
persistFeatureUpdate,
|
||||
handleStartImplementation,
|
||||
});
|
||||
@@ -1363,10 +1395,16 @@ export function BoardView() {
|
||||
if (enabled) {
|
||||
autoMode.start().catch((error) => {
|
||||
logger.error('[AutoMode] Failed to start:', error);
|
||||
toast.error('Failed to start auto mode', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
});
|
||||
} else {
|
||||
autoMode.stop().catch((error) => {
|
||||
logger.error('[AutoMode] Failed to stop:', error);
|
||||
toast.error('Failed to stop auto mode', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -1469,7 +1507,7 @@ export function BoardView() {
|
||||
onDuplicate: (feature) => handleDuplicateFeature(feature, false),
|
||||
onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true),
|
||||
}}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
runningAutoTasks={runningAutoTasksAllWorktrees}
|
||||
pipelineConfig={pipelineConfig}
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
@@ -1509,7 +1547,7 @@ export function BoardView() {
|
||||
}}
|
||||
onDuplicate={handleDuplicateFeature}
|
||||
featuresWithContext={featuresWithContext}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
runningAutoTasks={runningAutoTasksAllWorktrees}
|
||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
||||
|
||||
@@ -22,6 +22,7 @@ function formatThinkingLevel(level: ThinkingLevel | undefined): string {
|
||||
medium: 'Med',
|
||||
high: 'High',
|
||||
ultrathink: 'Ultra',
|
||||
adaptive: 'Adaptive',
|
||||
};
|
||||
return labels[level];
|
||||
}
|
||||
@@ -152,6 +153,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
|
||||
// Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos
|
||||
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
|
||||
const isFeatureFinished = feature.status === 'waiting_approval' || feature.status === 'verified';
|
||||
const effectiveTodos = useMemo(() => {
|
||||
// Use freshPlanSpec if available (fetched from API), fallback to store's feature.planSpec
|
||||
const planSpec = freshPlanSpec?.tasks?.length ? freshPlanSpec : feature.planSpec;
|
||||
@@ -162,6 +164,20 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
const currentTaskId = planSpec.currentTaskId;
|
||||
|
||||
return planSpec.tasks.map((task: ParsedTask, index: number) => {
|
||||
// When feature is finished (waiting_approval/verified), finalize task display:
|
||||
// - in_progress tasks → completed (agent was working on them when it finished)
|
||||
// - pending tasks stay pending (they were never started)
|
||||
// - completed tasks stay completed
|
||||
// This matches server-side behavior in feature-state-manager.ts
|
||||
if (isFeatureFinished) {
|
||||
const finalStatus =
|
||||
task.status === 'in_progress' || task.status === 'failed' ? 'completed' : task.status;
|
||||
return {
|
||||
content: task.description,
|
||||
status: (finalStatus || 'completed') as 'pending' | 'in_progress' | 'completed',
|
||||
};
|
||||
}
|
||||
|
||||
// Use real-time status from WebSocket events if available
|
||||
const realtimeStatus = taskStatusMap.get(task.id);
|
||||
|
||||
@@ -198,6 +214,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
feature.planSpec?.currentTaskId,
|
||||
agentInfo?.todos,
|
||||
taskStatusMap,
|
||||
isFeatureFinished,
|
||||
]);
|
||||
|
||||
// Listen to WebSocket events for real-time task status updates
|
||||
|
||||
@@ -293,56 +293,59 @@ export const CardActions = memo(function CardActions({
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === 'backlog' && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`edit-backlog-${feature.id}`}
|
||||
>
|
||||
<Edit className="w-3 h-3 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
{feature.planSpec?.content && onViewPlan && (
|
||||
{!isCurrentAutoTask &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'interrupted' ||
|
||||
feature.status === 'ready') && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewPlan();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`view-plan-${feature.id}`}
|
||||
title="View Plan"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
{onImplement && (
|
||||
<Button
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onImplement();
|
||||
onEdit();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`make-${feature.id}`}
|
||||
data-testid={`edit-backlog-${feature.id}`}
|
||||
>
|
||||
<PlayCircle className="w-3 h-3 mr-1" />
|
||||
Make
|
||||
<Edit className="w-3 h-3 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{feature.planSpec?.content && onViewPlan && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewPlan();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`view-plan-${feature.id}`}
|
||||
title="View Plan"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
{onImplement && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onImplement();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`make-${feature.id}`}
|
||||
>
|
||||
<PlayCircle className="w-3 h-3 mr-1" />
|
||||
Make
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @ts-nocheck - header component props with optional handlers and status variants
|
||||
import { memo, useState } from 'react';
|
||||
import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -41,6 +42,8 @@ interface CardHeaderProps {
|
||||
onSpawnTask?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onDuplicateAsChild?: () => void;
|
||||
dragHandleListeners?: DraggableSyntheticListeners;
|
||||
dragHandleAttributes?: DraggableAttributes;
|
||||
}
|
||||
|
||||
export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
@@ -54,6 +57,8 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
onSpawnTask,
|
||||
onDuplicate,
|
||||
onDuplicateAsChild,
|
||||
dragHandleListeners,
|
||||
dragHandleAttributes,
|
||||
}: CardHeaderProps) {
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
@@ -167,81 +172,88 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backlog header */}
|
||||
{!isCurrentAutoTask && !isSelectionMode && feature.status === 'backlog' && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
|
||||
onClick={handleDeleteClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`delete-backlog-${feature.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`menu-backlog-${feature.id}`}
|
||||
>
|
||||
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSpawnTask?.();
|
||||
}}
|
||||
data-testid={`spawn-backlog-${feature.id}`}
|
||||
className="text-xs"
|
||||
>
|
||||
<GitFork className="w-3 h-3 mr-2" />
|
||||
Spawn Sub-Task
|
||||
</DropdownMenuItem>
|
||||
{onDuplicate && (
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||
>
|
||||
<Copy className="w-3 h-3 mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
{onDuplicateAsChild && (
|
||||
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||
)}
|
||||
</div>
|
||||
{onDuplicateAsChild && (
|
||||
<DropdownMenuSubContent>
|
||||
{/* Backlog header (also handles 'interrupted' and 'ready' statuses that display in backlog column) */}
|
||||
{!isCurrentAutoTask &&
|
||||
!isSelectionMode &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'interrupted' ||
|
||||
feature.status === 'ready') && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSpawnTask?.();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`spawn-backlog-${feature.id}`}
|
||||
title="Spawn Sub-Task"
|
||||
>
|
||||
<GitFork className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
|
||||
onClick={handleDeleteClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`delete-backlog-${feature.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`menu-backlog-${feature.id}`}
|
||||
>
|
||||
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{onDuplicate && (
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicateAsChild();
|
||||
onDuplicate();
|
||||
}}
|
||||
className="text-xs"
|
||||
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||
>
|
||||
<GitFork className="w-3 h-3 mr-2" />
|
||||
Duplicate as Child
|
||||
<Copy className="w-3 h-3 mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
)}
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
{onDuplicateAsChild && (
|
||||
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||
)}
|
||||
</div>
|
||||
{onDuplicateAsChild && (
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicateAsChild();
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<GitFork className="w-3 h-3 mr-2" />
|
||||
Duplicate as Child
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
)}
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Waiting approval / Verified header */}
|
||||
{!isCurrentAutoTask &&
|
||||
@@ -483,8 +495,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
<div className="flex items-start gap-2">
|
||||
{isDraggable && (
|
||||
<div
|
||||
className="-ml-2 -mt-1 p-2 touch-none opacity-40 hover:opacity-70 transition-opacity"
|
||||
className="-ml-2 -mt-1 p-2 touch-none cursor-grab active:cursor-grabbing opacity-40 hover:opacity-70 transition-opacity"
|
||||
data-testid={`drag-handle-${feature.id}`}
|
||||
{...dragHandleAttributes}
|
||||
{...dragHandleListeners}
|
||||
>
|
||||
<GripVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ function getCursorClass(
|
||||
): string {
|
||||
if (isSelectionMode) return 'cursor-pointer';
|
||||
if (isOverlay) return 'cursor-grabbing';
|
||||
if (isDraggable) return 'cursor-grab active:cursor-grabbing';
|
||||
// Drag cursor is now only on the drag handle, not the full card
|
||||
return 'cursor-default';
|
||||
}
|
||||
|
||||
@@ -112,6 +112,9 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
currentProject: state.currentProject,
|
||||
}))
|
||||
);
|
||||
// A card in waiting_approval should not display as "actively running" even if
|
||||
// it's still in the runningAutoTasks list. The waiting_approval UI takes precedence.
|
||||
const isActivelyRunning = !!isCurrentAutoTask && feature.status !== 'waiting_approval';
|
||||
const [isLifted, setIsLifted] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -125,6 +128,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
const isDraggable =
|
||||
!isSelectionMode &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'interrupted' ||
|
||||
feature.status === 'ready' ||
|
||||
feature.status === 'waiting_approval' ||
|
||||
feature.status === 'verified' ||
|
||||
feature.status.startsWith('pipeline_') ||
|
||||
@@ -171,7 +176,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
const isSelectable = isSelectionMode && feature.status === selectionTarget;
|
||||
|
||||
const wrapperClasses = cn(
|
||||
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
|
||||
'relative select-none outline-none transition-transform duration-200 ease-out',
|
||||
getCursorClass(isOverlay, isDraggable, isSelectable),
|
||||
isOverlay && isLifted && 'scale-105 rotate-1 z-50',
|
||||
// Visual feedback when another card is being dragged over this one
|
||||
@@ -188,10 +193,10 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
// Disable hover translate for in-progress cards to prevent gap showing gradient
|
||||
isInteractive &&
|
||||
!reduceEffects &&
|
||||
!isCurrentAutoTask &&
|
||||
!isActivelyRunning &&
|
||||
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||
!isCurrentAutoTask &&
|
||||
!isActivelyRunning &&
|
||||
cardBorderEnabled &&
|
||||
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
|
||||
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
|
||||
@@ -208,7 +213,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
|
||||
const renderCardContent = () => (
|
||||
<Card
|
||||
style={isCurrentAutoTask ? undefined : cardStyle}
|
||||
style={isActivelyRunning ? undefined : cardStyle}
|
||||
className={innerCardClasses}
|
||||
onDoubleClick={isSelectionMode ? undefined : onEdit}
|
||||
onClick={handleCardClick}
|
||||
@@ -247,7 +252,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<CardHeaderSection
|
||||
feature={feature}
|
||||
isDraggable={isDraggable}
|
||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||
isCurrentAutoTask={isActivelyRunning}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
@@ -255,6 +260,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
onSpawnTask={onSpawnTask}
|
||||
onDuplicate={onDuplicate}
|
||||
onDuplicateAsChild={onDuplicateAsChild}
|
||||
dragHandleListeners={isDraggable ? listeners : undefined}
|
||||
dragHandleAttributes={isDraggable ? attributes : undefined}
|
||||
/>
|
||||
|
||||
<CardContent className="px-3 pt-0 pb-0">
|
||||
@@ -273,7 +280,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
{/* Actions */}
|
||||
<CardActions
|
||||
feature={feature}
|
||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||
isCurrentAutoTask={isActivelyRunning}
|
||||
hasContext={hasContext}
|
||||
shortcutKey={shortcutKey}
|
||||
isSelectionMode={isSelectionMode}
|
||||
@@ -297,12 +304,10 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={dndStyle}
|
||||
{...attributes}
|
||||
{...(isDraggable ? listeners : {})}
|
||||
className={wrapperClasses}
|
||||
data-testid={`kanban-card-${feature.id}`}
|
||||
>
|
||||
{isCurrentAutoTask ? (
|
||||
{isActivelyRunning ? (
|
||||
<div className="animated-border-wrapper">{renderCardContent()}</div>
|
||||
) : (
|
||||
renderCardContent()
|
||||
|
||||
@@ -209,6 +209,10 @@ export const ListRow = memo(function ListRow({
|
||||
blockingDependencies = [],
|
||||
className,
|
||||
}: ListRowProps) {
|
||||
// A card in waiting_approval should not display as "actively running" even if
|
||||
// it's still in the runningAutoTasks list. The waiting_approval UI takes precedence.
|
||||
const isActivelyRunning = isCurrentAutoTask && feature.status !== 'waiting_approval';
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Don't trigger row click if clicking on checkbox or actions
|
||||
@@ -349,13 +353,13 @@ export const ListRow = memo(function ListRow({
|
||||
|
||||
{/* Actions column */}
|
||||
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
|
||||
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isCurrentAutoTask} />
|
||||
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isActivelyRunning} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Wrap with animated border for currently running auto task
|
||||
if (isCurrentAutoTask) {
|
||||
if (isActivelyRunning) {
|
||||
return <div className="animated-border-wrapper-row">{rowContent}</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -225,7 +225,13 @@ export function useBoardActions({
|
||||
};
|
||||
const createdFeature = addFeature(newFeatureData);
|
||||
// Must await to ensure feature exists on server before user can drag it
|
||||
await persistFeatureCreate(createdFeature);
|
||||
try {
|
||||
await persistFeatureCreate(createdFeature);
|
||||
} catch (error) {
|
||||
// Remove the feature from state if server creation failed (e.g., duplicate title)
|
||||
removeFeature(createdFeature.id);
|
||||
throw error;
|
||||
}
|
||||
saveCategory(featureData.category);
|
||||
|
||||
// Handle child dependencies - update other features to depend on this new feature
|
||||
@@ -276,6 +282,7 @@ export function useBoardActions({
|
||||
},
|
||||
[
|
||||
addFeature,
|
||||
removeFeature,
|
||||
persistFeatureCreate,
|
||||
persistFeatureUpdate,
|
||||
updateFeature,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createLogger } from '@automaker/utils/logger';
|
||||
import { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||
import { toast } from 'sonner';
|
||||
import { COLUMNS, ColumnId } from '../constants';
|
||||
|
||||
@@ -33,6 +34,7 @@ export function useBoardDragDrop({
|
||||
null
|
||||
);
|
||||
const { moveFeature, updateFeature } = useAppStore();
|
||||
const autoMode = useAutoMode();
|
||||
|
||||
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
|
||||
// at execution time based on feature.branchName
|
||||
@@ -155,19 +157,9 @@ export function useBoardDragDrop({
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if dragging is allowed based on status and skipTests
|
||||
// - Backlog items can always be dragged
|
||||
// - waiting_approval items can always be dragged (to allow manual verification via drag)
|
||||
// - verified items can always be dragged (to allow moving back to waiting_approval)
|
||||
// - in_progress items can be dragged (but not if they're currently running)
|
||||
// - Non-skipTests (TDD) items that are in progress cannot be dragged if they are running
|
||||
if (draggedFeature.status === 'in_progress') {
|
||||
// Only allow dragging in_progress if it's not currently running
|
||||
if (isRunningTask) {
|
||||
logger.debug('Cannot drag feature - currently running');
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Determine if dragging is allowed based on status
|
||||
// Running in_progress features CAN be dragged to backlog (stops the agent)
|
||||
// but cannot be dragged to other columns
|
||||
|
||||
let targetStatus: ColumnId | null = null;
|
||||
|
||||
@@ -235,15 +227,38 @@ export function useBoardDragDrop({
|
||||
} else if (draggedFeature.status === 'in_progress') {
|
||||
// Handle in_progress features being moved
|
||||
if (targetStatus === 'backlog') {
|
||||
// Allow moving in_progress cards back to backlog
|
||||
// If the feature is currently running, stop it first
|
||||
if (isRunningTask) {
|
||||
try {
|
||||
await autoMode.stopFeature(featureId);
|
||||
logger.info('Stopped running feature via drag to backlog:', featureId);
|
||||
} catch (error) {
|
||||
logger.error('Error stopping feature during drag to backlog:', error);
|
||||
toast.error('Failed to stop agent', {
|
||||
description: 'The feature will still be moved to backlog.',
|
||||
});
|
||||
}
|
||||
}
|
||||
moveFeature(featureId, 'backlog');
|
||||
persistFeatureUpdate(featureId, { status: 'backlog' });
|
||||
toast.info('Feature moved to backlog', {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
|
||||
toast.info(
|
||||
isRunningTask
|
||||
? 'Agent stopped and feature moved to backlog'
|
||||
: 'Feature moved to backlog',
|
||||
{
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
|
||||
}
|
||||
);
|
||||
} else if (isRunningTask) {
|
||||
// Running features can only be dragged to backlog, not other columns
|
||||
logger.debug('Cannot drag running feature to', targetStatus);
|
||||
toast.error('Cannot move running feature', {
|
||||
description: 'Stop the agent first or drag to Backlog to stop and move.',
|
||||
});
|
||||
return;
|
||||
} else if (targetStatus === 'verified' && draggedFeature.skipTests) {
|
||||
// Manual verify via drag (only for skipTests features)
|
||||
moveFeature(featureId, 'verified');
|
||||
@@ -310,6 +325,7 @@ export function useBoardDragDrop({
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
handleStartImplementation,
|
||||
autoMode,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -75,26 +75,28 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
);
|
||||
|
||||
// Persist feature creation to API
|
||||
// Throws on failure so callers can handle it (e.g., remove the feature from state)
|
||||
const persistFeatureCreate = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api.features) {
|
||||
throw new Error('Features API not available');
|
||||
}
|
||||
|
||||
// Optimistically add to React Query cache for immediate board refresh
|
||||
queryClient.setQueryData<Feature[]>(
|
||||
queryKeys.features.all(currentProject.path),
|
||||
(existing) => (existing ? [...existing, feature] : [feature])
|
||||
);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.features) {
|
||||
logger.error('Features API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistically add to React Query cache for immediate board refresh
|
||||
queryClient.setQueryData<Feature[]>(
|
||||
queryKeys.features.all(currentProject.path),
|
||||
(existing) => (existing ? [...existing, feature] : [feature])
|
||||
);
|
||||
|
||||
const result = await api.features.create(currentProject.path, feature as ApiFeature);
|
||||
if (result.success && result.feature) {
|
||||
updateFeature(result.feature.id, result.feature as Partial<Feature>);
|
||||
} else if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to create feature on server');
|
||||
}
|
||||
// Always invalidate to sync with server state
|
||||
queryClient.invalidateQueries({
|
||||
@@ -106,6 +108,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[currentProject, updateFeature, queryClient]
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils';
|
||||
|
||||
interface MobileUsageBarProps {
|
||||
showClaudeUsage: boolean;
|
||||
@@ -23,11 +24,15 @@ function UsageBar({
|
||||
label,
|
||||
percentage,
|
||||
isStale,
|
||||
pacePercentage,
|
||||
}: {
|
||||
label: string;
|
||||
percentage: number;
|
||||
isStale: boolean;
|
||||
pacePercentage?: number | null;
|
||||
}) {
|
||||
const paceLabel = pacePercentage != null ? getPaceStatusLabel(percentage, pacePercentage) : null;
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 first:mt-0">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
@@ -49,7 +54,7 @@ function UsageBar({
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'h-1 w-full bg-muted-foreground/10 rounded-full overflow-hidden transition-opacity',
|
||||
'relative h-1 w-full bg-muted-foreground/10 rounded-full overflow-hidden transition-opacity',
|
||||
isStale && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
@@ -57,7 +62,24 @@ function UsageBar({
|
||||
className={cn('h-full transition-all duration-500', getProgressBarColor(percentage))}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
{pacePercentage != null && pacePercentage > 0 && pacePercentage < 100 && (
|
||||
<div
|
||||
className="absolute top-0 h-full w-0.5 bg-foreground/60"
|
||||
style={{ left: `${pacePercentage}%` }}
|
||||
title={`Expected: ${Math.round(pacePercentage)}%`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{paceLabel && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-[9px] mt-0.5',
|
||||
percentage > (pacePercentage ?? 0) ? 'text-orange-500' : 'text-green-500'
|
||||
)}
|
||||
>
|
||||
{paceLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -190,6 +212,7 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB
|
||||
label="Weekly"
|
||||
percentage={claudeUsage.weeklyPercentage}
|
||||
isStale={isClaudeStale}
|
||||
pacePercentage={getExpectedWeeklyPacePercentage(claudeUsage.weeklyResetTime)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -112,6 +112,8 @@ export function WorktreePanel({
|
||||
// Use separate selectors to avoid creating new object references on each render
|
||||
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
|
||||
const currentProject = useAppStore((state) => state.currentProject);
|
||||
const setAutoModeRunning = useAppStore((state) => state.setAutoModeRunning);
|
||||
const getMaxConcurrencyForWorktree = useAppStore((state) => state.getMaxConcurrencyForWorktree);
|
||||
|
||||
// Helper to generate worktree key for auto-mode (inlined to avoid selector issues)
|
||||
const getAutoModeWorktreeKey = useCallback(
|
||||
@@ -137,8 +139,6 @@ export function WorktreePanel({
|
||||
async (worktree: WorktreeInfo) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
// Import the useAutoMode to get start/stop functions
|
||||
// Since useAutoMode is a hook, we'll use the API client directly
|
||||
const api = getHttpApiClient();
|
||||
const branchName = worktree.isMain ? null : worktree.branch;
|
||||
const isRunning = isAutoModeRunningForWorktree(worktree);
|
||||
@@ -147,14 +147,17 @@ export function WorktreePanel({
|
||||
if (isRunning) {
|
||||
const result = await api.autoMode.stop(projectPath, branchName);
|
||||
if (result.success) {
|
||||
setAutoModeRunning(currentProject.id, branchName, false);
|
||||
const desc = branchName ? `worktree ${branchName}` : 'main branch';
|
||||
toast.success(`Auto Mode stopped for ${desc}`);
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to stop Auto Mode');
|
||||
}
|
||||
} else {
|
||||
const result = await api.autoMode.start(projectPath, branchName);
|
||||
const maxConcurrency = getMaxConcurrencyForWorktree(currentProject.id, branchName);
|
||||
const result = await api.autoMode.start(projectPath, branchName, maxConcurrency);
|
||||
if (result.success) {
|
||||
setAutoModeRunning(currentProject.id, branchName, true, maxConcurrency);
|
||||
const desc = branchName ? `worktree ${branchName}` : 'main branch';
|
||||
toast.success(`Auto Mode started for ${desc}`);
|
||||
} else {
|
||||
@@ -166,7 +169,13 @@ export function WorktreePanel({
|
||||
console.error('Auto mode toggle error:', error);
|
||||
}
|
||||
},
|
||||
[currentProject, projectPath, isAutoModeRunningForWorktree]
|
||||
[
|
||||
currentProject,
|
||||
projectPath,
|
||||
isAutoModeRunningForWorktree,
|
||||
setAutoModeRunning,
|
||||
getMaxConcurrencyForWorktree,
|
||||
]
|
||||
);
|
||||
|
||||
// Check if init script exists for the project using React Query
|
||||
|
||||
@@ -313,14 +313,21 @@ export function GraphViewPage() {
|
||||
// Handle add and start feature
|
||||
const handleAddAndStartFeature = useCallback(
|
||||
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
|
||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
||||
await handleAddFeature(featureData);
|
||||
try {
|
||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
||||
await handleAddFeature(featureData);
|
||||
|
||||
const latestFeatures = useAppStore.getState().features;
|
||||
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
||||
const latestFeatures = useAppStore.getState().features;
|
||||
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
||||
|
||||
if (newFeature) {
|
||||
await handleStartImplementation(newFeature);
|
||||
if (newFeature) {
|
||||
await handleStartImplementation(newFeature);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to add and start feature:', error);
|
||||
toast.error(
|
||||
`Failed to add and start feature: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
},
|
||||
[handleAddFeature, handleStartImplementation]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useCallback, useMemo } from 'react';
|
||||
import { useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
|
||||
@@ -11,6 +11,12 @@ import { getGlobalEventsRecent } from '@/hooks/use-event-recency';
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByWorktreeKey';
|
||||
|
||||
function arraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const set = new Set(b);
|
||||
return a.every((id) => set.has(id));
|
||||
}
|
||||
const AUTO_MODE_POLLING_INTERVAL = 30000;
|
||||
|
||||
/**
|
||||
@@ -142,9 +148,16 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
// Check if we can start a new task based on concurrency limit
|
||||
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
|
||||
|
||||
// Ref to prevent refreshStatus from overwriting optimistic state during start/stop
|
||||
const isTransitioningRef = useRef(false);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
// Skip sync when user is in the middle of start/stop - avoids race where
|
||||
// refreshStatus runs before the API call completes and overwrites optimistic state
|
||||
if (isTransitioningRef.current) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.status) return;
|
||||
@@ -152,18 +165,28 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
const result = await api.autoMode.status(currentProject.path, branchName);
|
||||
if (result.success && result.isAutoLoopRunning !== undefined) {
|
||||
const backendIsRunning = result.isAutoLoopRunning;
|
||||
const backendRunningFeatures = result.runningFeatures ?? [];
|
||||
const needsSync =
|
||||
backendIsRunning !== isAutoModeRunning ||
|
||||
// Also sync when backend has runningFeatures we're missing (handles missed WebSocket events)
|
||||
(backendIsRunning &&
|
||||
Array.isArray(backendRunningFeatures) &&
|
||||
backendRunningFeatures.length > 0 &&
|
||||
!arraysEqual(backendRunningFeatures, runningAutoTasks));
|
||||
|
||||
if (backendIsRunning !== isAutoModeRunning) {
|
||||
if (needsSync) {
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
logger.info(
|
||||
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
|
||||
);
|
||||
if (backendIsRunning !== isAutoModeRunning) {
|
||||
logger.info(
|
||||
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
|
||||
);
|
||||
}
|
||||
setAutoModeRunning(
|
||||
currentProject.id,
|
||||
branchName,
|
||||
backendIsRunning,
|
||||
result.maxConcurrency,
|
||||
result.runningFeatures
|
||||
backendRunningFeatures
|
||||
);
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
|
||||
}
|
||||
@@ -171,7 +194,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
} catch (error) {
|
||||
logger.error('Error syncing auto mode state with backend:', error);
|
||||
}
|
||||
}, [branchName, currentProject, isAutoModeRunning, setAutoModeRunning]);
|
||||
}, [branchName, currentProject, isAutoModeRunning, runningAutoTasks, setAutoModeRunning]);
|
||||
|
||||
// On mount, query backend for current auto loop status and sync UI state.
|
||||
// This handles cases where the backend is still running after a page refresh.
|
||||
@@ -558,6 +581,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
isTransitioningRef.current = true;
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.start) {
|
||||
@@ -588,14 +612,18 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
}
|
||||
|
||||
logger.debug(`[AutoMode] Started successfully for ${worktreeDesc}`);
|
||||
// Sync with backend after success (gets runningFeatures if events were delayed)
|
||||
queueMicrotask(() => void refreshStatus());
|
||||
} catch (error) {
|
||||
// Revert UI state on error
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
|
||||
setAutoModeRunning(currentProject.id, branchName, false);
|
||||
logger.error('Error starting auto mode:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
isTransitioningRef.current = false;
|
||||
}
|
||||
}, [currentProject, branchName, setAutoModeRunning]);
|
||||
}, [currentProject, branchName, setAutoModeRunning, getMaxConcurrencyForWorktree]);
|
||||
|
||||
// Stop auto mode - calls backend to stop the auto loop for this worktree
|
||||
const stop = useCallback(async () => {
|
||||
@@ -604,6 +632,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
isTransitioningRef.current = true;
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.stop) {
|
||||
@@ -631,12 +660,16 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
// NOTE: Running tasks will continue until natural completion.
|
||||
// The backend stops picking up new features but doesn't abort running ones.
|
||||
logger.info(`Stopped ${worktreeDesc} - running tasks will continue`);
|
||||
// Sync with backend after success
|
||||
queueMicrotask(() => void refreshStatus());
|
||||
} catch (error) {
|
||||
// Revert UI state on error
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, true);
|
||||
setAutoModeRunning(currentProject.id, branchName, true);
|
||||
logger.error('Error stopping auto mode:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
isTransitioningRef.current = false;
|
||||
}
|
||||
}, [currentProject, branchName, setAutoModeRunning]);
|
||||
|
||||
|
||||
@@ -28,8 +28,11 @@ const PROGRESS_DEBOUNCE_MAX_WAIT = 2000;
|
||||
* feature moving to custom pipeline columns (fixes GitHub issue #668)
|
||||
*/
|
||||
const FEATURE_LIST_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
|
||||
'auto_mode_feature_start',
|
||||
'auto_mode_feature_complete',
|
||||
'auto_mode_error',
|
||||
'auto_mode_started',
|
||||
'auto_mode_stopped',
|
||||
'plan_approval_required',
|
||||
'plan_approved',
|
||||
'plan_rejected',
|
||||
@@ -39,11 +42,11 @@ const FEATURE_LIST_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
|
||||
|
||||
/**
|
||||
* Events that should invalidate a specific feature (features.single query)
|
||||
* Note: pipeline_step_started is NOT included here because it already invalidates
|
||||
* features.all() above, which also invalidates child queries (features.single)
|
||||
* Note: auto_mode_feature_start and pipeline_step_started are NOT included here
|
||||
* because they already invalidate features.all() above, which also invalidates
|
||||
* child queries (features.single)
|
||||
*/
|
||||
const SINGLE_FEATURE_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
|
||||
'auto_mode_feature_start',
|
||||
'auto_mode_phase',
|
||||
'auto_mode_phase_complete',
|
||||
'auto_mode_task_status',
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createLogger } from '@automaker/utils/logger';
|
||||
// Note: setItem/getItem moved to ./utils/theme-utils.ts
|
||||
import { UI_SANS_FONT_OPTIONS, UI_MONO_FONT_OPTIONS } from '@/config/ui-font-options';
|
||||
import type {
|
||||
Feature as BaseFeature,
|
||||
FeatureImagePath,
|
||||
FeatureTextFilePath,
|
||||
ModelAlias,
|
||||
@@ -15,25 +14,11 @@ import type {
|
||||
ThinkingLevel,
|
||||
ReasoningEffort,
|
||||
ModelProvider,
|
||||
CursorModelId,
|
||||
CodexModelId,
|
||||
OpencodeModelId,
|
||||
GeminiModelId,
|
||||
CopilotModelId,
|
||||
PhaseModelConfig,
|
||||
PhaseModelKey,
|
||||
PhaseModelEntry,
|
||||
MCPServerConfig,
|
||||
FeatureStatusWithPipeline,
|
||||
PipelineConfig,
|
||||
PipelineStep,
|
||||
PromptCustomization,
|
||||
ModelDefinition,
|
||||
ServerLogLevel,
|
||||
EventHook,
|
||||
ClaudeApiProfile,
|
||||
ClaudeCompatibleProvider,
|
||||
SidebarStyle,
|
||||
ParsedTask,
|
||||
PlanSpec,
|
||||
} from '@automaker/types';
|
||||
@@ -2131,7 +2116,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
const updateSizes = (layout: TerminalPanelContent): TerminalPanelContent => {
|
||||
if (layout.type === 'split') {
|
||||
// Find matching panels and update sizes
|
||||
const updatedPanels = layout.panels.map((panel, index) => {
|
||||
const updatedPanels = layout.panels.map((panel, _index) => {
|
||||
// Generate key for this panel
|
||||
const panelKey =
|
||||
panel.type === 'split'
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { Project, TrashedProject } from '@/lib/electron';
|
||||
import type {
|
||||
ModelAlias,
|
||||
PlanningMode,
|
||||
ThinkingLevel,
|
||||
ReasoningEffort,
|
||||
ModelProvider,
|
||||
CursorModelId,
|
||||
CodexModelId,
|
||||
@@ -33,7 +31,7 @@ import type {
|
||||
BackgroundSettings,
|
||||
} from './ui-types';
|
||||
import type { ApiKeys } from './settings-types';
|
||||
import type { ChatMessage, ChatSession, FeatureImage } from './chat-types';
|
||||
import type { ChatMessage, ChatSession } from './chat-types';
|
||||
import type { TerminalState, TerminalPanelContent, PersistedTerminalState } from './terminal-types';
|
||||
import type { Feature, ProjectAnalysis } from './project-types';
|
||||
import type { ClaudeUsage, CodexUsage } from './usage-types';
|
||||
|
||||
@@ -1,5 +1,64 @@
|
||||
import type { ClaudeUsage } from '../types/usage-types';
|
||||
|
||||
/**
|
||||
* Calculate the expected weekly usage percentage based on how far through the week we are.
|
||||
* Claude's weekly usage resets every Thursday. Given the reset time (when the NEXT reset occurs),
|
||||
* we can determine how much of the week has elapsed and therefore what percentage of the budget
|
||||
* should have been used if usage were evenly distributed.
|
||||
*
|
||||
* @param weeklyResetTime - ISO date string for when the weekly usage next resets
|
||||
* @returns The expected usage percentage (0-100), or null if the reset time is invalid
|
||||
*/
|
||||
export function getExpectedWeeklyPacePercentage(
|
||||
weeklyResetTime: string | undefined
|
||||
): number | null {
|
||||
if (!weeklyResetTime) return null;
|
||||
|
||||
try {
|
||||
const resetDate = new Date(weeklyResetTime);
|
||||
if (isNaN(resetDate.getTime())) return null;
|
||||
|
||||
const now = new Date();
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// The week started 7 days before the reset
|
||||
const weekStartDate = new Date(resetDate.getTime() - WEEK_MS);
|
||||
|
||||
// How far through the week are we?
|
||||
const elapsed = now.getTime() - weekStartDate.getTime();
|
||||
const fractionElapsed = elapsed / WEEK_MS;
|
||||
|
||||
// Clamp to 0-1 range
|
||||
const clamped = Math.max(0, Math.min(1, fractionElapsed));
|
||||
|
||||
return clamped * 100;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable label for the pace status (ahead or behind expected usage).
|
||||
*
|
||||
* @param actualPercentage - The actual usage percentage (0-100)
|
||||
* @param expectedPercentage - The expected usage percentage (0-100)
|
||||
* @returns A string like "5% ahead of pace" or "10% behind pace", or null
|
||||
*/
|
||||
export function getPaceStatusLabel(
|
||||
actualPercentage: number,
|
||||
expectedPercentage: number | null
|
||||
): string | null {
|
||||
if (expectedPercentage === null) return null;
|
||||
|
||||
const diff = Math.round(actualPercentage - expectedPercentage);
|
||||
|
||||
if (diff === 0) return 'On pace';
|
||||
// Using more than expected = behind pace (bad)
|
||||
if (diff > 0) return `${Math.abs(diff)}% behind pace`;
|
||||
// Using less than expected = ahead of pace (good)
|
||||
return `${Math.abs(diff)}% ahead of pace`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit)
|
||||
* Returns true if any limit is reached, meaning auto mode should pause feature pickup.
|
||||
|
||||
Reference in New Issue
Block a user