Merge branch 'fs/ui' into removing-electron-features-build-api

Resolved conflict in http-api-client.ts by adopting the server-side
file browser dialog approach from fs/ui branch.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alec Koifman
2025-12-12 17:38:05 -05:00
16 changed files with 810 additions and 192 deletions

View File

@@ -15,8 +15,9 @@ import { RunningAgentsView } from "@/components/views/running-agents-view";
import { useAppStore } from "@/store/app-store";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI, isElectron } from "@/lib/electron";
import { FileBrowserProvider, useFileBrowser, setGlobalFileBrowser } from "@/contexts/file-browser-context";
export default function Home() {
function HomeContent() {
const {
currentView,
setCurrentView,
@@ -27,6 +28,7 @@ export default function Home() {
const { isFirstRun, setupComplete } = useSetupStore();
const [isMounted, setIsMounted] = useState(false);
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
const { openFileBrowser } = useFileBrowser();
// Hidden streamer panel - opens with "\" key
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
@@ -79,6 +81,11 @@ export default function Home() {
setIsMounted(true);
}, []);
// Initialize global file browser for HttpApiClient
useEffect(() => {
setGlobalFileBrowser(openFileBrowser);
}, [openFileBrowser]);
// Check if this is first run and redirect to setup if needed
useEffect(() => {
console.log("[Setup Flow] Checking setup state:", {
@@ -236,3 +243,11 @@ export default function Home() {
</main>
);
}
export default function Home() {
return (
<FileBrowserProvider>
<HomeContent />
</FileBrowserProvider>
);
}

View File

@@ -0,0 +1,231 @@
"use client";
import { useState, useEffect } from "react";
import { FolderOpen, Folder, ChevronRight, Home, ArrowLeft, HardDrive } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
interface DirectoryEntry {
name: string;
path: string;
}
interface BrowseResult {
success: boolean;
currentPath: string;
parentPath: string | null;
directories: DirectoryEntry[];
drives?: string[];
error?: string;
}
interface FileBrowserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (path: string) => void;
title?: string;
description?: string;
}
export function FileBrowserDialog({
open,
onOpenChange,
onSelect,
title = "Select Project Directory",
description = "Navigate to your project folder",
}: FileBrowserDialogProps) {
const [currentPath, setCurrentPath] = useState<string>("");
const [parentPath, setParentPath] = useState<string | null>(null);
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
const [drives, setDrives] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const browseDirectory = async (dirPath?: string) => {
setLoading(true);
setError("");
try {
// Get server URL from environment or default
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const response = await fetch(`${serverUrl}/api/fs/browse`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dirPath }),
});
const result: BrowseResult = await response.json();
if (result.success) {
setCurrentPath(result.currentPath);
setParentPath(result.parentPath);
setDirectories(result.directories);
setDrives(result.drives || []);
} else {
setError(result.error || "Failed to browse directory");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load directories");
} finally {
setLoading(false);
}
};
// Load home directory on mount
useEffect(() => {
if (open && !currentPath) {
browseDirectory();
}
}, [open]);
const handleSelectDirectory = (dir: DirectoryEntry) => {
browseDirectory(dir.path);
};
const handleGoToParent = () => {
if (parentPath) {
browseDirectory(parentPath);
}
};
const handleGoHome = () => {
browseDirectory();
};
const handleSelectDrive = (drivePath: string) => {
browseDirectory(drivePath);
};
const handleSelect = () => {
if (currentPath) {
onSelect(currentPath);
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderOpen className="w-5 h-5 text-brand-500" />
{title}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{description}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 min-h-[400px]">
{/* Drives selector (Windows only) */}
{drives.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-2">
<HardDrive className="w-3 h-3" />
<span>Drives:</span>
</div>
{drives.map((drive) => (
<Button
key={drive}
variant={currentPath.startsWith(drive) ? "default" : "outline"}
size="sm"
onClick={() => handleSelectDrive(drive)}
className="h-7 px-3 text-xs"
disabled={loading}
>
{drive.replace("\\", "")}
</Button>
))}
</div>
)}
{/* Current path breadcrumb */}
<div className="flex items-center gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
<Button
variant="ghost"
size="sm"
onClick={handleGoHome}
className="h-7 px-2"
disabled={loading}
>
<Home className="w-4 h-4" />
</Button>
{parentPath && (
<Button
variant="ghost"
size="sm"
onClick={handleGoToParent}
className="h-7 px-2"
disabled={loading}
>
<ArrowLeft className="w-4 h-4" />
</Button>
)}
<div className="flex-1 font-mono text-sm truncate text-muted-foreground">
{currentPath || "Loading..."}
</div>
</div>
{/* Directory list */}
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-lg">
{loading && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-muted-foreground">Loading directories...</div>
</div>
)}
{error && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-destructive">{error}</div>
</div>
)}
{!loading && !error && directories.length === 0 && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-muted-foreground">No subdirectories found</div>
</div>
)}
{!loading && !error && directories.length > 0 && (
<div className="divide-y divide-sidebar-border">
{directories.map((dir) => (
<button
key={dir.path}
onClick={() => handleSelectDirectory(dir)}
className="w-full flex items-center gap-3 p-3 hover:bg-sidebar-accent/10 transition-colors text-left group"
>
<Folder className="w-5 h-5 text-brand-500 shrink-0" />
<span className="flex-1 truncate text-sm">{dir.name}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</button>
))}
</div>
)}
</div>
<div className="text-xs text-muted-foreground">
Click on a folder to navigate. Select the current folder or navigate to a subfolder.
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSelect} disabled={!currentPath || loading}>
<FolderOpen className="w-4 h-4 mr-2" />
Select Current Folder
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -399,7 +399,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
`;
// Write the spec file
const specPath = `${currentProject.path}/app_spec.txt`;
const specPath = `${currentProject.path}/.automaker/app_spec.txt`;
const writeResult = await api.writeFile(specPath, specContent);
if (writeResult.success) {

View File

@@ -207,10 +207,12 @@ export const KanbanCard = memo(function KanbanCard({
// - Backlog items can always be dragged
// - skipTests items can be dragged even when in_progress or verified (unless currently running)
// - waiting_approval items can always be dragged (to allow manual verification via drag)
// - Non-skipTests (TDD) items in progress or verified cannot be dragged
// - verified items can always be dragged (to allow moving back to waiting_approval or backlog)
// - Non-skipTests (TDD) items in progress cannot be dragged (they are running)
const isDraggable =
feature.status === "backlog" ||
feature.status === "waiting_approval" ||
feature.status === "verified" ||
(feature.skipTests && !isCurrentAutoTask);
const {
attributes,

View File

@@ -61,13 +61,15 @@ export function AuthenticationStatusDisplay({
{claudeAuthStatus.method === "oauth_token_env"
? "Using CLAUDE_CODE_OAUTH_TOKEN"
: claudeAuthStatus.method === "oauth_token"
? "Using stored OAuth token (claude login)"
? "Using stored OAuth token (subscription)"
: claudeAuthStatus.method === "api_key_env"
? "Using ANTHROPIC_API_KEY"
: claudeAuthStatus.method === "api_key"
? "Using stored API key"
: claudeAuthStatus.method === "credentials_file"
? "Using credentials file"
: claudeAuthStatus.method === "cli_authenticated"
? "Using Claude CLI authentication"
: `Using ${claudeAuthStatus.method || "detected"} authentication`}
</span>
</div>

View File

@@ -74,8 +74,8 @@ export function useCliStatus() {
apiKeyValid?: boolean;
};
// Map server method names to client method types
// Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, none
const validMethods = ["oauth_token_env", "oauth_token", "api_key", "api_key_env", "credentials_file", "none"] as const;
// Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, cli_authenticated, none
const validMethods = ["oauth_token_env", "oauth_token", "api_key", "api_key_env", "credentials_file", "cli_authenticated", "none"] as const;
type AuthMethod = typeof validMethods[number];
const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
? (auth.method as AuthMethod)

View File

@@ -40,6 +40,8 @@ export function useCliStatus({
"oauth_token",
"api_key",
"api_key_env",
"credentials_file",
"cli_authenticated",
"none",
] as const;
type AuthMethod = (typeof validMethods)[number];

View File

@@ -14,7 +14,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Save, RefreshCw, FileText, Sparkles, Loader2, FilePlus2, AlertCircle, ListPlus } from "lucide-react";
import { Save, RefreshCw, FileText, Sparkles, Loader2, FilePlus2, AlertCircle, ListPlus, CheckCircle2 } from "lucide-react";
import { toast } from "sonner";
import { Checkbox } from "@/components/ui/checkbox";
import { XmlSyntaxEditor } from "@/components/ui/xml-syntax-editor";
import type { SpecRegenerationEvent } from "@/types/electron";
@@ -311,14 +312,22 @@ export function SpecView() {
// The backend sends explicit signals for completion:
// 1. "All tasks completed" in the message
// 2. [Phase: complete] marker in logs
// 3. "Spec regeneration complete!" for regeneration
// 4. "Initial spec creation complete!" for creation without features
const isFinalCompletionMessage = event.message?.includes("All tasks completed") ||
event.message === "All tasks completed!" ||
event.message === "All tasks completed";
event.message === "All tasks completed" ||
event.message === "Spec regeneration complete!" ||
event.message === "Initial spec creation complete!";
const hasCompletePhase = logsRef.current.includes("[Phase: complete]");
// Intermediate completion means features are being generated after spec creation
const isIntermediateCompletion = event.message?.includes("Features are being generated") ||
event.message?.includes("features are being generated");
// Rely solely on explicit backend signals
const shouldComplete = isFinalCompletionMessage || hasCompletePhase;
const shouldComplete = (isFinalCompletionMessage || hasCompletePhase) && !isIntermediateCompletion;
if (shouldComplete) {
// Fully complete - clear all states immediately
@@ -337,9 +346,29 @@ export function SpecView() {
setProjectOverview("");
setErrorMessage("");
stateRestoredRef.current = false;
// Reload the spec to show the new content
loadSpec();
} else {
// Reload the spec with delay to ensure file is written to disk
setTimeout(() => {
loadSpec();
}, SPEC_FILE_WRITE_DELAY);
// Show success toast notification
const isRegeneration = event.message?.includes("regeneration");
const isFeatureGeneration = event.message?.includes("Feature generation");
toast.success(
isFeatureGeneration
? "Feature Generation Complete"
: isRegeneration
? "Spec Regeneration Complete"
: "Spec Creation Complete",
{
description: isFeatureGeneration
? "Features have been created from the app specification."
: "Your app specification has been saved.",
icon: <CheckCircle2 className="w-4 h-4" />,
}
);
} else if (isIntermediateCompletion) {
// Intermediate completion - keep state active for feature generation
setIsCreating(true);
setIsRegenerating(true);

View File

@@ -0,0 +1,68 @@
"use client";
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import { FileBrowserDialog } from "@/components/dialogs/file-browser-dialog";
interface FileBrowserContextValue {
openFileBrowser: () => Promise<string | null>;
}
const FileBrowserContext = createContext<FileBrowserContextValue | null>(null);
export function FileBrowserProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const [resolver, setResolver] = useState<((value: string | null) => void) | null>(null);
const openFileBrowser = useCallback((): Promise<string | null> => {
return new Promise((resolve) => {
setIsOpen(true);
setResolver(() => resolve);
});
}, []);
const handleSelect = useCallback((path: string) => {
if (resolver) {
resolver(path);
setResolver(null);
}
setIsOpen(false);
}, [resolver]);
const handleOpenChange = useCallback((open: boolean) => {
if (!open && resolver) {
resolver(null);
setResolver(null);
}
setIsOpen(open);
}, [resolver]);
return (
<FileBrowserContext.Provider value={{ openFileBrowser }}>
{children}
<FileBrowserDialog
open={isOpen}
onOpenChange={handleOpenChange}
onSelect={handleSelect}
/>
</FileBrowserContext.Provider>
);
}
export function useFileBrowser() {
const context = useContext(FileBrowserContext);
if (!context) {
throw new Error("useFileBrowser must be used within FileBrowserProvider");
}
return context;
}
// Global reference for non-React code (like HttpApiClient)
let globalFileBrowserFn: (() => Promise<string | null>) | null = null;
export function setGlobalFileBrowser(fn: () => Promise<string | null>) {
globalFileBrowserFn = fn;
}
export function getGlobalFileBrowser() {
return globalFileBrowserFn;
}

View File

@@ -31,7 +31,7 @@ import type {
ModelDefinition,
ProviderStatus,
} from "@/types/electron";
import { openDirectoryPicker, openFilePicker, type DirectoryPickerResult } from "./file-picker";
import { getGlobalFileBrowser } from "@/contexts/file-browser-context";
// Server URL - configurable via environment variable
@@ -202,96 +202,62 @@ export class HttpApiClient implements ElectronAPI {
return { success: true };
}
// File picker - uses web-based file picker (works on Windows)
// File picker - uses server-side file browser dialog
async openDirectory(): Promise<DialogResult> {
try {
console.log("[HttpApiClient] Opening directory picker...");
const directoryInfo = await openDirectoryPicker();
console.log("[HttpApiClient] Directory info:", directoryInfo);
if (!directoryInfo) {
console.log("[HttpApiClient] No directory selected (user canceled)");
return { canceled: true, filePaths: [] };
}
const fileBrowser = getGlobalFileBrowser();
// Try to resolve directory path using server endpoint
// First, try if we have an absolute path (from file.path property)
if (directoryInfo.directoryName && (directoryInfo.directoryName.includes("\\") || directoryInfo.directoryName.includes("/") || directoryInfo.directoryName.startsWith("/"))) {
// Looks like an absolute path, try validating it directly
console.log("[HttpApiClient] Attempting direct path validation:", directoryInfo.directoryName);
const directResult = await this.post<{
success: boolean;
path?: string;
error?: string;
}>("/api/fs/validate-path", { filePath: directoryInfo.directoryName });
if (directResult.success && directResult.path) {
console.log("[HttpApiClient] Direct path validation succeeded:", directResult.path);
return { canceled: false, filePaths: [directResult.path] };
}
}
// If direct validation failed or we only have a directory name,
// use the resolve endpoint with directory structure
console.log("[HttpApiClient] Resolving directory using structure info...");
const result = await this.post<{
success: boolean;
path?: string;
error?: string;
}>("/api/fs/resolve-directory", {
directoryName: directoryInfo.directoryName,
sampleFiles: directoryInfo.sampleFiles,
fileCount: directoryInfo.fileCount,
});
console.log("[HttpApiClient] Directory resolution result:", result);
if (result.success && result.path) {
console.log("[HttpApiClient] Directory resolved successfully:", result.path);
return { canceled: false, filePaths: [result.path] };
}
// If resolution failed, show error
console.warn("[HttpApiClient] Directory resolution failed:", result.error);
const errorMsg = result.error || "Could not locate directory. Please ensure the directory exists and try selecting it again.";
alert(errorMsg);
return { canceled: true, filePaths: [] };
} catch (error) {
console.error("[HttpApiClient] Failed to open directory picker:", error);
alert("Failed to open directory picker. Please try again.");
if (!fileBrowser) {
console.error("File browser not initialized");
return { canceled: true, filePaths: [] };
}
const path = await fileBrowser();
if (!path) {
return { canceled: true, filePaths: [] };
}
// Validate with server
const result = await this.post<{
success: boolean;
path?: string;
error?: string;
}>("/api/fs/validate-path", { filePath: path });
if (result.success && result.path) {
return { canceled: false, filePaths: [result.path] };
}
console.error("Invalid directory:", result.error);
return { canceled: true, filePaths: [] };
}
async openFile(options?: object): Promise<DialogResult> {
try {
const selectedPath = await openFilePicker(options);
if (!selectedPath) {
return { canceled: true, filePaths: [] };
}
const fileBrowser = getGlobalFileBrowser();
// Handle both single file and multiple files
const filePaths = Array.isArray(selectedPath) ? selectedPath : [selectedPath];
// Validate files exist with server
// For multiple files, check the first one as a validation step
const firstPath = filePaths[0];
const result = await this.post<{ success: boolean; exists: boolean }>(
"/api/fs/exists",
{ filePath: firstPath }
);
if (result.success && result.exists) {
return { canceled: false, filePaths };
}
alert("File does not exist or cannot be accessed.");
return { canceled: true, filePaths: [] };
} catch (error) {
console.error("[HttpApiClient] Failed to open file picker:", error);
alert("Failed to open file picker. Please try again.");
if (!fileBrowser) {
console.error("File browser not initialized");
return { canceled: true, filePaths: [] };
}
// For now, use the same directory browser (could be enhanced for file selection)
const path = await fileBrowser();
if (!path) {
return { canceled: true, filePaths: [] };
}
const result = await this.post<{ success: boolean; exists: boolean }>(
"/api/fs/exists",
{ filePath: path }
);
if (result.success && result.exists) {
return { canceled: false, filePaths: [path] };
}
console.error("File not found");
return { canceled: true, filePaths: [] };
}
// File system operations

View File

@@ -17,6 +17,7 @@ export type ClaudeAuthMethod =
| "api_key_env" // ANTHROPIC_API_KEY environment variable
| "api_key" // Manually stored API key
| "credentials_file" // Generic credentials file detection
| "cli_authenticated" // Claude CLI is installed and has active sessions/activity
| "none";
// Claude Auth Status