various fixes

This commit is contained in:
Cody Seibert
2025-12-12 20:51:01 -05:00
parent 5544031164
commit ca4809ca06
13 changed files with 496 additions and 126 deletions

View File

@@ -1,10 +1,14 @@
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3'); const {
const fs = require('fs'); S3Client,
const path = require('path'); PutObjectCommand,
GetObjectCommand,
} = require("@aws-sdk/client-s3");
const fs = require("fs");
const path = require("path");
const s3Client = new S3Client({ const s3Client = new S3Client({
region: 'auto', region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, endpoint: process.env.R2_ENDPOINT,
credentials: { credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID, accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
@@ -18,15 +22,17 @@ const GITHUB_REPO = process.env.GITHUB_REPOSITORY;
async function fetchExistingReleases() { async function fetchExistingReleases() {
try { try {
const response = await s3Client.send(new GetObjectCommand({ const response = await s3Client.send(
Bucket: BUCKET, new GetObjectCommand({
Key: 'releases.json', Bucket: BUCKET,
})); Key: "releases.json",
})
);
const body = await response.Body.transformToString(); const body = await response.Body.transformToString();
return JSON.parse(body); return JSON.parse(body);
} catch (error) { } catch (error) {
if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) { if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) {
console.log('No existing releases.json found, creating new one'); console.log("No existing releases.json found, creating new one");
return { latestVersion: null, releases: [] }; return { latestVersion: null, releases: [] };
} }
throw error; throw error;
@@ -37,12 +43,14 @@ async function uploadFile(localPath, r2Key, contentType) {
const fileBuffer = fs.readFileSync(localPath); const fileBuffer = fs.readFileSync(localPath);
const stats = fs.statSync(localPath); const stats = fs.statSync(localPath);
await s3Client.send(new PutObjectCommand({ await s3Client.send(
Bucket: BUCKET, new PutObjectCommand({
Key: r2Key, Bucket: BUCKET,
Body: fileBuffer, Key: r2Key,
ContentType: contentType, Body: fileBuffer,
})); ContentType: contentType,
})
);
console.log(`Uploaded: ${r2Key} (${stats.size} bytes)`); console.log(`Uploaded: ${r2Key} (${stats.size} bytes)`);
return stats.size; return stats.size;
@@ -51,44 +59,44 @@ async function uploadFile(localPath, r2Key, contentType) {
function findArtifacts(dir, pattern) { function findArtifacts(dir, pattern) {
if (!fs.existsSync(dir)) return []; if (!fs.existsSync(dir)) return [];
const files = fs.readdirSync(dir); 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() { async function main() {
const artifactsDir = 'artifacts'; const artifactsDir = "artifacts";
// Find all artifacts // Find all artifacts
const artifacts = { const artifacts = {
windows: findArtifacts( windows: findArtifacts(path.join(artifactsDir, "windows-builds"), /\.exe$/),
path.join(artifactsDir, 'windows-builds'), macos: findArtifacts(path.join(artifactsDir, "macos-builds"), /-x64\.dmg$/),
/\.exe$/
),
macos: findArtifacts(
path.join(artifactsDir, 'macos-builds'),
/-x64\.dmg$/
),
macosArm: findArtifacts( macosArm: findArtifacts(
path.join(artifactsDir, 'macos-builds'), path.join(artifactsDir, "macos-builds"),
/-arm64\.dmg$/ /-arm64\.dmg$/
), ),
linux: findArtifacts( linux: findArtifacts(
path.join(artifactsDir, 'linux-builds'), path.join(artifactsDir, "linux-builds"),
/\.AppImage$/ /\.AppImage$/
), ),
}; };
console.log('Found artifacts:'); console.log("Found artifacts:");
for (const [platform, files] of Object.entries(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 // Upload each artifact to R2
const assets = {}; const assets = {};
const contentTypes = { const contentTypes = {
windows: 'application/x-msdownload', windows: "application/x-msdownload",
macos: 'application/x-apple-diskimage', macos: "application/x-apple-diskimage",
macosArm: 'application/x-apple-diskimage', macosArm: "application/x-apple-diskimage",
linux: 'application/x-executable', linux: "application/x-executable",
}; };
for (const [platform, files] of Object.entries(artifacts)) { for (const [platform, files] of Object.entries(artifacts)) {
@@ -107,7 +115,7 @@ async function main() {
url: `${PUBLIC_URL}/releases/${VERSION}/${filename}`, url: `${PUBLIC_URL}/releases/${VERSION}/${filename}`,
filename, filename,
size, 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 // 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 // Prepend new release
releasesData.releases.unshift(newRelease); releasesData.releases.unshift(newRelease);
releasesData.latestVersion = VERSION; releasesData.latestVersion = VERSION;
// Upload updated releases.json // Upload updated releases.json
await s3Client.send(new PutObjectCommand({ await s3Client.send(
Bucket: BUCKET, new PutObjectCommand({
Key: 'releases.json', Bucket: BUCKET,
Body: JSON.stringify(releasesData, null, 2), Key: "releases.json",
ContentType: 'application/json', Body: JSON.stringify(releasesData, null, 2),
CacheControl: 'public, max-age=60', 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(`Latest version: ${VERSION}`);
console.log(`Total releases: ${releasesData.releases.length}`); console.log(`Total releases: ${releasesData.releases.length}`);
} }
main().catch(err => { main().catch((err) => {
console.error('Failed to upload to R2:', err); console.error("Failed to upload to R2:", err);
process.exit(1); process.exit(1);
}); });

View File

@@ -129,7 +129,7 @@ jobs:
- name: Upload to R2 and update releases.json - name: Upload to R2 and update releases.json
env: env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }} R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}

View File

@@ -42,6 +42,7 @@ import {
Search, Search,
Bug, Bug,
Activity, Activity,
Recycle,
} from "lucide-react"; } from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
@@ -70,7 +71,7 @@ import {
useKeyboardShortcutsConfig, useKeyboardShortcutsConfig,
KeyboardShortcut, KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts"; } from "@/hooks/use-keyboard-shortcuts";
import { getElectronAPI, Project, TrashedProject } from "@/lib/electron"; import { getElectronAPI, Project, TrashedProject, RunningAgent } from "@/lib/electron";
import { import {
initializeProject, initializeProject,
hasAppSpec, hasAppSpec,
@@ -80,6 +81,7 @@ import { toast } from "sonner";
import { Sparkles, Loader2 } from "lucide-react"; import { Sparkles, Loader2 } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import type { SpecRegenerationEvent } from "@/types/electron"; import type { SpecRegenerationEvent } from "@/types/electron";
import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog";
import { import {
DndContext, DndContext,
DragEndEvent, DragEndEvent,
@@ -212,6 +214,7 @@ export function Sidebar() {
setProjectTheme, setProjectTheme,
setTheme, setTheme,
theme: globalTheme, theme: globalTheme,
moveProjectToTrash,
} = useAppStore(); } = useAppStore();
// Get customizable keyboard shortcuts // Get customizable keyboard shortcuts
@@ -225,6 +228,12 @@ export function Sidebar() {
const [activeTrashId, setActiveTrashId] = useState<string | null>(null); const [activeTrashId, setActiveTrashId] = useState<string | null>(null);
const [isEmptyingTrash, setIsEmptyingTrash] = useState(false); 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 // State for new project setup dialog
const [showSetupDialog, setShowSetupDialog] = useState(false); const [showSetupDialog, setShowSetupDialog] = useState(false);
const [setupProjectPath, setSetupProjectPath] = useState(""); const [setupProjectPath, setSetupProjectPath] = useState("");
@@ -334,6 +343,64 @@ export function Sidebar() {
}; };
}, [setCurrentView]); }, [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 // Handle creating initial spec for new project
const handleCreateInitialSpec = useCallback(async () => { const handleCreateInitialSpec = useCallback(async () => {
if (!setupProjectPath || !projectOverview.trim()) return; if (!setupProjectPath || !projectOverview.trim()) return;
@@ -534,14 +601,14 @@ export function Sidebar() {
} }
const confirmed = window.confirm( 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; if (!confirmed) return;
setIsEmptyingTrash(true); setIsEmptyingTrash(true);
try { try {
emptyTrash(); emptyTrash();
toast.success("Trash cleared"); toast.success("Recycle bin cleared");
setShowTrashDialog(false); setShowTrashDialog(false);
} finally { } finally {
setIsEmptyingTrash(false); setIsEmptyingTrash(false);
@@ -830,10 +897,10 @@ export function Sidebar() {
<button <button
onClick={() => setShowTrashDialog(true)} onClick={() => setShowTrashDialog(true)}
className="group flex items-center justify-center px-3 h-[42px] rounded-lg relative overflow-hidden transition-all text-muted-foreground hover:text-primary hover:bg-destructive/10 border border-sidebar-border" className="group flex items-center justify-center px-3 h-[42px] rounded-lg relative overflow-hidden transition-all text-muted-foreground hover:text-primary hover:bg-destructive/10 border border-sidebar-border"
title="Trash" title="Recycle Bin"
data-testid="trash-button" data-testid="trash-button"
> >
<Trash2 className="size-4 shrink-0" /> <Recycle className="size-4 shrink-0" />
{trashedProjects.length > 0 && ( {trashedProjects.length > 0 && (
<span className="absolute -top-[2px] -right-[2px] flex items-center justify-center w-5 h-5 text-[10px] font-medium rounded-full text-brand-500"> <span className="absolute -top-[2px] -right-[2px] flex items-center justify-center w-5 h-5 text-[10px] font-medium rounded-full text-brand-500">
{trashedProjects.length > 9 ? "9+" : trashedProjects.length} {trashedProjects.length > 9 ? "9+" : trashedProjects.length}
@@ -1039,6 +1106,17 @@ export function Sidebar() {
</DropdownMenuItem> </DropdownMenuItem>
</> </>
)} )}
{/* Move to Trash Section */}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowDeleteProjectDialog(true)}
className="text-destructive focus:text-destructive focus:bg-destructive/10"
data-testid="move-project-to-trash"
>
<Trash2 className="w-4 h-4 mr-2" />
<span>Move to Trash</span>
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
@@ -1242,7 +1320,7 @@ export function Sidebar() {
<Dialog open={showTrashDialog} onOpenChange={setShowTrashDialog}> <Dialog open={showTrashDialog} onOpenChange={setShowTrashDialog}>
<DialogContent className="bg-popover border-border max-w-2xl"> <DialogContent className="bg-popover border-border max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Trash</DialogTitle> <DialogTitle>Recycle Bin</DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
Restore projects to the sidebar or delete their folders using your Restore projects to the sidebar or delete their folders using your
system Trash. system Trash.
@@ -1250,7 +1328,7 @@ export function Sidebar() {
</DialogHeader> </DialogHeader>
{trashedProjects.length === 0 ? ( {trashedProjects.length === 0 ? (
<p className="text-sm text-muted-foreground">Trash is empty.</p> <p className="text-sm text-muted-foreground">Recycle bin is empty.</p>
) : ( ) : (
<div className="space-y-3 max-h-[360px] overflow-y-auto pr-1"> <div className="space-y-3 max-h-[360px] overflow-y-auto pr-1">
{trashedProjects.map((project) => ( {trashedProjects.map((project) => (
@@ -1318,7 +1396,7 @@ export function Sidebar() {
disabled={isEmptyingTrash} disabled={isEmptyingTrash}
data-testid="empty-trash" data-testid="empty-trash"
> >
{isEmptyingTrash ? "Clearing..." : "Empty Trash"} {isEmptyingTrash ? "Clearing..." : "Empty Recycle Bin"}
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>
@@ -1421,6 +1499,14 @@ export function Sidebar() {
</button> </button>
</div> </div>
)} )}
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteProjectDialog}
onOpenChange={setShowDeleteProjectDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
</aside> </aside>
); );
} }

View File

@@ -17,6 +17,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
FolderPlus, FolderPlus,
FolderOpen,
Rocket, Rocket,
ExternalLink, ExternalLink,
Check, Check,
@@ -28,6 +29,7 @@ import { starterTemplates, type StarterTemplate } from "@/lib/templates";
import { getElectronAPI } from "@/lib/electron"; import { getElectronAPI } from "@/lib/electron";
import { getHttpApiClient } from "@/lib/http-api-client"; import { getHttpApiClient } from "@/lib/http-api-client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useFileBrowser } from "@/contexts/file-browser-context";
interface ValidationErrors { interface ValidationErrors {
projectName?: boolean; projectName?: boolean;
@@ -69,6 +71,7 @@ export function NewProjectModal({
const [useCustomUrl, setUseCustomUrl] = useState(false); const [useCustomUrl, setUseCustomUrl] = useState(false);
const [customUrl, setCustomUrl] = useState(""); const [customUrl, setCustomUrl] = useState("");
const [errors, setErrors] = useState<ValidationErrors>({}); const [errors, setErrors] = useState<ValidationErrors>({});
const { openFileBrowser } = useFileBrowser();
// Fetch workspace directory when modal opens // Fetch workspace directory when modal opens
useEffect(() => { 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}` : ""; const projectPath = workspaceDir && projectName ? `${workspaceDir}/${projectName}` : "";
return ( return (
@@ -226,16 +243,28 @@ export function NewProjectModal({
"flex items-center gap-2 text-sm", "flex items-center gap-2 text-sm",
errors.workspaceDir ? "text-red-500" : "text-muted-foreground" errors.workspaceDir ? "text-red-500" : "text-muted-foreground"
)}> )}>
<Folder className="w-4 h-4" /> <Folder className="w-4 h-4 shrink-0" />
<span> <span className="flex-1 min-w-0">
{isLoadingWorkspace ? ( {isLoadingWorkspace ? (
"Loading workspace..." "Loading workspace..."
) : workspaceDir ? ( ) : workspaceDir ? (
<>Will be created at: <code className="text-xs bg-muted px-1.5 py-0.5 rounded">{projectPath || "..."}</code></> <>Will be created at: <code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">{projectPath || "..."}</code></>
) : ( ) : (
<span className="text-red-500">No workspace configured - please configure WORKSPACE_DIR</span> <span className="text-red-500">No workspace configured</span>
)} )}
</span> </span>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleBrowseDirectory}
disabled={isLoadingWorkspace}
className="shrink-0 h-7 px-2 text-xs"
data-testid="browse-directory-button"
>
<FolderOpen className="w-3.5 h-3.5 mr-1" />
Browse
</Button>
</div> </div>
</div> </div>

View File

@@ -867,7 +867,8 @@ export function BoardView() {
// features often have skipTests=true, and we want status-based handling first // features often have skipTests=true, and we want status-based handling first
if (targetStatus === "verified") { if (targetStatus === "verified") {
moveFeature(featureId, "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", { toast.success("Feature verified", {
description: `Manually verified: ${draggedFeature.description.slice( description: `Manually verified: ${draggedFeature.description.slice(
0, 0,
@@ -877,7 +878,8 @@ export function BoardView() {
} else if (targetStatus === "backlog") { } else if (targetStatus === "backlog") {
// Allow moving waiting_approval cards back to backlog // Allow moving waiting_approval cards back to backlog
moveFeature(featureId, "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", { toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice( description: `Moved to Backlog: ${draggedFeature.description.slice(
0, 0,
@@ -1198,7 +1200,8 @@ export function BoardView() {
description: feature.description, description: feature.description,
}); });
moveFeature(feature.id, "verified"); 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", { toast.success("Feature verified", {
description: `Marked as verified: ${feature.description.slice(0, 50)}${ description: `Marked as verified: ${feature.description.slice(0, 50)}${
feature.description.length > 50 ? "..." : "" feature.description.length > 50 ? "..." : ""
@@ -1264,9 +1267,11 @@ export function BoardView() {
} }
// Move feature back to in_progress before sending follow-up // Move feature back to in_progress before sending follow-up
// Clear justFinished flag since user is now interacting with it
const updates = { const updates = {
status: "in_progress" as const, status: "in_progress" as const,
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
justFinished: false,
}; };
updateFeature(featureId, updates); updateFeature(featureId, updates);
persistFeatureUpdate(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; return map;
}, [features, runningAutoTasks, searchQuery]); }, [features, runningAutoTasks, searchQuery]);

View File

@@ -18,6 +18,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron"; import { getElectronAPI } from "@/lib/electron";
import { Markdown } from "@/components/ui/markdown"; import { Markdown } from "@/components/ui/markdown";
import { useFileBrowser } from "@/contexts/file-browser-context";
interface InterviewMessage { interface InterviewMessage {
id: string; id: string;
@@ -65,6 +66,7 @@ const INTERVIEW_QUESTIONS = [
export function InterviewView() { export function InterviewView() {
const { setCurrentView, addProject, setCurrentProject, setAppSpec } = const { setCurrentView, addProject, setCurrentProject, setAppSpec } =
useAppStore(); useAppStore();
const { openFileBrowser } = useFileBrowser();
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [messages, setMessages] = useState<InterviewMessage[]>([]); const [messages, setMessages] = useState<InterviewMessage[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
@@ -286,11 +288,13 @@ export function InterviewView() {
}; };
const handleSelectDirectory = async () => { const handleSelectDirectory = async () => {
const api = getElectronAPI(); const selectedPath = await openFileBrowser({
const result = await api.openDirectory(); title: "Select Base Directory",
description: "Choose the parent directory where your new project will be created",
});
if (!result.canceled && result.filePaths[0]) { if (selectedPath) {
setProjectPath(result.filePaths[0]); setProjectPath(selectedPath);
} }
}; };

View File

@@ -27,7 +27,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Feature, useAppStore } from "@/store/app-store"; import { Feature, useAppStore, ThinkingLevel } from "@/store/app-store";
import { import {
GripVertical, GripVertical,
Edit, Edit,
@@ -55,6 +55,7 @@ import {
GitMerge, GitMerge,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Brain,
} from "lucide-react"; } from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer"; import { CountUpTimer } from "@/components/ui/count-up-timer";
import { getElectronAPI } from "@/lib/electron"; import { getElectronAPI } from "@/lib/electron";
@@ -72,6 +73,21 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
/**
* Formats thinking level for compact display
*/
function formatThinkingLevel(level: ThinkingLevel | undefined): string {
if (!level || level === "none") return "";
const labels: Record<ThinkingLevel, string> = {
none: "",
low: "Low",
medium: "Med",
high: "High",
ultrathink: "Ultra",
};
return labels[level];
}
interface KanbanCardProps { interface KanbanCardProps {
feature: Feature; feature: Feature;
onEdit: () => void; onEdit: () => void;
@@ -280,6 +296,21 @@ export const KanbanCard = memo(function KanbanCard({
<span>Errored</span> <span>Errored</span>
</div> </div>
)} )}
{/* Just Finished indicator badge - shows when agent just completed work */}
{feature.justFinished && feature.status === "waiting_approval" && !feature.error && (
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
feature.skipTests ? "top-8 left-2" : "top-2 left-2",
"bg-green-500/20 border border-green-500/50 text-green-400 animate-pulse"
)}
data-testid={`just-finished-badge-${feature.id}`}
title="Agent just finished working on this feature"
>
<Sparkles className="w-3 h-3" />
<span>Done</span>
</div>
)}
{/* Branch badge - show when feature has a worktree */} {/* Branch badge - show when feature has a worktree */}
{hasWorktree && !isCurrentAutoTask && ( {hasWorktree && !isCurrentAutoTask && (
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
@@ -289,8 +320,8 @@ export const KanbanCard = memo(function KanbanCard({
className={cn( className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default", "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", "bg-purple-500/20 border border-purple-500/50 text-purple-400",
// Position below error badge if present, otherwise use normal position // Position below other badges if present, otherwise use normal position
feature.error || feature.skipTests feature.error || feature.skipTests || (feature.justFinished && feature.status === "waiting_approval")
? "top-8 left-2" ? "top-8 left-2"
: "top-2 left-2" : "top-2 left-2"
)} )}
@@ -310,14 +341,17 @@ export const KanbanCard = memo(function KanbanCard({
className={cn( className={cn(
"p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout "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 // 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 // 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 && ( {isCurrentAutoTask && (
<div className="absolute top-2 right-2 flex items-center justify-center gap-2 bg-running-indicator/20 border border-running-indicator rounded px-2 py-0.5"> <div className="absolute top-2 right-2 flex items-center justify-center gap-2 bg-running-indicator/20 border border-running-indicator rounded px-2 py-0.5">
<Loader2 className="w-4 h-4 text-running-indicator animate-spin" /> <Loader2 className="w-4 h-4 text-running-indicator animate-spin" />
<span className="text-xs text-running-indicator font-medium">
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
{feature.startedAt && ( {feature.startedAt && (
<CountUpTimer <CountUpTimer
startedAt={feature.startedAt} startedAt={feature.startedAt}
@@ -452,6 +486,28 @@ export const KanbanCard = memo(function KanbanCard({
</div> </div>
)} )}
{/* Model/Preset Info for Backlog Cards - Show in Detailed mode */}
{showAgentInfo && feature.status === "backlog" && (
<div className="mb-3 space-y-2 overflow-hidden">
<div className="flex items-center gap-2 text-xs flex-wrap">
<div className="flex items-center gap-1 text-cyan-400">
<Cpu className="w-3 h-3" />
<span className="font-medium">
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
</div>
{feature.thinkingLevel && feature.thinkingLevel !== "none" && (
<div className="flex items-center gap-1 text-purple-400">
<Brain className="w-3 h-3" />
<span className="font-medium">
{formatThinkingLevel(feature.thinkingLevel)}
</span>
</div>
)}
</div>
</div>
)}
{/* Agent Info Panel - shows for in_progress, waiting_approval, verified */} {/* Agent Info Panel - shows for in_progress, waiting_approval, verified */}
{/* Detailed mode: Show all agent info */} {/* Detailed mode: Show all agent info */}
{showAgentInfo && feature.status !== "backlog" && agentInfo && ( {showAgentInfo && feature.status !== "backlog" && agentInfo && (

View File

@@ -181,7 +181,8 @@ export function WelcomeView() {
if (!result.canceled && result.filePaths[0]) { if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0]; const path = result.filePaths[0];
// Extract folder name from path (works on both Windows and Mac/Linux) // 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); await initializeAndOpenProject(path, name);
} }
} }
@@ -193,7 +194,8 @@ export function WelcomeView() {
if (!result.canceled && result.filePaths[0]) { if (!result.canceled && result.filePaths[0]) {
const path = 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); await initializeAndOpenProject(path, name);
} }
} }
@@ -231,7 +233,10 @@ export function WelcomeView() {
/** /**
* Create a blank project with just .automaker directory structure * 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); setIsCreating(true);
try { try {
const api = getElectronAPI(); const api = getElectronAPI();
@@ -359,11 +364,15 @@ export function WelcomeView() {
</overview> </overview>
<technology_stack> <technology_stack>
${template.techStack.map((tech) => `<technology>${tech}</technology>`).join("\n ")} ${template.techStack
.map((tech) => `<technology>${tech}</technology>`)
.join("\n ")}
</technology_stack> </technology_stack>
<core_capabilities> <core_capabilities>
${template.features.map((feature) => `<capability>${feature}</capability>`).join("\n ")} ${template.features
.map((feature) => `<capability>${feature}</capability>`)
.join("\n ")}
</core_capabilities> </core_capabilities>
<implemented_features> <implemented_features>

View File

@@ -3,8 +3,13 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from "react"; import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import { FileBrowserDialog } from "@/components/dialogs/file-browser-dialog"; import { FileBrowserDialog } from "@/components/dialogs/file-browser-dialog";
interface FileBrowserOptions {
title?: string;
description?: string;
}
interface FileBrowserContextValue { interface FileBrowserContextValue {
openFileBrowser: () => Promise<string | null>; openFileBrowser: (options?: FileBrowserOptions) => Promise<string | null>;
} }
const FileBrowserContext = createContext<FileBrowserContextValue | null>(null); const FileBrowserContext = createContext<FileBrowserContextValue | null>(null);
@@ -12,9 +17,11 @@ const FileBrowserContext = createContext<FileBrowserContextValue | null>(null);
export function FileBrowserProvider({ children }: { children: ReactNode }) { export function FileBrowserProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [resolver, setResolver] = useState<((value: string | null) => void) | null>(null); const [resolver, setResolver] = useState<((value: string | null) => void) | null>(null);
const [dialogOptions, setDialogOptions] = useState<FileBrowserOptions>({});
const openFileBrowser = useCallback((): Promise<string | null> => { const openFileBrowser = useCallback((options?: FileBrowserOptions): Promise<string | null> => {
return new Promise((resolve) => { return new Promise((resolve) => {
setDialogOptions(options || {});
setIsOpen(true); setIsOpen(true);
setResolver(() => resolve); setResolver(() => resolve);
}); });
@@ -26,6 +33,7 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
setResolver(null); setResolver(null);
} }
setIsOpen(false); setIsOpen(false);
setDialogOptions({});
}, [resolver]); }, [resolver]);
const handleOpenChange = useCallback((open: boolean) => { const handleOpenChange = useCallback((open: boolean) => {
@@ -34,6 +42,9 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
setResolver(null); setResolver(null);
} }
setIsOpen(open); setIsOpen(open);
if (!open) {
setDialogOptions({});
}
}, [resolver]); }, [resolver]);
return ( return (
@@ -43,6 +54,8 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
open={isOpen} open={isOpen}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
onSelect={handleSelect} onSelect={handleSelect}
title={dialogOptions.title}
description={dialogOptions.description}
/> />
</FileBrowserContext.Provider> </FileBrowserContext.Provider>
); );
@@ -57,12 +70,15 @@ export function useFileBrowser() {
} }
// Global reference for non-React code (like HttpApiClient) // Global reference for non-React code (like HttpApiClient)
let globalFileBrowserFn: (() => Promise<string | null>) | null = null; let globalFileBrowserFn: ((options?: FileBrowserOptions) => Promise<string | null>) | null = null;
export function setGlobalFileBrowser(fn: () => Promise<string | null>) { export function setGlobalFileBrowser(fn: (options?: FileBrowserOptions) => Promise<string | null>) {
globalFileBrowserFn = fn; globalFileBrowserFn = fn;
} }
export function getGlobalFileBrowser() { export function getGlobalFileBrowser() {
return globalFileBrowserFn; return globalFileBrowserFn;
} }
// Export the options type for consumers
export type { FileBrowserOptions };

View File

@@ -245,6 +245,7 @@ export interface Feature {
// Worktree info - set when a feature is being worked on in an isolated git worktree // Worktree info - set when a feature is being worked on in an isolated git worktree
worktreePath?: string; // Path to the worktree directory worktreePath?: string; // Path to the worktree directory
branchName?: string; // Name of the feature branch 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 // File tree node for project analysis
@@ -332,6 +333,13 @@ export interface AppState {
// Project Analysis // Project Analysis
projectAnalysis: ProjectAnalysis | null; projectAnalysis: ProjectAnalysis | null;
isAnalyzing: boolean; isAnalyzing: boolean;
// Board Background Settings (per-project, keyed by project path)
boardBackgroundByProject: Record<string, {
imagePath: string | null; // Path to background image in .automaker directory
cardOpacity: number; // Opacity of cards (0-100)
columnOpacity: number; // Opacity of columns (0-100)
}>;
} }
export interface AutoModeActivity { export interface AutoModeActivity {
@@ -455,6 +463,13 @@ export interface AppActions {
setLastSelectedSession: (projectPath: string, sessionId: string | null) => void; setLastSelectedSession: (projectPath: string, sessionId: string | null) => void;
getLastSelectedSession: (projectPath: string) => string | null; 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
reset: () => void; reset: () => void;
} }
@@ -546,6 +561,7 @@ const initialState: AppState = {
aiProfiles: DEFAULT_AI_PROFILES, aiProfiles: DEFAULT_AI_PROFILES,
projectAnalysis: null, projectAnalysis: null,
isAnalyzing: false, isAnalyzing: false,
boardBackgroundByProject: {},
}; };
export const useAppStore = create<AppState & AppActions>()( export const useAppStore = create<AppState & AppActions>()(
@@ -1131,6 +1147,69 @@ export const useAppStore = create<AppState & AppActions>()(
getLastSelectedSession: (projectPath) => { getLastSelectedSession: (projectPath) => {
return get().lastSelectedSessionByProject[projectPath] || null; 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
reset: () => set(initialState), reset: () => set(initialState),
}), }),
@@ -1164,6 +1243,8 @@ export const useAppStore = create<AppState & AppActions>()(
aiProfiles: state.aiProfiles, aiProfiles: state.aiProfiles,
chatSessions: state.chatSessions, chatSessions: state.chatSessions,
lastSelectedSessionByProject: state.lastSelectedSessionByProject, lastSelectedSessionByProject: state.lastSelectedSessionByProject,
// Board background settings
boardBackgroundByProject: state.boardBackgroundByProject,
}), }),
} }
) )

View File

@@ -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 // Browse directories for file picker
// SECURITY: Restricted to home directory, allowed paths, and drive roots on Windows // SECURITY: Restricted to home directory, allowed paths, and drive roots on Windows
router.post("/browse", async (req: Request, res: Response) => { router.post("/browse", async (req: Request, res: Response) => {

View File

@@ -3,32 +3,22 @@
*/ */
import { Router, type Request, type Response } from "express"; import { Router, type Request, type Response } from "express";
import path from "path"; import type { AutoModeService } from "../services/auto-mode-service.js";
interface RunningAgent { export function createRunningAgentsRoutes(autoModeService: AutoModeService): Router {
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<string, RunningAgent>();
let autoLoopRunning = false;
export function createRunningAgentsRoutes(): Router {
const router = Router(); const router = Router();
// Get all running agents // Get all running agents
router.get("/", async (_req: Request, res: Response) => { router.get("/", async (_req: Request, res: Response) => {
try { try {
const runningAgents = Array.from(runningAgentsMap.values()); const runningAgents = autoModeService.getRunningAgents();
const status = autoModeService.getStatus();
res.json({ res.json({
success: true, success: true,
runningAgents, runningAgents,
totalCount: runningAgents.length, totalCount: runningAgents.length,
autoLoopRunning, autoLoopRunning: status.autoLoopRunning,
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";
@@ -38,33 +28,3 @@ export function createRunningAgentsRoutes(): Router {
return 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);
}

View File

@@ -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 helpers
private async setupWorktree( private async setupWorktree(
@@ -639,6 +656,13 @@ Format your response as a structured markdown document.`;
const feature = JSON.parse(data); const feature = JSON.parse(data);
feature.status = status; feature.status = status;
feature.updatedAt = new Date().toISOString(); 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)); await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
} catch { } catch {
// Feature file may not exist // Feature file may not exist