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 && (
<>

View File

@@ -17,6 +17,7 @@ export function useAutoMode() {
clearRunningTasks,
currentProject,
addAutoModeActivity,
maxConcurrency,
} = useAppStore(
useShallow((state) => ({
isAutoModeRunning: state.isAutoModeRunning,
@@ -27,9 +28,13 @@ export function useAutoMode() {
clearRunningTasks: state.clearRunningTasks,
currentProject: state.currentProject,
addAutoModeActivity: state.addAutoModeActivity,
maxConcurrency: state.maxConcurrency,
}))
);
// Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
// Handle auto mode events
useEffect(() => {
const api = getElectronAPI();
@@ -178,6 +183,8 @@ export function useAutoMode() {
return {
isRunning: isAutoModeRunning,
runningTasks: runningAutoTasks,
maxConcurrency,
canStartNewTask,
start,
stop,
};

View File

@@ -107,6 +107,7 @@ export interface AppState {
isAutoModeRunning: boolean;
runningAutoTasks: string[]; // Feature IDs being worked on (supports concurrent tasks)
autoModeActivityLog: AutoModeActivity[];
maxConcurrency: number; // Maximum number of concurrent agent tasks
}
export interface AutoModeActivity {
@@ -174,6 +175,7 @@ export interface AppActions {
clearRunningTasks: () => void;
addAutoModeActivity: (activity: Omit<AutoModeActivity, "id" | "timestamp">) => void;
clearAutoModeActivity: () => void;
setMaxConcurrency: (max: number) => void;
// Reset
reset: () => void;
@@ -200,6 +202,7 @@ const initialState: AppState = {
isAutoModeRunning: false,
runningAutoTasks: [],
autoModeActivityLog: [],
maxConcurrency: 3, // Default to 3 concurrent agents
};
export const useAppStore = create<AppState & AppActions>()(
@@ -417,6 +420,8 @@ export const useAppStore = create<AppState & AppActions>()(
clearAutoModeActivity: () => set({ autoModeActivityLog: [] }),
setMaxConcurrency: (max) => set({ maxConcurrency: max }),
// Reset
reset: () => set(initialState),
}),
@@ -430,6 +435,7 @@ export const useAppStore = create<AppState & AppActions>()(
apiKeys: state.apiKeys,
chatSessions: state.chatSessions,
chatHistoryOpen: state.chatHistoryOpen,
maxConcurrency: state.maxConcurrency,
}),
}
)