diff --git a/.automaker/feature_list.json b/.automaker/feature_list.json index d0d44f89..d39ae6a5 100644 --- a/.automaker/feature_list.json +++ b/.automaker/feature_list.json @@ -1,53 +1,70 @@ [ - { - "id": "feature-1765252193603-eb6fx2zcy", - "category": "UI", - "description": "change the description in add new feature modal to a textarea", - "steps": [ - "go to kanban view", - "click new feature button", - "verify description is textarea" - ], - "status": "verified" - }, - { - "id": "feature-1765252237454-1gudpwx26", - "category": "Kanban", - "description": "change category to a typeahead and save the category of the feature inside the feature_list.json", - "steps": [], - "status": "verified" - }, - { - "id": "feature-1765252262937-bt0wotam8", - "category": "Kanban", - "description": "Deleting a feature should show a confirm dialog", - "steps": [], - "status": "verified" - }, - { - "id": "feature-1765252502536-t11kphnca", - "category": "Kanban", - "description": "If i have the output of a feature open while it's in progress, then it gets verified, automatically close the output modal", - "steps": [ - "1. drag YOLO11 to in progress", - "2. open the output modal", - "3. wait until it is moved to verified", - "4. assert modal is hidden" - ], - "status": "verified" - }, - { - "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": "verified" - }, { "id": "feature-1765259922422-d61lu00sq", "category": "Core", "description": "add a context feature / route which allows users to upload files or images or text which will persist to .automaker/context. there should be a left panel with all context files and a text editor or image previewer that lets users view edit delete the context. include the context in every single coding prompt or improve the coding_prompt.md to have a phase where it loads in that context", "steps": [], "status": "in_progress" + }, + { + "id": "feature-1765260287663-pnwg0wfgz", + "category": "Agent Runner", + "description": "When I archived a session I had selected, I'd expect it to unselect it", + "steps": [ + "1. create a session", + "2. select it", + "3. archive it", + "4. expect empty state placeholder in right panel" + ], + "status": "backlog" + }, + { + "id": "feature-1765260557163-86b3tby5d", + "category": "Core", + "description": "Remove analysis link and related code, it's not useful", + "steps": [], + "status": "backlog" + }, + { + "id": "feature-1765260608543-frhplaxss", + "category": "Kanban", + "description": "when clicking a value in the typeahead, there is a bug where it does not close automatically, fix this", + "steps": [], + "status": "backlog" + }, + { + "id": "feature-1765260671085-7dgotl21h", + "category": "Kanban", + "description": "show a error toast when concurrency limit is hit and someone tries to drag a card into in progress to give them feedback why it won't work.", + "steps": [], + "status": "verified" + }, + { + "id": "feature-1765260791341-iaxxt172n", + "category": "Kanban", + "description": "Add a way to force stop an agent on a card which is currently running", + "steps": [], + "status": "in_progress" + }, + { + "id": "feature-1765260864296-98yunv0vj", + "category": "Kanban", + "description": "Remove drag icon from cards when in in progress or verified. also add a timer that tracks how long it has been since the agent started, a count up timer basically formatted 00:00", + "steps": [], + "status": "backlog" + }, + { + "id": "feature-1765260912320-p7d5eang8", + "category": "Kanban", + "description": "add a count up timer for showing how long the card has been in progress", + "steps": [], + "status": "backlog" + }, + { + "id": "feature-1765261027396-b78maajg7", + "category": "Kanban", + "description": "When the agent is marked as verified, remove their context file", + "steps": [], + "status": "backlog" } ] \ No newline at end of file diff --git a/app/package-lock.json b/app/package-lock.json index e60b551d..b6939ecd 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -25,6 +25,7 @@ "next": "16.0.7", "react": "19.2.0", "react-dom": "19.2.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zustand": "^5.0.9" }, @@ -11150,6 +11151,16 @@ "node": ">= 6.0.0" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/app/package.json b/app/package.json index 328d2470..ea905db3 100644 --- a/app/package.json +++ b/app/package.json @@ -32,6 +32,7 @@ "next": "16.0.7", "react": "19.2.0", "react-dom": "19.2.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zustand": "^5.0.9" }, diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx index fa570640..b968ddf9 100644 --- a/app/src/app/layout.tsx +++ b/app/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { Toaster } from "sonner"; import "./globals.css"; const geistSans = Geist({ @@ -28,6 +29,7 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased dark`} > {children} + ); diff --git a/app/src/components/views/board-view.tsx b/app/src/components/views/board-view.tsx index 8cfee1c4..9694b6be 100644 --- a/app/src/components/views/board-view.tsx +++ b/app/src/components/views/board-view.tsx @@ -44,6 +44,7 @@ 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, Users } from "lucide-react"; +import { toast } from "sonner"; import { Slider } from "@/components/ui/slider"; import { useAutoMode } from "@/hooks/use-auto-mode"; @@ -318,6 +319,9 @@ export function BoardView() { // 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"); + toast.error("Concurrency limit reached", { + description: `You can only have ${autoMode.maxConcurrency} task${autoMode.maxConcurrency > 1 ? "s" : ""} running at a time. Wait for a task to complete or increase the limit.`, + }); return; } @@ -486,6 +490,22 @@ export function BoardView() { setShowOutputModal(true); }; + const handleForceStopFeature = async (feature: Feature) => { + try { + await autoMode.stopFeature(feature.id); + // Move the feature back to backlog status after stopping + moveFeature(feature.id, "backlog"); + toast.success("Agent stopped", { + description: `Stopped working on: ${feature.description.slice(0, 50)}${feature.description.length > 50 ? "..." : ""}`, + }); + } catch (error) { + console.error("[Board] Error stopping feature:", error); + toast.error("Failed to stop agent", { + description: error instanceof Error ? error.message : "An error occurred", + }); + } + }; + if (!currentProject) { return (
handleViewOutput(feature)} onVerify={() => handleVerifyFeature(feature)} onResume={() => handleResumeFeature(feature)} + onForceStop={() => handleForceStopFeature(feature)} hasContext={featuresWithContext.has(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)} /> diff --git a/app/tests/utils.ts b/app/tests/utils.ts index 230bf6c0..dc0f7ccf 100644 --- a/app/tests/utils.ts +++ b/app/tests/utils.ts @@ -335,3 +335,184 @@ export async function setupMockProjectWithConcurrency( localStorage.setItem("automaker-storage", JSON.stringify(mockState)); }, concurrency); } + +/** + * Navigate to the context view + */ +export async function navigateToContext(page: Page): Promise { + await page.goto("/"); + + // Wait for the page to load + await page.waitForLoadState("networkidle"); + + // Click on the Context nav button + const contextNav = page.locator('[data-testid="nav-context"]'); + if (await contextNav.isVisible().catch(() => false)) { + await contextNav.click(); + } + + // Wait for the context view to be visible + await waitForElement(page, "context-view", { timeout: 10000 }); +} + +/** + * Get the context file list element + */ +export async function getContextFileList(page: Page): Promise { + return page.locator('[data-testid="context-file-list"]'); +} + +/** + * Click on a context file in the list + */ +export async function clickContextFile( + page: Page, + fileName: string +): Promise { + const fileButton = page.locator(`[data-testid="context-file-${fileName}"]`); + await fileButton.click(); +} + +/** + * Get the context editor element + */ +export async function getContextEditor(page: Page): Promise { + return page.locator('[data-testid="context-editor"]'); +} + +/** + * Open the add context file dialog + */ +export async function openAddContextFileDialog(page: Page): Promise { + await clickElement(page, "add-context-file"); + await waitForElement(page, "add-context-dialog"); +} + +/** + * Wait for an error toast to appear with specific text + */ +export async function waitForErrorToast( + page: Page, + titleText?: string, + options?: { timeout?: number } +): Promise { + // Sonner toasts use data-sonner-toast and data-type="error" for error toasts + const toastSelector = titleText + ? `[data-sonner-toast][data-type="error"]:has-text("${titleText}")` + : '[data-sonner-toast][data-type="error"]'; + + const toast = page.locator(toastSelector).first(); + await toast.waitFor({ + timeout: options?.timeout ?? 5000, + state: "visible", + }); + return toast; +} + +/** + * Check if an error toast is visible + */ +export async function isErrorToastVisible( + page: Page, + titleText?: string +): Promise { + const toastSelector = titleText + ? `[data-sonner-toast][data-type="error"]:has-text("${titleText}")` + : '[data-sonner-toast][data-type="error"]'; + + const toast = page.locator(toastSelector).first(); + return await toast.isVisible(); +} + +/** + * Set up a mock project with specific running tasks to simulate concurrency limit + */ +export async function setupMockProjectAtConcurrencyLimit( + page: Page, + maxConcurrency: number = 1, + runningTasks: string[] = ["running-task-1"] +): Promise { + await page.addInitScript( + ({ maxConcurrency, runningTasks }: { maxConcurrency: number; runningTasks: string[] }) => { + 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, + isAutoModeRunning: false, + runningAutoTasks: runningTasks, + autoModeActivityLog: [], + }, + version: 0, + }; + + localStorage.setItem("automaker-storage", JSON.stringify(mockState)); + }, + { maxConcurrency, runningTasks } + ); +} + +/** + * Get the force stop button for a specific feature + */ +export async function getForceStopButton( + page: Page, + featureId: string +): Promise { + return page.locator(`[data-testid="force-stop-${featureId}"]`); +} + +/** + * Click the force stop button for a specific feature + */ +export async function clickForceStop( + page: Page, + featureId: string +): Promise { + const button = page.locator(`[data-testid="force-stop-${featureId}"]`); + await button.click(); +} + +/** + * Check if the force stop button is visible for a feature + */ +export async function isForceStopButtonVisible( + page: Page, + featureId: string +): Promise { + const button = page.locator(`[data-testid="force-stop-${featureId}"]`); + return await button.isVisible(); +} + +/** + * Wait for a success toast to appear with specific text + */ +export async function waitForSuccessToast( + page: Page, + titleText?: string, + options?: { timeout?: number } +): Promise { + // Sonner toasts use data-sonner-toast and data-type="success" for success toasts + const toastSelector = titleText + ? `[data-sonner-toast][data-type="success"]:has-text("${titleText}")` + : '[data-sonner-toast][data-type="success"]'; + + const toast = page.locator(toastSelector).first(); + await toast.waitFor({ + timeout: options?.timeout ?? 5000, + state: "visible", + }); + return toast; +}