Add concurrency slider for automode to control max parallel agents

- Added maxConcurrency state to app-store with persistence
- Created slider UI component using Radix UI
- Added concurrency slider to board-view header (left of Auto Mode button)
- Updated use-auto-mode hook to expose canStartNewTask based on limit
- Block dragging features to in_progress when at max concurrency
- Added test utilities for concurrency slider interactions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cody Seibert
2025-12-09 00:54:26 -05:00
parent 2822cdfc32
commit 479b2545e5
8 changed files with 466 additions and 4 deletions

View File

@@ -0,0 +1,27 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ComponentRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-white/10">
<SliderPrimitive.Range className="absolute h-full bg-gradient-to-r from-purple-600 to-blue-600" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-white/20 bg-zinc-800 shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/50 disabled:pointer-events-none disabled:opacity-50 hover:bg-zinc-700" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -43,7 +43,8 @@ import { KanbanColumn } from "./kanban-column";
import { KanbanCard } from "./kanban-card";
import { AutoModeLog } from "./auto-mode-log";
import { AgentOutputModal } from "./agent-output-modal";
import { Plus, RefreshCw, Play, StopCircle, Loader2, ChevronUp, ChevronDown } from "lucide-react";
import { Plus, RefreshCw, Play, StopCircle, Loader2, ChevronUp, ChevronDown, Users } from "lucide-react";
import { Slider } from "@/components/ui/slider";
import { useAutoMode } from "@/hooks/use-auto-mode";
type ColumnId = Feature["status"];
@@ -64,6 +65,8 @@ export function BoardView() {
removeFeature,
moveFeature,
runningAutoTasks,
maxConcurrency,
setMaxConcurrency,
} = useAppStore();
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
@@ -186,6 +189,34 @@ export function BoardView() {
loadFeatures();
}, [loadFeatures]);
// Sync running tasks from electron backend on mount
useEffect(() => {
const syncRunningTasks = async () => {
try {
const api = getElectronAPI();
if (!api?.autoMode?.status) return;
const status = await api.autoMode.status();
if (status.success && status.runningFeatures) {
console.log("[Board] Syncing running tasks from backend:", status.runningFeatures);
// Clear existing running tasks and add the actual running ones
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
clearRunningTasks();
// Add each running feature to the store
status.runningFeatures.forEach((featureId: string) => {
addRunningTask(featureId);
});
}
} catch (error) {
console.error("[Board] Failed to sync running tasks:", error);
}
};
syncRunningTasks();
}, []);
// Check which features have context files
useEffect(() => {
const checkAllContexts = async () => {
@@ -284,6 +315,12 @@ export function BoardView() {
if (!targetStatus) return;
// Check concurrency limit before moving to in_progress
if (targetStatus === "in_progress" && !autoMode.canStartNewTask) {
console.log("[Board] Cannot start new task - at max concurrency limit");
return;
}
// Move the feature
moveFeature(featureId, targetStatus);
@@ -482,7 +519,32 @@ export function BoardView() {
<h1 className="text-xl font-bold">Kanban Board</h1>
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
</div>
<div className="flex gap-2">
<div className="flex gap-2 items-center">
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
{isMounted && (
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10"
data-testid="concurrency-slider-container"
>
<Users className="w-4 h-4 text-zinc-400" />
<Slider
value={[maxConcurrency]}
onValueChange={(value) => setMaxConcurrency(value[0])}
min={1}
max={10}
step={1}
className="w-20"
data-testid="concurrency-slider"
/>
<span
className="text-sm text-zinc-400 min-w-[2ch] text-center"
data-testid="concurrency-value"
>
{maxConcurrency}
</span>
</div>
)}
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
<>