diff --git a/.github/scripts/upload-to-r2.js b/.github/scripts/upload-to-r2.js index 336069cb..67940265 100644 --- a/.github/scripts/upload-to-r2.js +++ b/.github/scripts/upload-to-r2.js @@ -1,10 +1,14 @@ -const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3'); -const fs = require('fs'); -const path = require('path'); +const { + S3Client, + PutObjectCommand, + GetObjectCommand, +} = require("@aws-sdk/client-s3"); +const fs = require("fs"); +const path = require("path"); const s3Client = new S3Client({ - region: 'auto', - endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + region: "auto", + endpoint: process.env.R2_ENDPOINT, credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY, @@ -18,15 +22,17 @@ const GITHUB_REPO = process.env.GITHUB_REPOSITORY; async function fetchExistingReleases() { try { - const response = await s3Client.send(new GetObjectCommand({ - Bucket: BUCKET, - Key: 'releases.json', - })); + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: BUCKET, + Key: "releases.json", + }) + ); const body = await response.Body.transformToString(); return JSON.parse(body); } catch (error) { - if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) { - console.log('No existing releases.json found, creating new one'); + if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) { + console.log("No existing releases.json found, creating new one"); return { latestVersion: null, releases: [] }; } throw error; @@ -37,12 +43,14 @@ async function uploadFile(localPath, r2Key, contentType) { const fileBuffer = fs.readFileSync(localPath); const stats = fs.statSync(localPath); - await s3Client.send(new PutObjectCommand({ - Bucket: BUCKET, - Key: r2Key, - Body: fileBuffer, - ContentType: contentType, - })); + await s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: r2Key, + Body: fileBuffer, + ContentType: contentType, + }) + ); console.log(`Uploaded: ${r2Key} (${stats.size} bytes)`); return stats.size; @@ -51,44 +59,44 @@ async function uploadFile(localPath, r2Key, contentType) { function findArtifacts(dir, pattern) { if (!fs.existsSync(dir)) return []; const files = fs.readdirSync(dir); - return files.filter(f => pattern.test(f)).map(f => path.join(dir, f)); + return files.filter((f) => pattern.test(f)).map((f) => path.join(dir, f)); } async function main() { - const artifactsDir = 'artifacts'; + const artifactsDir = "artifacts"; // Find all artifacts const artifacts = { - windows: findArtifacts( - path.join(artifactsDir, 'windows-builds'), - /\.exe$/ - ), - macos: findArtifacts( - path.join(artifactsDir, 'macos-builds'), - /-x64\.dmg$/ - ), + windows: findArtifacts(path.join(artifactsDir, "windows-builds"), /\.exe$/), + macos: findArtifacts(path.join(artifactsDir, "macos-builds"), /-x64\.dmg$/), macosArm: findArtifacts( - path.join(artifactsDir, 'macos-builds'), + path.join(artifactsDir, "macos-builds"), /-arm64\.dmg$/ ), linux: findArtifacts( - path.join(artifactsDir, 'linux-builds'), + path.join(artifactsDir, "linux-builds"), /\.AppImage$/ ), }; - console.log('Found artifacts:'); + console.log("Found artifacts:"); for (const [platform, files] of Object.entries(artifacts)) { - console.log(` ${platform}: ${files.length > 0 ? files.map(f => path.basename(f)).join(', ') : 'none'}`); + console.log( + ` ${platform}: ${ + files.length > 0 + ? files.map((f) => path.basename(f)).join(", ") + : "none" + }` + ); } // Upload each artifact to R2 const assets = {}; const contentTypes = { - windows: 'application/x-msdownload', - macos: 'application/x-apple-diskimage', - macosArm: 'application/x-apple-diskimage', - linux: 'application/x-executable', + windows: "application/x-msdownload", + macos: "application/x-apple-diskimage", + macosArm: "application/x-apple-diskimage", + linux: "application/x-executable", }; for (const [platform, files] of Object.entries(artifacts)) { @@ -107,7 +115,7 @@ async function main() { url: `${PUBLIC_URL}/releases/${VERSION}/${filename}`, filename, size, - arch: platform === 'macosArm' ? 'arm64' : 'x64', + arch: platform === "macosArm" ? "arm64" : "x64", }; } @@ -122,27 +130,31 @@ async function main() { }; // Remove existing entry for this version if re-running - releasesData.releases = releasesData.releases.filter(r => r.version !== VERSION); + releasesData.releases = releasesData.releases.filter( + (r) => r.version !== VERSION + ); // Prepend new release releasesData.releases.unshift(newRelease); releasesData.latestVersion = VERSION; // Upload updated releases.json - await s3Client.send(new PutObjectCommand({ - Bucket: BUCKET, - Key: 'releases.json', - Body: JSON.stringify(releasesData, null, 2), - ContentType: 'application/json', - CacheControl: 'public, max-age=60', - })); + await s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: "releases.json", + Body: JSON.stringify(releasesData, null, 2), + ContentType: "application/json", + CacheControl: "public, max-age=60", + }) + ); - console.log('Successfully updated releases.json'); + console.log("Successfully updated releases.json"); console.log(`Latest version: ${VERSION}`); console.log(`Total releases: ${releasesData.releases.length}`); } -main().catch(err => { - console.error('Failed to upload to R2:', err); +main().catch((err) => { + console.error("Failed to upload to R2:", err); process.exit(1); }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5135a73b..11abdcd3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,7 +129,7 @@ jobs: - name: Upload to R2 and update releases.json env: - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }} diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx index 222e54e2..e659b282 100644 --- a/apps/app/src/components/layout/sidebar.tsx +++ b/apps/app/src/components/layout/sidebar.tsx @@ -42,6 +42,7 @@ import { Search, Bug, Activity, + Recycle, } from "lucide-react"; import { DropdownMenu, @@ -70,7 +71,7 @@ import { useKeyboardShortcutsConfig, KeyboardShortcut, } from "@/hooks/use-keyboard-shortcuts"; -import { getElectronAPI, Project, TrashedProject } from "@/lib/electron"; +import { getElectronAPI, Project, TrashedProject, RunningAgent } from "@/lib/electron"; import { initializeProject, hasAppSpec, @@ -80,6 +81,7 @@ import { toast } from "sonner"; import { Sparkles, Loader2 } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import type { SpecRegenerationEvent } from "@/types/electron"; +import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog"; import { DndContext, DragEndEvent, @@ -212,6 +214,7 @@ export function Sidebar() { setProjectTheme, setTheme, theme: globalTheme, + moveProjectToTrash, } = useAppStore(); // Get customizable keyboard shortcuts @@ -225,6 +228,12 @@ export function Sidebar() { const [activeTrashId, setActiveTrashId] = useState(null); const [isEmptyingTrash, setIsEmptyingTrash] = useState(false); + // State for delete project confirmation dialog + const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); + + // State for running agents count + const [runningAgentsCount, setRunningAgentsCount] = useState(0); + // State for new project setup dialog const [showSetupDialog, setShowSetupDialog] = useState(false); const [setupProjectPath, setSetupProjectPath] = useState(""); @@ -334,6 +343,64 @@ export function Sidebar() { }; }, [setCurrentView]); + // Fetch running agents count and update every 2 seconds + useEffect(() => { + const fetchRunningAgentsCount = async () => { + try { + const api = getElectronAPI(); + if (api.runningAgents) { + const result = await api.runningAgents.getAll(); + if (result.success && result.runningAgents) { + setRunningAgentsCount(result.runningAgents.length); + } + } + } catch (error) { + console.error("[Sidebar] Error fetching running agents count:", error); + } + }; + + // Initial fetch + fetchRunningAgentsCount(); + + // Set up interval to refresh every 2 seconds + const interval = setInterval(fetchRunningAgentsCount, 2000); + + return () => clearInterval(interval); + }, []); + + // Subscribe to auto-mode events to update running agents count in real-time + useEffect(() => { + const api = getElectronAPI(); + if (!api.autoMode) return; + + const unsubscribe = api.autoMode.onEvent((event) => { + // When a feature starts, completes, or errors, refresh the count + if ( + event.type === "auto_mode_feature_complete" || + event.type === "auto_mode_error" || + event.type === "auto_mode_feature_started" + ) { + const fetchRunningAgentsCount = async () => { + try { + if (api.runningAgents) { + const result = await api.runningAgents.getAll(); + if (result.success && result.runningAgents) { + setRunningAgentsCount(result.runningAgents.length); + } + } + } catch (error) { + console.error("[Sidebar] Error fetching running agents count:", error); + } + }; + fetchRunningAgentsCount(); + } + }); + + return () => { + unsubscribe(); + }; + }, []); + // Handle creating initial spec for new project const handleCreateInitialSpec = useCallback(async () => { if (!setupProjectPath || !projectOverview.trim()) return; @@ -534,14 +601,14 @@ export function Sidebar() { } const confirmed = window.confirm( - "Clear all trashed projects from Automaker? This does not delete folders from disk." + "Clear all projects from recycle bin? This does not delete folders from disk." ); if (!confirmed) return; setIsEmptyingTrash(true); try { emptyTrash(); - toast.success("Trash cleared"); + toast.success("Recycle bin cleared"); setShowTrashDialog(false); } finally { setIsEmptyingTrash(false); @@ -830,10 +897,10 @@ export function Sidebar() { )} @@ -1421,6 +1499,14 @@ export function Sidebar() { )} + + {/* Delete Project Confirmation Dialog */} + ); } diff --git a/apps/app/src/components/new-project-modal.tsx b/apps/app/src/components/new-project-modal.tsx index 562363c4..fd1429de 100644 --- a/apps/app/src/components/new-project-modal.tsx +++ b/apps/app/src/components/new-project-modal.tsx @@ -17,6 +17,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { FolderPlus, + FolderOpen, Rocket, ExternalLink, Check, @@ -28,6 +29,7 @@ import { starterTemplates, type StarterTemplate } from "@/lib/templates"; import { getElectronAPI } from "@/lib/electron"; import { getHttpApiClient } from "@/lib/http-api-client"; import { cn } from "@/lib/utils"; +import { useFileBrowser } from "@/contexts/file-browser-context"; interface ValidationErrors { projectName?: boolean; @@ -69,6 +71,7 @@ export function NewProjectModal({ const [useCustomUrl, setUseCustomUrl] = useState(false); const [customUrl, setCustomUrl] = useState(""); const [errors, setErrors] = useState({}); + const { openFileBrowser } = useFileBrowser(); // Fetch workspace directory when modal opens useEffect(() => { @@ -181,6 +184,20 @@ export function NewProjectModal({ } }; + const handleBrowseDirectory = async () => { + const selectedPath = await openFileBrowser({ + title: "Select Base Project Directory", + description: "Choose the parent directory where your project will be created", + }); + if (selectedPath) { + setWorkspaceDir(selectedPath); + // Clear any workspace error when a valid directory is selected + if (errors.workspaceDir) { + setErrors((prev) => ({ ...prev, workspaceDir: false })); + } + } + }; + const projectPath = workspaceDir && projectName ? `${workspaceDir}/${projectName}` : ""; return ( @@ -226,16 +243,28 @@ export function NewProjectModal({ "flex items-center gap-2 text-sm", errors.workspaceDir ? "text-red-500" : "text-muted-foreground" )}> - - + + {isLoadingWorkspace ? ( "Loading workspace..." ) : workspaceDir ? ( - <>Will be created at: {projectPath || "..."} + <>Will be created at: {projectPath || "..."} ) : ( - No workspace configured - please configure WORKSPACE_DIR + No workspace configured )} + diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index 23d332a9..35e8d0a0 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -867,7 +867,8 @@ export function BoardView() { // features often have skipTests=true, and we want status-based handling first if (targetStatus === "verified") { moveFeature(featureId, "verified"); - persistFeatureUpdate(featureId, { status: "verified" }); + // Clear justFinished flag when manually verifying via drag + persistFeatureUpdate(featureId, { status: "verified", justFinished: false }); toast.success("Feature verified", { description: `Manually verified: ${draggedFeature.description.slice( 0, @@ -877,7 +878,8 @@ export function BoardView() { } else if (targetStatus === "backlog") { // Allow moving waiting_approval cards back to backlog moveFeature(featureId, "backlog"); - persistFeatureUpdate(featureId, { status: "backlog" }); + // Clear justFinished flag when moving back to backlog + persistFeatureUpdate(featureId, { status: "backlog", justFinished: false }); toast.info("Feature moved to backlog", { description: `Moved to Backlog: ${draggedFeature.description.slice( 0, @@ -1198,7 +1200,8 @@ export function BoardView() { description: feature.description, }); moveFeature(feature.id, "verified"); - persistFeatureUpdate(feature.id, { status: "verified" }); + // Clear justFinished flag when manually verifying + persistFeatureUpdate(feature.id, { status: "verified", justFinished: false }); toast.success("Feature verified", { description: `Marked as verified: ${feature.description.slice(0, 50)}${ feature.description.length > 50 ? "..." : "" @@ -1264,9 +1267,11 @@ export function BoardView() { } // Move feature back to in_progress before sending follow-up + // Clear justFinished flag since user is now interacting with it const updates = { status: "in_progress" as const, startedAt: new Date().toISOString(), + justFinished: false, }; updateFeature(featureId, updates); persistFeatureUpdate(featureId, updates); @@ -1525,6 +1530,14 @@ export function BoardView() { } }); + // Sort waiting_approval column: justFinished features go to the top + map.waiting_approval.sort((a, b) => { + // Features with justFinished=true should appear first + if (a.justFinished && !b.justFinished) return -1; + if (!a.justFinished && b.justFinished) return 1; + return 0; // Keep original order for features with same justFinished status + }); + return map; }, [features, runningAutoTasks, searchQuery]); diff --git a/apps/app/src/components/views/interview-view.tsx b/apps/app/src/components/views/interview-view.tsx index 4e4d1b26..78110faa 100644 --- a/apps/app/src/components/views/interview-view.tsx +++ b/apps/app/src/components/views/interview-view.tsx @@ -18,6 +18,7 @@ import { import { cn } from "@/lib/utils"; import { getElectronAPI } from "@/lib/electron"; import { Markdown } from "@/components/ui/markdown"; +import { useFileBrowser } from "@/contexts/file-browser-context"; interface InterviewMessage { id: string; @@ -65,6 +66,7 @@ const INTERVIEW_QUESTIONS = [ export function InterviewView() { const { setCurrentView, addProject, setCurrentProject, setAppSpec } = useAppStore(); + const { openFileBrowser } = useFileBrowser(); const [input, setInput] = useState(""); const [messages, setMessages] = useState([]); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); @@ -286,11 +288,13 @@ export function InterviewView() { }; const handleSelectDirectory = async () => { - const api = getElectronAPI(); - const result = await api.openDirectory(); + const selectedPath = await openFileBrowser({ + title: "Select Base Directory", + description: "Choose the parent directory where your new project will be created", + }); - if (!result.canceled && result.filePaths[0]) { - setProjectPath(result.filePaths[0]); + if (selectedPath) { + setProjectPath(selectedPath); } }; diff --git a/apps/app/src/components/views/kanban-card.tsx b/apps/app/src/components/views/kanban-card.tsx index 41a35729..69603bd3 100644 --- a/apps/app/src/components/views/kanban-card.tsx +++ b/apps/app/src/components/views/kanban-card.tsx @@ -27,7 +27,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Feature, useAppStore } from "@/store/app-store"; +import { Feature, useAppStore, ThinkingLevel } from "@/store/app-store"; import { GripVertical, Edit, @@ -55,6 +55,7 @@ import { GitMerge, ChevronDown, ChevronUp, + Brain, } from "lucide-react"; import { CountUpTimer } from "@/components/ui/count-up-timer"; import { getElectronAPI } from "@/lib/electron"; @@ -72,6 +73,21 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; +/** + * Formats thinking level for compact display + */ +function formatThinkingLevel(level: ThinkingLevel | undefined): string { + if (!level || level === "none") return ""; + const labels: Record = { + none: "", + low: "Low", + medium: "Med", + high: "High", + ultrathink: "Ultra", + }; + return labels[level]; +} + interface KanbanCardProps { feature: Feature; onEdit: () => void; @@ -280,6 +296,21 @@ export const KanbanCard = memo(function KanbanCard({ Errored )} + {/* Just Finished indicator badge - shows when agent just completed work */} + {feature.justFinished && feature.status === "waiting_approval" && !feature.error && ( +
+ + Done +
+ )} {/* Branch badge - show when feature has a worktree */} {hasWorktree && !isCurrentAutoTask && ( @@ -289,8 +320,8 @@ export const KanbanCard = memo(function KanbanCard({ className={cn( "absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default", "bg-purple-500/20 border border-purple-500/50 text-purple-400", - // Position below error badge if present, otherwise use normal position - feature.error || feature.skipTests + // Position below other badges if present, otherwise use normal position + feature.error || feature.skipTests || (feature.justFinished && feature.status === "waiting_approval") ? "top-8 left-2" : "top-2 left-2" )} @@ -310,14 +341,17 @@ export const KanbanCard = memo(function KanbanCard({ className={cn( "p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout // Add extra top padding when badges are present to prevent text overlap - (feature.skipTests || feature.error) && "pt-10", + (feature.skipTests || feature.error || (feature.justFinished && feature.status === "waiting_approval")) && "pt-10", // Add even more top padding when both badges and branch are shown - hasWorktree && (feature.skipTests || feature.error) && "pt-14" + hasWorktree && (feature.skipTests || feature.error || (feature.justFinished && feature.status === "waiting_approval")) && "pt-14" )} > {isCurrentAutoTask && (
+ + {formatModelName(feature.model ?? DEFAULT_MODEL)} + {feature.startedAt && ( )} + {/* Model/Preset Info for Backlog Cards - Show in Detailed mode */} + {showAgentInfo && feature.status === "backlog" && ( +
+
+
+ + + {formatModelName(feature.model ?? DEFAULT_MODEL)} + +
+ {feature.thinkingLevel && feature.thinkingLevel !== "none" && ( +
+ + + {formatThinkingLevel(feature.thinkingLevel)} + +
+ )} +
+
+ )} + {/* Agent Info Panel - shows for in_progress, waiting_approval, verified */} {/* Detailed mode: Show all agent info */} {showAgentInfo && feature.status !== "backlog" && agentInfo && ( diff --git a/apps/app/src/components/views/welcome-view.tsx b/apps/app/src/components/views/welcome-view.tsx index bd161a9e..36744cb1 100644 --- a/apps/app/src/components/views/welcome-view.tsx +++ b/apps/app/src/components/views/welcome-view.tsx @@ -181,7 +181,8 @@ export function WelcomeView() { if (!result.canceled && result.filePaths[0]) { const path = result.filePaths[0]; // Extract folder name from path (works on both Windows and Mac/Linux) - const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; + const name = + path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; await initializeAndOpenProject(path, name); } } @@ -193,7 +194,8 @@ export function WelcomeView() { if (!result.canceled && result.filePaths[0]) { const path = result.filePaths[0]; - const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; + const name = + path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; await initializeAndOpenProject(path, name); } } @@ -231,7 +233,10 @@ export function WelcomeView() { /** * Create a blank project with just .automaker directory structure */ - const handleCreateBlankProject = async (projectName: string, parentDir: string) => { + const handleCreateBlankProject = async ( + projectName: string, + parentDir: string + ) => { setIsCreating(true); try { const api = getElectronAPI(); @@ -359,11 +364,15 @@ export function WelcomeView() { - ${template.techStack.map((tech) => `${tech}`).join("\n ")} + ${template.techStack + .map((tech) => `${tech}`) + .join("\n ")} - ${template.features.map((feature) => `${feature}`).join("\n ")} + ${template.features + .map((feature) => `${feature}`) + .join("\n ")} diff --git a/apps/app/src/contexts/file-browser-context.tsx b/apps/app/src/contexts/file-browser-context.tsx index f54fb27f..b4c0b4ee 100644 --- a/apps/app/src/contexts/file-browser-context.tsx +++ b/apps/app/src/contexts/file-browser-context.tsx @@ -3,8 +3,13 @@ import { createContext, useContext, useState, useCallback, type ReactNode } from "react"; import { FileBrowserDialog } from "@/components/dialogs/file-browser-dialog"; +interface FileBrowserOptions { + title?: string; + description?: string; +} + interface FileBrowserContextValue { - openFileBrowser: () => Promise; + openFileBrowser: (options?: FileBrowserOptions) => Promise; } const FileBrowserContext = createContext(null); @@ -12,9 +17,11 @@ const FileBrowserContext = createContext(null); export function FileBrowserProvider({ children }: { children: ReactNode }) { const [isOpen, setIsOpen] = useState(false); const [resolver, setResolver] = useState<((value: string | null) => void) | null>(null); + const [dialogOptions, setDialogOptions] = useState({}); - const openFileBrowser = useCallback((): Promise => { + const openFileBrowser = useCallback((options?: FileBrowserOptions): Promise => { return new Promise((resolve) => { + setDialogOptions(options || {}); setIsOpen(true); setResolver(() => resolve); }); @@ -26,6 +33,7 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) { setResolver(null); } setIsOpen(false); + setDialogOptions({}); }, [resolver]); const handleOpenChange = useCallback((open: boolean) => { @@ -34,6 +42,9 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) { setResolver(null); } setIsOpen(open); + if (!open) { + setDialogOptions({}); + } }, [resolver]); return ( @@ -43,6 +54,8 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) { open={isOpen} onOpenChange={handleOpenChange} onSelect={handleSelect} + title={dialogOptions.title} + description={dialogOptions.description} /> ); @@ -57,12 +70,15 @@ export function useFileBrowser() { } // Global reference for non-React code (like HttpApiClient) -let globalFileBrowserFn: (() => Promise) | null = null; +let globalFileBrowserFn: ((options?: FileBrowserOptions) => Promise) | null = null; -export function setGlobalFileBrowser(fn: () => Promise) { +export function setGlobalFileBrowser(fn: (options?: FileBrowserOptions) => Promise) { globalFileBrowserFn = fn; } export function getGlobalFileBrowser() { return globalFileBrowserFn; } + +// Export the options type for consumers +export type { FileBrowserOptions }; diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 81d50617..d185eea0 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -245,6 +245,7 @@ export interface Feature { // Worktree info - set when a feature is being worked on in an isolated git worktree worktreePath?: string; // Path to the worktree directory branchName?: string; // Name of the feature branch + justFinished?: boolean; // Set to true when agent just finished and moved to waiting_approval } // File tree node for project analysis @@ -332,6 +333,13 @@ export interface AppState { // Project Analysis projectAnalysis: ProjectAnalysis | null; isAnalyzing: boolean; + + // Board Background Settings (per-project, keyed by project path) + boardBackgroundByProject: Record; } export interface AutoModeActivity { @@ -455,6 +463,13 @@ export interface AppActions { setLastSelectedSession: (projectPath: string, sessionId: string | null) => void; getLastSelectedSession: (projectPath: string) => string | null; + // Board Background actions + setBoardBackground: (projectPath: string, imagePath: string | null) => void; + setCardOpacity: (projectPath: string, opacity: number) => void; + setColumnOpacity: (projectPath: string, opacity: number) => void; + getBoardBackground: (projectPath: string) => { imagePath: string | null; cardOpacity: number; columnOpacity: number }; + clearBoardBackground: (projectPath: string) => void; + // Reset reset: () => void; } @@ -546,6 +561,7 @@ const initialState: AppState = { aiProfiles: DEFAULT_AI_PROFILES, projectAnalysis: null, isAnalyzing: false, + boardBackgroundByProject: {}, }; export const useAppStore = create()( @@ -1131,6 +1147,69 @@ export const useAppStore = create()( getLastSelectedSession: (projectPath) => { return get().lastSelectedSessionByProject[projectPath] || null; }, + + // Board Background actions + setBoardBackground: (projectPath, imagePath) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + imagePath, + }, + }, + }); + }, + + setCardOpacity: (projectPath, opacity) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + cardOpacity: opacity, + }, + }, + }); + }, + + setColumnOpacity: (projectPath, opacity) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + columnOpacity: opacity, + }, + }, + }); + }, + + getBoardBackground: (projectPath) => { + const settings = get().boardBackgroundByProject[projectPath]; + return settings || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + }, + + clearBoardBackground: (projectPath) => { + const current = get().boardBackgroundByProject; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + }, + }, + }); + }, + // Reset reset: () => set(initialState), }), @@ -1164,6 +1243,8 @@ export const useAppStore = create()( aiProfiles: state.aiProfiles, chatSessions: state.chatSessions, lastSelectedSessionByProject: state.lastSelectedSessionByProject, + // Board background settings + boardBackgroundByProject: state.boardBackgroundByProject, }), } ) diff --git a/apps/server/src/routes/fs.ts b/apps/server/src/routes/fs.ts index ac492f03..7befe07f 100644 --- a/apps/server/src/routes/fs.ts +++ b/apps/server/src/routes/fs.ts @@ -500,6 +500,86 @@ export function createFsRoutes(_events: EventEmitter): Router { } }); + // Save board background image to .automaker/board directory + router.post("/save-board-background", async (req: Request, res: Response) => { + try { + const { data, filename, mimeType, projectPath } = req.body as { + data: string; + filename: string; + mimeType: string; + projectPath: string; + }; + + if (!data || !filename || !projectPath) { + res.status(400).json({ + success: false, + error: "data, filename, and projectPath are required", + }); + return; + } + + // Create .automaker/board directory if it doesn't exist + const boardDir = path.join(projectPath, ".automaker", "board"); + await fs.mkdir(boardDir, { recursive: true }); + + // Decode base64 data (remove data URL prefix if present) + const base64Data = data.replace(/^data:image\/\w+;base64,/, ""); + const buffer = Buffer.from(base64Data, "base64"); + + // Use a fixed filename for the board background (overwrite previous) + const ext = path.extname(filename) || ".png"; + const uniqueFilename = `background${ext}`; + const filePath = path.join(boardDir, uniqueFilename); + + // Write file + await fs.writeFile(filePath, buffer); + + // Add project path to allowed paths if not already + addAllowedPath(projectPath); + + // Return the relative path for storage + const relativePath = `.automaker/board/${uniqueFilename}`; + res.json({ success: true, path: relativePath }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Delete board background image + router.post("/delete-board-background", async (req: Request, res: Response) => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: "projectPath is required", + }); + return; + } + + const boardDir = path.join(projectPath, ".automaker", "board"); + + try { + // Try to remove all files in the board directory + const files = await fs.readdir(boardDir); + for (const file of files) { + if (file.startsWith("background")) { + await fs.unlink(path.join(boardDir, file)); + } + } + } catch { + // Directory may not exist, that's fine + } + + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + // Browse directories for file picker // SECURITY: Restricted to home directory, allowed paths, and drive roots on Windows router.post("/browse", async (req: Request, res: Response) => { diff --git a/apps/server/src/routes/running-agents.ts b/apps/server/src/routes/running-agents.ts index 57285636..116a5b00 100644 --- a/apps/server/src/routes/running-agents.ts +++ b/apps/server/src/routes/running-agents.ts @@ -3,32 +3,22 @@ */ import { Router, type Request, type Response } from "express"; -import path from "path"; +import type { AutoModeService } from "../services/auto-mode-service.js"; -interface RunningAgent { - featureId: string; - projectPath: string; - projectName: string; - isAutoMode: boolean; -} - -// In-memory tracking of running agents (shared with auto-mode service via reference) -const runningAgentsMap = new Map(); -let autoLoopRunning = false; - -export function createRunningAgentsRoutes(): Router { +export function createRunningAgentsRoutes(autoModeService: AutoModeService): Router { const router = Router(); // Get all running agents router.get("/", async (_req: Request, res: Response) => { try { - const runningAgents = Array.from(runningAgentsMap.values()); + const runningAgents = autoModeService.getRunningAgents(); + const status = autoModeService.getStatus(); res.json({ success: true, runningAgents, totalCount: runningAgents.length, - autoLoopRunning, + autoLoopRunning: status.autoLoopRunning, }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; @@ -38,33 +28,3 @@ export function createRunningAgentsRoutes(): Router { return router; } - -// Export functions to update running agents from other services -export function registerRunningAgent( - featureId: string, - projectPath: string, - isAutoMode: boolean -): void { - runningAgentsMap.set(featureId, { - featureId, - projectPath, - projectName: path.basename(projectPath), - isAutoMode, - }); -} - -export function unregisterRunningAgent(featureId: string): void { - runningAgentsMap.delete(featureId); -} - -export function setAutoLoopRunning(running: boolean): void { - autoLoopRunning = running; -} - -export function getRunningAgentsCount(): number { - return runningAgentsMap.size; -} - -export function isAgentRunning(featureId: string): boolean { - return runningAgentsMap.has(featureId); -} diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 8015de91..bdb34511 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -563,6 +563,23 @@ Format your response as a structured markdown document.`; }; } + /** + * Get detailed info about all running agents + */ + getRunningAgents(): Array<{ + featureId: string; + projectPath: string; + projectName: string; + isAutoMode: boolean; + }> { + return Array.from(this.runningFeatures.values()).map((rf) => ({ + featureId: rf.featureId, + projectPath: rf.projectPath, + projectName: path.basename(rf.projectPath), + isAutoMode: rf.isAutoMode, + })); + } + // Private helpers private async setupWorktree( @@ -639,6 +656,13 @@ Format your response as a structured markdown document.`; const feature = JSON.parse(data); feature.status = status; feature.updatedAt = new Date().toISOString(); + // Set justFinished flag when moving to waiting_approval (agent just completed) + if (status === "waiting_approval") { + feature.justFinished = true; + } else { + // Clear the flag when moving to other statuses + feature.justFinished = false; + } await fs.writeFile(featurePath, JSON.stringify(feature, null, 2)); } catch { // Feature file may not exist