diff --git a/.automaker/feature_list.json b/.automaker/feature_list.json index e8cb6f8f..c1cc17a4 100644 --- a/.automaker/feature_list.json +++ b/.automaker/feature_list.json @@ -34,13 +34,13 @@ "3. wait until it is moved to verified", "4. assert modal is hidden" ], - "status": "backlog" + "status": "in_progress" }, { "id": "feature-1765254432072-bqk25kivv", "category": "Automode", "description": "Add a concurrency slider left of automode so I can specify how many max agents should be running at one time. if we are at max, do not pull over more tasks from the backlog", "steps": [], - "status": "backlog" + "status": "verified" } ] \ No newline at end of file diff --git a/app/package-lock.json b/app/package-lock.json index 6ddc95ad..e60b551d 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -15,6 +15,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-query": "^5.90.12", @@ -2522,6 +2523,12 @@ "node": ">=18" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -3064,6 +3071,39 @@ } } }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", @@ -3197,6 +3237,21 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", diff --git a/app/package.json b/app/package.json index ea35e581..328d2470 100644 --- a/app/package.json +++ b/app/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-query": "^5.90.12", diff --git a/app/src/components/ui/slider.tsx b/app/src/components/ui/slider.tsx new file mode 100644 index 00000000..a367c2b6 --- /dev/null +++ b/app/src/components/ui/slider.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; diff --git a/app/src/components/views/board-view.tsx b/app/src/components/views/board-view.tsx index be2675ab..8cfee1c4 100644 --- a/app/src/components/views/board-view.tsx +++ b/app/src/components/views/board-view.tsx @@ -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(null); const [editingFeature, setEditingFeature] = useState(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() {

Kanban Board

{currentProject.name}

-
+
+ {/* Concurrency Slider - only show after mount to prevent hydration issues */} + {isMounted && ( +
+ + setMaxConcurrency(value[0])} + min={1} + max={10} + step={1} + className="w-20" + data-testid="concurrency-slider" + /> + + {maxConcurrency} + +
+ )} + {/* Auto Mode Toggle - only show after mount to prevent hydration issues */} {isMounted && ( <> diff --git a/app/src/hooks/use-auto-mode.ts b/app/src/hooks/use-auto-mode.ts index ed4a4cf8..b86bdb3f 100644 --- a/app/src/hooks/use-auto-mode.ts +++ b/app/src/hooks/use-auto-mode.ts @@ -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, }; diff --git a/app/src/store/app-store.ts b/app/src/store/app-store.ts index 73eba77b..b160e4cb 100644 --- a/app/src/store/app-store.ts +++ b/app/src/store/app-store.ts @@ -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) => 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()( @@ -417,6 +420,8 @@ export const useAppStore = create()( clearAutoModeActivity: () => set({ autoModeActivityLog: [] }), + setMaxConcurrency: (max) => set({ maxConcurrency: max }), + // Reset reset: () => set(initialState), }), @@ -430,6 +435,7 @@ export const useAppStore = create()( apiKeys: state.apiKeys, chatSessions: state.chatSessions, chatHistoryOpen: state.chatHistoryOpen, + maxConcurrency: state.maxConcurrency, }), } ) diff --git a/app/tests/utils.ts b/app/tests/utils.ts new file mode 100644 index 00000000..c5563ee8 --- /dev/null +++ b/app/tests/utils.ts @@ -0,0 +1,304 @@ +import { Page, Locator, expect } from "@playwright/test"; + +/** + * Get an element by its data-testid attribute + */ +export async function getByTestId( + page: Page, + testId: string +): Promise { + return page.locator(`[data-testid="${testId}"]`); +} + +/** + * Set up a mock project in localStorage to bypass the welcome screen + * This simulates having opened a project before + */ +export async function setupMockProject(page: Page): Promise { + await page.addInitScript(() => { + const mockProject = { + id: "test-project-1", + name: "Test Project", + path: "/mock/test-project", + lastOpened: new Date().toISOString(), + }; + + const mockState = { + state: { + projects: [mockProject], + currentProject: mockProject, + theme: "dark", + sidebarOpen: true, + apiKeys: { anthropic: "", google: "" }, + chatSessions: [], + chatHistoryOpen: false, + maxConcurrency: 3, + }, + version: 0, + }; + + localStorage.setItem("automaker-storage", JSON.stringify(mockState)); + }); +} + +/** + * Click an element by its data-testid attribute + */ +export async function clickElement(page: Page, testId: string): Promise { + const element = await getByTestId(page, testId); + await element.click(); +} + +/** + * Wait for an element with a specific data-testid to appear + */ +export async function waitForElement( + page: Page, + testId: string, + options?: { timeout?: number; state?: "attached" | "visible" | "hidden" } +): Promise { + const element = page.locator(`[data-testid="${testId}"]`); + await element.waitFor({ + timeout: options?.timeout ?? 5000, + state: options?.state ?? "visible", + }); + return element; +} + +/** + * Wait for an element with a specific data-testid to be hidden + */ +export async function waitForElementHidden( + page: Page, + testId: string, + options?: { timeout?: number } +): Promise { + const element = page.locator(`[data-testid="${testId}"]`); + await element.waitFor({ + timeout: options?.timeout ?? 5000, + state: "hidden", + }); +} + +/** + * Get a button by its text content + */ +export async function getButtonByText( + page: Page, + text: string +): Promise { + return page.locator(`button:has-text("${text}")`); +} + +/** + * Click a button by its text content + */ +export async function clickButtonByText( + page: Page, + text: string +): Promise { + const button = await getButtonByText(page, text); + await button.click(); +} + +/** + * Fill an input field by its data-testid attribute + */ +export async function fillInput( + page: Page, + testId: string, + value: string +): Promise { + const input = await getByTestId(page, testId); + await input.fill(value); +} + +/** + * Navigate to the board/kanban view + */ +export async function navigateToBoard(page: Page): Promise { + await page.goto("/"); + + // Wait for the page to load + await page.waitForLoadState("networkidle"); + + // Check if we're on the board view already + const boardView = page.locator('[data-testid="board-view"]'); + const isOnBoard = await boardView.isVisible().catch(() => false); + + if (!isOnBoard) { + // Try to click on a recent project first (from welcome screen) + const recentProject = page.locator('p:has-text("Test Project")').first(); + if (await recentProject.isVisible().catch(() => false)) { + await recentProject.click(); + await page.waitForTimeout(200); + } + + // Then click on Kanban Board nav button to ensure we're on the board + const kanbanNav = page.locator('[data-testid="nav-board"]'); + if (await kanbanNav.isVisible().catch(() => false)) { + await kanbanNav.click(); + } + } + + // Wait for the board view to be visible + await waitForElement(page, "board-view", { timeout: 10000 }); +} + +/** + * Check if the agent output modal is visible + */ +export async function isAgentOutputModalVisible(page: Page): Promise { + const modal = page.locator('[data-testid="agent-output-modal"]'); + return await modal.isVisible(); +} + +/** + * Wait for the agent output modal to be visible + */ +export async function waitForAgentOutputModal( + page: Page, + options?: { timeout?: number } +): Promise { + return await waitForElement(page, "agent-output-modal", options); +} + +/** + * Wait for the agent output modal to be hidden + */ +export async function waitForAgentOutputModalHidden( + page: Page, + options?: { timeout?: number } +): Promise { + await waitForElementHidden(page, "agent-output-modal", options); +} + +/** + * Drag a kanban card from one column to another + */ +export async function dragKanbanCard( + page: Page, + featureId: string, + targetColumnId: string +): Promise { + const card = page.locator(`[data-testid="kanban-card-${featureId}"]`); + const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`); + const targetColumn = page.locator(`[data-testid="kanban-column-${targetColumnId}"]`); + + // Perform drag and drop + await dragHandle.dragTo(targetColumn); +} + +/** + * Get a kanban card by feature ID + */ +export async function getKanbanCard( + page: Page, + featureId: string +): Promise { + return page.locator(`[data-testid="kanban-card-${featureId}"]`); +} + +/** + * Click the view output button on a kanban card + */ +export async function clickViewOutput( + page: Page, + featureId: string +): Promise { + // Try the running version first, then the in-progress version + const runningBtn = page.locator(`[data-testid="view-output-${featureId}"]`); + const inProgressBtn = page.locator( + `[data-testid="view-output-inprogress-${featureId}"]` + ); + + if (await runningBtn.isVisible()) { + await runningBtn.click(); + } else if (await inProgressBtn.isVisible()) { + await inProgressBtn.click(); + } else { + throw new Error(`View output button not found for feature ${featureId}`); + } +} + +/** + * Get the concurrency slider container + */ +export async function getConcurrencySliderContainer( + page: Page +): Promise { + return page.locator('[data-testid="concurrency-slider-container"]'); +} + +/** + * Get the concurrency slider + */ +export async function getConcurrencySlider(page: Page): Promise { + return page.locator('[data-testid="concurrency-slider"]'); +} + +/** + * Get the displayed concurrency value + */ +export async function getConcurrencyValue(page: Page): Promise { + const valueElement = page.locator('[data-testid="concurrency-value"]'); + return await valueElement.textContent(); +} + +/** + * Change the concurrency slider value by clicking on the slider track + */ +export async function setConcurrencyValue( + page: Page, + targetValue: number, + min: number = 1, + max: number = 10 +): Promise { + const slider = page.locator('[data-testid="concurrency-slider"]'); + const sliderBounds = await slider.boundingBox(); + + if (!sliderBounds) { + throw new Error("Concurrency slider not found or not visible"); + } + + // Calculate position for target value + const percentage = (targetValue - min) / (max - min); + const targetX = sliderBounds.x + sliderBounds.width * percentage; + const centerY = sliderBounds.y + sliderBounds.height / 2; + + // Click at the target position to set the value + await page.mouse.click(targetX, centerY); +} + +/** + * Set up a mock project with custom concurrency value + */ +export async function setupMockProjectWithConcurrency( + page: Page, + concurrency: number +): Promise { + await page.addInitScript((maxConcurrency: number) => { + const mockProject = { + id: "test-project-1", + name: "Test Project", + path: "/mock/test-project", + lastOpened: new Date().toISOString(), + }; + + const mockState = { + state: { + projects: [mockProject], + currentProject: mockProject, + theme: "dark", + sidebarOpen: true, + apiKeys: { anthropic: "", google: "" }, + chatSessions: [], + chatHistoryOpen: false, + maxConcurrency: maxConcurrency, + }, + version: 0, + }; + + localStorage.setItem("automaker-storage", JSON.stringify(mockState)); + }, concurrency); +}