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:
gsxdsm
2026-02-16 23:28:32 -08:00
97 changed files with 16957 additions and 8206 deletions

View File

@@ -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;
}
});

View File

@@ -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>

View File

@@ -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)}

View File

@@ -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

View File

@@ -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>
);
});

View File

@@ -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>

View File

@@ -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()

View File

@@ -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>;
}

View File

@@ -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,

View File

@@ -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,
]
);

View File

@@ -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]

View File

@@ -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)}
/>
</>
) : (

View File

@@ -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

View File

@@ -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]