mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
refactor: streamline Electron API integration and enhance UI components
- Removed unused Electron API methods and simplified the main process. - Introduced a new workspace picker modal for improved project selection. - Enhanced error handling for authentication issues across various components. - Updated UI styles for dark mode support and added new CSS variables. - Refactored session management to utilize a centralized API access method. - Added server routes for workspace management, including directory listing and configuration checks.
This commit is contained in:
@@ -143,6 +143,80 @@
|
||||
--running-indicator-text: oklch(0.6 0.22 265);
|
||||
}
|
||||
|
||||
/* Apply dark mode immediately based on system preference (before JS runs) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* Deep dark backgrounds - zinc-950 family */
|
||||
--background: oklch(0.04 0 0); /* zinc-950 */
|
||||
--background-50: oklch(0.04 0 0 / 0.5); /* zinc-950/50 */
|
||||
--background-80: oklch(0.04 0 0 / 0.8); /* zinc-950/80 */
|
||||
|
||||
/* Text colors following hierarchy */
|
||||
--foreground: oklch(1 0 0); /* text-white */
|
||||
--foreground-secondary: oklch(0.588 0 0); /* text-zinc-400 */
|
||||
--foreground-muted: oklch(0.525 0 0); /* text-zinc-500 */
|
||||
|
||||
/* Card and popover backgrounds */
|
||||
--card: oklch(0.14 0 0);
|
||||
--card-foreground: oklch(1 0 0);
|
||||
--popover: oklch(0.10 0 0);
|
||||
--popover-foreground: oklch(1 0 0);
|
||||
|
||||
/* Brand colors - purple/violet theme */
|
||||
--primary: oklch(0.55 0.25 265);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--brand-400: oklch(0.6 0.22 265);
|
||||
--brand-500: oklch(0.55 0.25 265);
|
||||
--brand-600: oklch(0.5 0.28 270);
|
||||
|
||||
/* Glass morphism borders and accents */
|
||||
--secondary: oklch(1 0 0 / 0.05);
|
||||
--secondary-foreground: oklch(1 0 0);
|
||||
--muted: oklch(0.176 0 0);
|
||||
--muted-foreground: oklch(0.588 0 0);
|
||||
--accent: oklch(1 0 0 / 0.1);
|
||||
--accent-foreground: oklch(1 0 0);
|
||||
|
||||
/* Borders with transparency for glass effect */
|
||||
--border: oklch(0.176 0 0);
|
||||
--border-glass: oklch(1 0 0 / 0.1);
|
||||
--destructive: oklch(0.6 0.25 25);
|
||||
--input: oklch(0.04 0 0 / 0.8);
|
||||
--ring: oklch(0.55 0.25 265);
|
||||
|
||||
/* Chart colors with brand theme */
|
||||
--chart-1: oklch(0.55 0.25 265);
|
||||
--chart-2: oklch(0.65 0.2 160);
|
||||
--chart-3: oklch(0.75 0.2 70);
|
||||
--chart-4: oklch(0.6 0.25 300);
|
||||
--chart-5: oklch(0.6 0.25 20);
|
||||
|
||||
/* Sidebar with glass morphism */
|
||||
--sidebar: oklch(0.04 0 0 / 0.5);
|
||||
--sidebar-foreground: oklch(1 0 0);
|
||||
--sidebar-primary: oklch(0.55 0.25 265);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(1 0 0 / 0.05);
|
||||
--sidebar-accent-foreground: oklch(1 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 0.1);
|
||||
--sidebar-ring: oklch(0.55 0.25 265);
|
||||
|
||||
/* Action button colors */
|
||||
--action-view: oklch(0.6 0.25 265);
|
||||
--action-view-hover: oklch(0.55 0.27 270);
|
||||
--action-followup: oklch(0.6 0.2 230);
|
||||
--action-followup-hover: oklch(0.55 0.22 230);
|
||||
--action-commit: oklch(0.55 0.2 140);
|
||||
--action-commit-hover: oklch(0.5 0.22 140);
|
||||
--action-verify: oklch(0.55 0.2 140);
|
||||
--action-verify-hover: oklch(0.5 0.22 140);
|
||||
|
||||
/* Running indicator - Purple */
|
||||
--running-indicator: oklch(0.6 0.25 265);
|
||||
--running-indicator-text: oklch(0.65 0.22 265);
|
||||
}
|
||||
}
|
||||
|
||||
.light {
|
||||
/* Explicit light mode - same as root but ensures it overrides any dark defaults */
|
||||
--background: oklch(1 0 0); /* White */
|
||||
@@ -211,10 +285,10 @@
|
||||
--foreground-secondary: oklch(0.588 0 0); /* text-zinc-400 */
|
||||
--foreground-muted: oklch(0.525 0 0); /* text-zinc-500 */
|
||||
|
||||
/* Glass morphism effects */
|
||||
--card: oklch(0.04 0 0 / 0.5); /* zinc-950/50 with transparency */
|
||||
/* Card and popover backgrounds */
|
||||
--card: oklch(0.14 0 0); /* slightly lighter than background for contrast */
|
||||
--card-foreground: oklch(1 0 0);
|
||||
--popover: oklch(0.04 0 0 / 0.8); /* zinc-950/80 for popover */
|
||||
--popover: oklch(0.10 0 0); /* slightly lighter than background */
|
||||
--popover-foreground: oklch(1 0 0);
|
||||
|
||||
/* Brand colors - purple/violet theme */
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { GeistMono } from "geist/font/mono";
|
||||
import { Toaster } from "sonner";
|
||||
import { CoursePromoBadge } from "@/components/ui/course-promo-badge";
|
||||
import "./globals.css";
|
||||
export const metadata: Metadata = {
|
||||
title: "Automaker - Autonomous AI Development Studio",
|
||||
@@ -20,6 +21,7 @@ export default function RootLayout({
|
||||
>
|
||||
{children}
|
||||
<Toaster richColors position="bottom-right" />
|
||||
<CoursePromoBadge />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SessionListItem } from "@/types/electron";
|
||||
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
|
||||
// Random session name generator
|
||||
const adjectives = [
|
||||
@@ -115,14 +116,15 @@ export function SessionManager({
|
||||
|
||||
// Check running state for all sessions
|
||||
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
||||
if (!window.electronAPI?.agent) return;
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) return;
|
||||
|
||||
const runningIds = new Set<string>();
|
||||
|
||||
// Check each session's running state
|
||||
for (const session of sessionList) {
|
||||
try {
|
||||
const result = await window.electronAPI.agent.getHistory(session.id);
|
||||
const result = await api.agent.getHistory(session.id);
|
||||
if (result.success && result.isRunning) {
|
||||
runningIds.add(session.id);
|
||||
}
|
||||
@@ -140,10 +142,11 @@ export function SessionManager({
|
||||
|
||||
// Load sessions
|
||||
const loadSessions = async () => {
|
||||
if (!window.electronAPI?.sessions) return;
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) return;
|
||||
|
||||
// Always load all sessions and filter client-side
|
||||
const result = await window.electronAPI.sessions.list(true);
|
||||
const result = await api.sessions.list(true);
|
||||
if (result.success && result.sessions) {
|
||||
setSessions(result.sessions);
|
||||
// Check running state for all sessions
|
||||
@@ -171,39 +174,41 @@ export function SessionManager({
|
||||
|
||||
// Create new session with random name
|
||||
const handleCreateSession = async () => {
|
||||
if (!window.electronAPI?.sessions) return;
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) return;
|
||||
|
||||
const sessionName = newSessionName.trim() || generateRandomSessionName();
|
||||
|
||||
const result = await window.electronAPI.sessions.create(
|
||||
const result = await api.sessions.create(
|
||||
sessionName,
|
||||
projectPath,
|
||||
projectPath
|
||||
);
|
||||
|
||||
if (result.success && result.sessionId) {
|
||||
if (result.success && result.session?.id) {
|
||||
setNewSessionName("");
|
||||
setIsCreating(false);
|
||||
await loadSessions();
|
||||
onSelectSession(result.sessionId);
|
||||
onSelectSession(result.session.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Create new session directly with a random name (one-click)
|
||||
const handleQuickCreateSession = async () => {
|
||||
if (!window.electronAPI?.sessions) return;
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) return;
|
||||
|
||||
const sessionName = generateRandomSessionName();
|
||||
|
||||
const result = await window.electronAPI.sessions.create(
|
||||
const result = await api.sessions.create(
|
||||
sessionName,
|
||||
projectPath,
|
||||
projectPath
|
||||
);
|
||||
|
||||
if (result.success && result.sessionId) {
|
||||
if (result.success && result.session?.id) {
|
||||
await loadSessions();
|
||||
onSelectSession(result.sessionId);
|
||||
onSelectSession(result.session.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -221,9 +226,10 @@ export function SessionManager({
|
||||
|
||||
// Rename session
|
||||
const handleRenameSession = async (sessionId: string) => {
|
||||
if (!editingName.trim() || !window.electronAPI?.sessions) return;
|
||||
const api = getElectronAPI();
|
||||
if (!editingName.trim() || !api?.sessions) return;
|
||||
|
||||
const result = await window.electronAPI.sessions.update(
|
||||
const result = await api.sessions.update(
|
||||
sessionId,
|
||||
editingName,
|
||||
undefined
|
||||
@@ -238,9 +244,10 @@ export function SessionManager({
|
||||
|
||||
// Archive session
|
||||
const handleArchiveSession = async (sessionId: string) => {
|
||||
if (!window.electronAPI?.sessions) return;
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) return;
|
||||
|
||||
const result = await window.electronAPI.sessions.archive(sessionId);
|
||||
const result = await api.sessions.archive(sessionId);
|
||||
if (result.success) {
|
||||
// If the archived session was currently selected, deselect it
|
||||
if (currentSessionId === sessionId) {
|
||||
@@ -252,9 +259,10 @@ export function SessionManager({
|
||||
|
||||
// Unarchive session
|
||||
const handleUnarchiveSession = async (sessionId: string) => {
|
||||
if (!window.electronAPI?.sessions) return;
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) return;
|
||||
|
||||
const result = await window.electronAPI.sessions.unarchive(sessionId);
|
||||
const result = await api.sessions.unarchive(sessionId);
|
||||
if (result.success) {
|
||||
await loadSessions();
|
||||
}
|
||||
@@ -262,10 +270,11 @@ export function SessionManager({
|
||||
|
||||
// Delete session
|
||||
const handleDeleteSession = async (sessionId: string) => {
|
||||
if (!window.electronAPI?.sessions) return;
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) return;
|
||||
if (!confirm("Are you sure you want to delete this session?")) return;
|
||||
|
||||
const result = await window.electronAPI.sessions.delete(sessionId);
|
||||
const result = await api.sessions.delete(sessionId);
|
||||
if (result.success) {
|
||||
await loadSessions();
|
||||
if (currentSessionId === sessionId) {
|
||||
|
||||
136
apps/app/src/components/ui/course-promo-badge.tsx
Normal file
136
apps/app/src/components/ui/course-promo-badge.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Sparkles, Rocket, X, ExternalLink, Code, MessageSquare, Brain, Terminal } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "./dialog";
|
||||
import { Button } from "./button";
|
||||
|
||||
export function CoursePromoBadge() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [dismissed, setDismissed] = React.useState(false);
|
||||
|
||||
if (dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="group cursor-pointer flex items-center gap-2 pl-4 pr-2 py-2 bg-primary text-primary-foreground rounded-full font-semibold text-sm shadow-lg hover:bg-primary/90 hover:scale-105 transition-all border border-border"
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
<span>Become a 10x Dev</span>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDismissed(true);
|
||||
}}
|
||||
className="p-1 rounded-full hover:bg-primary-foreground/20 transition-colors cursor-pointer"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||
<Rocket className="size-5 text-primary" />
|
||||
Learn Agentic AI Development
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-base">
|
||||
Master the tools and techniques behind modern AI-assisted coding
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="p-3 rounded-lg bg-accent/50 border border-border">
|
||||
<p className="text-sm text-foreground">
|
||||
Did you know <span className="font-semibold">Automaker was built entirely through agentic coding</span>?
|
||||
Want to learn how? Check out the course!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">Agentic Jumpstart</span> teaches you
|
||||
how to leverage AI tools to build software faster and smarter than ever before.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10">
|
||||
<Terminal className="size-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">Claude Code Mastery</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn to use Claude Code effectively for autonomous development workflows
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10">
|
||||
<Code className="size-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">Cursor & AI IDEs</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Master Cursor and other AI-powered development environments
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10">
|
||||
<MessageSquare className="size-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">Prompting Techniques</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Craft effective prompts that get you the results you need
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10">
|
||||
<Brain className="size-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">Context Engineering</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Structure your projects and context for optimal AI collaboration
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Maybe Later
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.open("https://agenticjumpstart.com", "_blank")}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Get Started
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -83,6 +83,13 @@ export function DescriptionImageDropZone({
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const currentProject = useAppStore((state) => state.currentProject);
|
||||
|
||||
// Construct server URL for loading saved images
|
||||
const getImageServerUrl = useCallback((imagePath: string): string => {
|
||||
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
|
||||
const projectPath = currentProject?.path || "";
|
||||
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
|
||||
}, [currentProject?.path]);
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -374,7 +381,15 @@ export function DescriptionImageDropZone({
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className="w-6 h-6 text-muted-foreground" />
|
||||
<img
|
||||
src={getImageServerUrl(image.path)}
|
||||
alt={image.filename}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
onError={(e) => {
|
||||
// If image fails to load, hide it
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Remove button */}
|
||||
|
||||
@@ -583,10 +583,24 @@ export function BoardView() {
|
||||
}
|
||||
|
||||
loadFeatures();
|
||||
// Show error toast
|
||||
toast.error("Agent encountered an error", {
|
||||
description: event.error || "Check the logs for details",
|
||||
});
|
||||
|
||||
// Check for authentication errors and show a more helpful message
|
||||
const isAuthError = event.errorType === "authentication" ||
|
||||
(event.error && (
|
||||
event.error.includes("Authentication failed") ||
|
||||
event.error.includes("Invalid API key")
|
||||
));
|
||||
|
||||
if (isAuthError) {
|
||||
toast.error("Authentication Failed", {
|
||||
description: "Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.",
|
||||
duration: 10000,
|
||||
});
|
||||
} else {
|
||||
toast.error("Agent encountered an error", {
|
||||
description: event.error || "Check the logs for details",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { toast } from "sonner";
|
||||
import { WorkspacePickerModal } from "@/components/workspace-picker-modal";
|
||||
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||
|
||||
export function WelcomeView() {
|
||||
const { projects, addProject, setCurrentProject, setCurrentView } =
|
||||
@@ -57,6 +59,7 @@ export function WelcomeView() {
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
} | null>(null);
|
||||
const [showWorkspacePicker, setShowWorkspacePicker] = useState(false);
|
||||
|
||||
/**
|
||||
* Kick off project analysis agent to analyze the codebase
|
||||
@@ -172,17 +175,51 @@ export function WelcomeView() {
|
||||
);
|
||||
|
||||
const handleOpenProject = useCallback(async () => {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.openDirectory();
|
||||
try {
|
||||
// Check if workspace is configured
|
||||
const httpClient = getHttpApiClient();
|
||||
const configResult = await httpClient.workspace.getConfig();
|
||||
|
||||
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";
|
||||
await initializeAndOpenProject(path, name);
|
||||
if (configResult.success && configResult.configured) {
|
||||
// Show workspace picker modal
|
||||
setShowWorkspacePicker(true);
|
||||
} else {
|
||||
// Fall back to current behavior (native dialog or manual input)
|
||||
const api = getElectronAPI();
|
||||
const result = await api.openDirectory();
|
||||
|
||||
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";
|
||||
await initializeAndOpenProject(path, name);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Welcome] Failed to check workspace config:", error);
|
||||
// Fall back to current behavior on error
|
||||
const api = getElectronAPI();
|
||||
const result = await api.openDirectory();
|
||||
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
const path = result.filePaths[0];
|
||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
|
||||
await initializeAndOpenProject(path, name);
|
||||
}
|
||||
}
|
||||
}, [initializeAndOpenProject]);
|
||||
|
||||
/**
|
||||
* Handle selecting a project from workspace picker
|
||||
*/
|
||||
const handleWorkspaceSelect = useCallback(
|
||||
async (path: string, name: string) => {
|
||||
setShowWorkspacePicker(false);
|
||||
await initializeAndOpenProject(path, name);
|
||||
},
|
||||
[initializeAndOpenProject]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle clicking on a recent project
|
||||
*/
|
||||
@@ -621,6 +658,13 @@ export function WelcomeView() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Workspace Picker Modal */}
|
||||
<WorkspacePickerModal
|
||||
open={showWorkspacePicker}
|
||||
onOpenChange={setShowWorkspacePicker}
|
||||
onSelect={handleWorkspaceSelect}
|
||||
/>
|
||||
|
||||
{/* Loading overlay when opening project */}
|
||||
{isOpening && (
|
||||
<div
|
||||
|
||||
154
apps/app/src/components/workspace-picker-modal.tsx
Normal file
154
apps/app/src/components/workspace-picker-modal.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Folder, Loader2, FolderOpen, AlertCircle } from "lucide-react";
|
||||
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||
|
||||
interface WorkspaceDirectory {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface WorkspacePickerModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (path: string, name: string) => void;
|
||||
}
|
||||
|
||||
export function WorkspacePickerModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
}: WorkspacePickerModalProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadDirectories = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const client = getHttpApiClient();
|
||||
const result = await client.workspace.getDirectories();
|
||||
|
||||
if (result.success && result.directories) {
|
||||
setDirectories(result.directories);
|
||||
} else {
|
||||
setError(result.error || "Failed to load directories");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load directories");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load directories when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadDirectories();
|
||||
}
|
||||
}, [open, loadDirectories]);
|
||||
|
||||
const handleSelect = (dir: WorkspaceDirectory) => {
|
||||
onSelect(dir.path, dir.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-card border-border max-w-lg max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||
<FolderOpen className="w-5 h-5 text-brand-500" />
|
||||
Select Project
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Choose a project from your workspace directory
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4 min-h-[200px]">
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<Loader2 className="w-8 h-8 text-brand-500 animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">Loading projects...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
|
||||
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||
<AlertCircle className="w-6 h-6 text-destructive" />
|
||||
</div>
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadDirectories}
|
||||
className="mt-2"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && directories.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
|
||||
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center">
|
||||
<Folder className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No projects found in workspace directory
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && directories.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{directories.map((dir) => (
|
||||
<button
|
||||
key={dir.path}
|
||||
onClick={() => handleSelect(dir)}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-lg border border-border bg-card hover:bg-card/70 hover:border-brand-500/50 transition-all duration-200 text-left group"
|
||||
data-testid={`workspace-dir-${dir.name}`}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-muted border border-border flex items-center justify-center group-hover:border-brand-500/50 transition-colors shrink-0">
|
||||
<Folder className="w-5 h-5 text-muted-foreground group-hover:text-brand-500 transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground truncate group-hover:text-brand-500 transition-colors">
|
||||
{dir.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 truncate">
|
||||
{dir.path}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -121,11 +121,26 @@ export function useAutoMode() {
|
||||
case "auto_mode_error":
|
||||
console.error("[AutoMode Error]", event.error);
|
||||
if (event.featureId && event.error) {
|
||||
// Check for authentication errors and provide a more helpful message
|
||||
const isAuthError = event.errorType === "authentication" ||
|
||||
event.error.includes("Authentication failed") ||
|
||||
event.error.includes("Invalid API key");
|
||||
|
||||
const errorMessage = isAuthError
|
||||
? `Authentication failed: Please check your API key in Settings or run 'claude login' in terminal to re-authenticate.`
|
||||
: event.error;
|
||||
|
||||
addAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: "error",
|
||||
message: event.error,
|
||||
message: errorMessage,
|
||||
errorType: isAuthError ? "authentication" : "execution",
|
||||
});
|
||||
|
||||
// Remove the task from running since it failed
|
||||
if (eventProjectId) {
|
||||
removeRunningTask(eventProjectId, event.featureId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { Message, StreamEvent } from "@/types/electron";
|
||||
import { useMessageQueue } from "./use-message-queue";
|
||||
import type { ImageAttachment } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
|
||||
interface UseElectronAgentOptions {
|
||||
sessionId: string;
|
||||
@@ -44,8 +45,9 @@ export function useElectronAgent({
|
||||
// Send message directly to the agent (bypassing queue)
|
||||
const sendMessageDirectly = useCallback(
|
||||
async (content: string, images?: ImageAttachment[]) => {
|
||||
if (!window.electronAPI?.agent) {
|
||||
setError("Electron API not available");
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) {
|
||||
setError("API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -64,10 +66,10 @@ export function useElectronAgent({
|
||||
|
||||
// Save images to .automaker/images and get paths
|
||||
let imagePaths: string[] | undefined;
|
||||
if (images && images.length > 0) {
|
||||
if (images && images.length > 0 && api.saveImageToTemp) {
|
||||
imagePaths = [];
|
||||
for (const image of images) {
|
||||
const result = await window.electronAPI.saveImageToTemp(
|
||||
const result = await api.saveImageToTemp(
|
||||
image.data,
|
||||
image.filename,
|
||||
image.mimeType,
|
||||
@@ -82,7 +84,7 @@ export function useElectronAgent({
|
||||
}
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.agent.send(
|
||||
const result = await api.agent!.send(
|
||||
sessionId,
|
||||
content,
|
||||
workingDirectory,
|
||||
@@ -120,8 +122,9 @@ export function useElectronAgent({
|
||||
|
||||
// Initialize connection and load history
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI?.agent) {
|
||||
setError("Electron API not available. Please run in Electron.");
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) {
|
||||
setError("API not available.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -142,7 +145,7 @@ export function useElectronAgent({
|
||||
|
||||
try {
|
||||
console.log("[useElectronAgent] Starting session:", sessionId);
|
||||
const result = await window.electronAPI.agent.start(
|
||||
const result = await api.agent!.start(
|
||||
sessionId,
|
||||
workingDirectory
|
||||
);
|
||||
@@ -155,7 +158,7 @@ export function useElectronAgent({
|
||||
setIsConnected(true);
|
||||
|
||||
// Check if the agent is currently running for this session
|
||||
const historyResult = await window.electronAPI.agent.getHistory(sessionId);
|
||||
const historyResult = await api.agent!.getHistory(sessionId);
|
||||
if (mounted && historyResult.success) {
|
||||
const isRunning = historyResult.isRunning || false;
|
||||
console.log("[useElectronAgent] Session running state:", isRunning);
|
||||
@@ -190,7 +193,8 @@ export function useElectronAgent({
|
||||
|
||||
// Subscribe to streaming events
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI?.agent) return;
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) return;
|
||||
if (!sessionId) return; // Don't subscribe if no session
|
||||
|
||||
console.log("[useElectronAgent] Subscribing to stream events for session:", sessionId);
|
||||
@@ -282,7 +286,7 @@ export function useElectronAgent({
|
||||
}
|
||||
};
|
||||
|
||||
unsubscribeRef.current = window.electronAPI.agent.onStream(handleStream);
|
||||
unsubscribeRef.current = api.agent!.onStream(handleStream as (data: unknown) => void);
|
||||
|
||||
return () => {
|
||||
if (unsubscribeRef.current) {
|
||||
@@ -296,8 +300,9 @@ export function useElectronAgent({
|
||||
// Send a message to the agent
|
||||
const sendMessage = useCallback(
|
||||
async (content: string, images?: ImageAttachment[]) => {
|
||||
if (!window.electronAPI?.agent) {
|
||||
setError("Electron API not available");
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) {
|
||||
setError("API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -317,10 +322,10 @@ export function useElectronAgent({
|
||||
|
||||
// Save images to .automaker/images and get paths
|
||||
let imagePaths: string[] | undefined;
|
||||
if (images && images.length > 0) {
|
||||
if (images && images.length > 0 && api.saveImageToTemp) {
|
||||
imagePaths = [];
|
||||
for (const image of images) {
|
||||
const result = await window.electronAPI.saveImageToTemp(
|
||||
const result = await api.saveImageToTemp(
|
||||
image.data,
|
||||
image.filename,
|
||||
image.mimeType,
|
||||
@@ -335,7 +340,7 @@ export function useElectronAgent({
|
||||
}
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.agent.send(
|
||||
const result = await api.agent!.send(
|
||||
sessionId,
|
||||
content,
|
||||
workingDirectory,
|
||||
@@ -359,14 +364,15 @@ export function useElectronAgent({
|
||||
|
||||
// Stop current execution
|
||||
const stopExecution = useCallback(async () => {
|
||||
if (!window.electronAPI?.agent) {
|
||||
setError("Electron API not available");
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) {
|
||||
setError("API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[useElectronAgent] Stopping execution");
|
||||
const result = await window.electronAPI.agent.stop(sessionId);
|
||||
const result = await api.agent!.stop(sessionId);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || "Failed to stop execution");
|
||||
@@ -381,14 +387,15 @@ export function useElectronAgent({
|
||||
|
||||
// Clear conversation history
|
||||
const clearHistory = useCallback(async () => {
|
||||
if (!window.electronAPI?.agent) {
|
||||
setError("Electron API not available");
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) {
|
||||
setError("API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[useElectronAgent] Clearing history");
|
||||
const result = await window.electronAPI.agent.clear(sessionId);
|
||||
const result = await api.agent!.clear(sessionId);
|
||||
|
||||
if (result.success) {
|
||||
setMessages([]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Type definitions for Electron IPC API
|
||||
import type { SessionListItem, Message } from "@/types/electron";
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
@@ -413,6 +414,59 @@ export interface ElectronAPI {
|
||||
onInstallProgress?: (callback: (progress: any) => void) => () => void;
|
||||
onAuthProgress?: (callback: (progress: any) => void) => () => void;
|
||||
};
|
||||
agent?: {
|
||||
start: (sessionId: string, workingDirectory?: string) => Promise<{
|
||||
success: boolean;
|
||||
messages?: Message[];
|
||||
error?: string;
|
||||
}>;
|
||||
send: (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
workingDirectory?: string,
|
||||
imagePaths?: string[]
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
getHistory: (sessionId: string) => Promise<{
|
||||
success: boolean;
|
||||
messages?: Message[];
|
||||
isRunning?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
stop: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
clear: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
onStream: (callback: (data: unknown) => void) => () => void;
|
||||
};
|
||||
sessions?: {
|
||||
list: (includeArchived?: boolean) => Promise<{
|
||||
success: boolean;
|
||||
sessions?: SessionListItem[];
|
||||
error?: string;
|
||||
}>;
|
||||
create: (
|
||||
name: string,
|
||||
projectPath: string,
|
||||
workingDirectory?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
session?: {
|
||||
id: string;
|
||||
name: string;
|
||||
projectPath: string;
|
||||
workingDirectory?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
update: (
|
||||
sessionId: string,
|
||||
name?: string,
|
||||
tags?: string[]
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
archive: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
unarchive: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
delete: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
// Note: Window interface is declared in @/types/electron.d.ts
|
||||
@@ -438,7 +492,7 @@ const STORAGE_KEYS = {
|
||||
// Mock file system using localStorage
|
||||
const mockFileSystem: Record<string, string> = {};
|
||||
|
||||
// Check if we're in Electron
|
||||
// Check if we're in Electron (for UI indicators only)
|
||||
export const isElectron = (): boolean => {
|
||||
return typeof window !== "undefined" && window.isElectron === true;
|
||||
};
|
||||
@@ -478,72 +532,50 @@ export const resetServerCheck = (): void => {
|
||||
// Cached HTTP client instance
|
||||
let httpClientInstance: ElectronAPI | null = null;
|
||||
|
||||
// Check if we're in simplified Electron mode (HTTP backend instead of IPC)
|
||||
const isSimplifiedElectronMode = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const api = window.electronAPI as any;
|
||||
// Simplified mode has isElectron flag and getServerUrl but NOT readFile
|
||||
return api?.isElectron === true &&
|
||||
typeof api?.getServerUrl === "function" &&
|
||||
typeof api?.readFile !== "function";
|
||||
};
|
||||
|
||||
// Get the Electron API or HTTP client for web mode
|
||||
// In simplified Electron mode and web mode, uses HTTP client
|
||||
/**
|
||||
* Get the HTTP API client
|
||||
*
|
||||
* All API calls go through HTTP to the backend server.
|
||||
* This is the only transport mode supported.
|
||||
*/
|
||||
export const getElectronAPI = (): ElectronAPI => {
|
||||
// Check if we're in simplified Electron mode (uses HTTP backend)
|
||||
if (isSimplifiedElectronMode()) {
|
||||
if (typeof window !== "undefined" && !httpClientInstance) {
|
||||
const { getHttpApiClient } = require("./http-api-client");
|
||||
httpClientInstance = getHttpApiClient();
|
||||
}
|
||||
return httpClientInstance!;
|
||||
if (typeof window === "undefined") {
|
||||
throw new Error("Cannot get API during SSR");
|
||||
}
|
||||
|
||||
// Full Electron API with IPC
|
||||
if (isElectron() && window.electronAPI) {
|
||||
return window.electronAPI;
|
||||
if (!httpClientInstance) {
|
||||
const { getHttpApiClient } = require("./http-api-client");
|
||||
httpClientInstance = getHttpApiClient();
|
||||
}
|
||||
|
||||
// Web mode: use HTTP API client
|
||||
if (typeof window !== "undefined") {
|
||||
if (!httpClientInstance) {
|
||||
const { getHttpApiClient } = require("./http-api-client");
|
||||
httpClientInstance = getHttpApiClient();
|
||||
}
|
||||
return httpClientInstance!;
|
||||
}
|
||||
|
||||
// SSR fallback - this shouldn't be called during actual operation
|
||||
throw new Error("Cannot get Electron API during SSR");
|
||||
return httpClientInstance!;
|
||||
};
|
||||
|
||||
// Async version that checks server availability first
|
||||
// Async version (same as sync since HTTP client is synchronously instantiated)
|
||||
export const getElectronAPIAsync = async (): Promise<ElectronAPI> => {
|
||||
// Simplified Electron mode or web mode: use HTTP client
|
||||
if (isSimplifiedElectronMode() || !isElectron()) {
|
||||
if (typeof window !== "undefined") {
|
||||
const { getHttpApiClient } = await import("./http-api-client");
|
||||
return getHttpApiClient();
|
||||
}
|
||||
}
|
||||
|
||||
// Full Electron API with IPC
|
||||
if (isElectron() && window.electronAPI) {
|
||||
return window.electronAPI;
|
||||
}
|
||||
|
||||
throw new Error("Cannot get Electron API during SSR");
|
||||
return getElectronAPI();
|
||||
};
|
||||
|
||||
// Check if backend is connected (for showing connection status in UI)
|
||||
export const isBackendConnected = async (): Promise<boolean> => {
|
||||
// Full Electron mode: backend is built-in
|
||||
if (isElectron() && !isSimplifiedElectronMode()) return true;
|
||||
// Simplified Electron or web mode: check server availability
|
||||
return await checkServerAvailable();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current API mode being used
|
||||
* Always returns "http" since that's the only mode now
|
||||
*/
|
||||
export const getCurrentApiMode = (): "http" => {
|
||||
return "http";
|
||||
};
|
||||
|
||||
// Debug helpers
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).__checkApiMode = () => {
|
||||
console.log("Current API mode:", getCurrentApiMode());
|
||||
console.log("isElectron():", isElectron());
|
||||
};
|
||||
}
|
||||
|
||||
// Mock API for development/fallback when no backend is available
|
||||
const getMockElectronAPI = (): ElectronAPI => {
|
||||
return {
|
||||
@@ -1962,7 +1994,9 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
|
||||
}
|
||||
|
||||
mockSpecRegenerationRunning = true;
|
||||
console.log(`[Mock] Generating features from existing spec for: ${projectPath}`);
|
||||
console.log(
|
||||
`[Mock] Generating features from existing spec for: ${projectPath}`
|
||||
);
|
||||
|
||||
// Simulate async feature generation
|
||||
simulateFeatureGeneration(projectPath);
|
||||
@@ -2149,7 +2183,8 @@ async function simulateFeatureGeneration(projectPath: string) {
|
||||
mockSpecRegenerationPhase = "initialization";
|
||||
emitSpecRegenerationEvent({
|
||||
type: "spec_regeneration_progress",
|
||||
content: "[Phase: initialization] Starting feature generation from existing app_spec.txt...\n",
|
||||
content:
|
||||
"[Phase: initialization] Starting feature generation from existing app_spec.txt...\n",
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
FeatureSuggestion,
|
||||
SuggestionType,
|
||||
} from "./electron";
|
||||
import type { Message, SessionListItem } from "@/types/electron";
|
||||
import type { Feature } from "@/store/app-store";
|
||||
import type {
|
||||
WorktreeAPI,
|
||||
@@ -31,46 +32,9 @@ import type {
|
||||
ProviderStatus,
|
||||
} from "@/types/electron";
|
||||
|
||||
// Check if we're in simplified Electron mode (Electron with HTTP backend)
|
||||
const isSimplifiedElectronMode = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const api = (window as any).electronAPI;
|
||||
// Simplified mode has isElectron flag but limited methods
|
||||
return api?.isElectron === true && typeof api?.getServerUrl === "function";
|
||||
};
|
||||
|
||||
// Check if native Electron dialogs are available
|
||||
const hasNativeDialogs = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const api = (window as any).electronAPI;
|
||||
return typeof api?.openDirectory === "function";
|
||||
};
|
||||
|
||||
// Server URL - configurable via environment variable or Electron
|
||||
const getServerUrl = async (): Promise<string> => {
|
||||
if (typeof window !== "undefined") {
|
||||
// In simplified Electron mode, get URL from main process
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.getServerUrl) {
|
||||
try {
|
||||
return await api.getServerUrl();
|
||||
} catch {
|
||||
// Fall through to defaults
|
||||
}
|
||||
}
|
||||
|
||||
// Check for environment variable
|
||||
const envUrl = process.env.NEXT_PUBLIC_SERVER_URL;
|
||||
if (envUrl) return envUrl;
|
||||
|
||||
// Default to localhost for development
|
||||
return "http://localhost:3008";
|
||||
}
|
||||
return "http://localhost:3008";
|
||||
};
|
||||
|
||||
// Synchronous version for constructor (uses default, then updates)
|
||||
const getServerUrlSync = (): string => {
|
||||
// Server URL - configurable via environment variable
|
||||
const getServerUrl = (): string => {
|
||||
if (typeof window !== "undefined") {
|
||||
const envUrl = process.env.NEXT_PUBLIC_SERVER_URL;
|
||||
if (envUrl) return envUrl;
|
||||
@@ -78,6 +42,7 @@ const getServerUrlSync = (): string => {
|
||||
return "http://localhost:3008";
|
||||
};
|
||||
|
||||
|
||||
// Get API key from environment variable
|
||||
const getApiKey = (): string | null => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -105,25 +70,10 @@ export class HttpApiClient implements ElectronAPI {
|
||||
private isConnecting = false;
|
||||
|
||||
constructor() {
|
||||
this.serverUrl = getServerUrlSync();
|
||||
// Update server URL asynchronously if in Electron
|
||||
this.initServerUrl();
|
||||
this.serverUrl = getServerUrl();
|
||||
this.connectWebSocket();
|
||||
}
|
||||
|
||||
private async initServerUrl(): Promise<void> {
|
||||
const url = await getServerUrl();
|
||||
if (url !== this.serverUrl) {
|
||||
this.serverUrl = url;
|
||||
// Reconnect WebSocket with new URL
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.connectWebSocket();
|
||||
}
|
||||
}
|
||||
|
||||
private connectWebSocket(): void {
|
||||
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
|
||||
return;
|
||||
@@ -222,6 +172,23 @@ export class HttpApiClient implements ElectronAPI {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async put<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: "PUT",
|
||||
headers: this.getHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async httpDelete<T>(endpoint: string): Promise<T> {
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: "DELETE",
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Basic operations
|
||||
async ping(): Promise<string> {
|
||||
const result = await this.get<{ status: string }>("/api/health");
|
||||
@@ -229,27 +196,13 @@ export class HttpApiClient implements ElectronAPI {
|
||||
}
|
||||
|
||||
async openExternalLink(url: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Use native Electron shell if available (better UX)
|
||||
if (hasNativeDialogs()) {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api.openExternalLink) {
|
||||
return api.openExternalLink(url);
|
||||
}
|
||||
}
|
||||
// Web mode: open in new tab
|
||||
// Open in new tab
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// File picker - uses native Electron dialogs when available, otherwise prompt
|
||||
// File picker - uses prompt for path input
|
||||
async openDirectory(): Promise<DialogResult> {
|
||||
// Use native Electron dialog if available
|
||||
if (hasNativeDialogs()) {
|
||||
const api = (window as any).electronAPI;
|
||||
return api.openDirectory();
|
||||
}
|
||||
|
||||
// Web mode: show a modal to let user type/paste path
|
||||
const path = prompt("Enter project directory path:");
|
||||
if (!path) {
|
||||
return { canceled: true, filePaths: [] };
|
||||
@@ -271,13 +224,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
}
|
||||
|
||||
async openFile(options?: object): Promise<DialogResult> {
|
||||
// Use native Electron dialog if available
|
||||
if (hasNativeDialogs()) {
|
||||
const api = (window as any).electronAPI;
|
||||
return api.openFile(options);
|
||||
}
|
||||
|
||||
// Web mode: prompt for file path
|
||||
// Prompt for file path
|
||||
const path = prompt("Enter file path:");
|
||||
if (!path) {
|
||||
return { canceled: true, filePaths: [] };
|
||||
@@ -651,6 +598,98 @@ export class HttpApiClient implements ElectronAPI {
|
||||
error?: string;
|
||||
}> => this.get("/api/running-agents"),
|
||||
};
|
||||
|
||||
// Workspace API
|
||||
workspace = {
|
||||
getConfig: (): Promise<{
|
||||
success: boolean;
|
||||
configured: boolean;
|
||||
workspaceDir?: string;
|
||||
error?: string;
|
||||
}> => this.get("/api/workspace/config"),
|
||||
|
||||
getDirectories: (): Promise<{
|
||||
success: boolean;
|
||||
directories?: Array<{ name: string; path: string }>;
|
||||
error?: string;
|
||||
}> => this.get("/api/workspace/directories"),
|
||||
};
|
||||
|
||||
// Agent API
|
||||
agent = {
|
||||
start: (sessionId: string, workingDirectory?: string): Promise<{
|
||||
success: boolean;
|
||||
messages?: Message[];
|
||||
error?: string;
|
||||
}> => this.post("/api/agent/start", { sessionId, workingDirectory }),
|
||||
|
||||
send: (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
workingDirectory?: string,
|
||||
imagePaths?: string[]
|
||||
): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post("/api/agent/send", { sessionId, message, workingDirectory, imagePaths }),
|
||||
|
||||
getHistory: (sessionId: string): Promise<{
|
||||
success: boolean;
|
||||
messages?: Message[];
|
||||
isRunning?: boolean;
|
||||
error?: string;
|
||||
}> => this.post("/api/agent/history", { sessionId }),
|
||||
|
||||
stop: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post("/api/agent/stop", { sessionId }),
|
||||
|
||||
clear: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post("/api/agent/clear", { sessionId }),
|
||||
|
||||
onStream: (callback: (data: unknown) => void): (() => void) => {
|
||||
return this.subscribeToEvent("agent:stream", callback as EventCallback);
|
||||
},
|
||||
};
|
||||
|
||||
// Sessions API
|
||||
sessions = {
|
||||
list: (includeArchived?: boolean): Promise<{
|
||||
success: boolean;
|
||||
sessions?: SessionListItem[];
|
||||
error?: string;
|
||||
}> => this.get(`/api/sessions?includeArchived=${includeArchived || false}`),
|
||||
|
||||
create: (
|
||||
name: string,
|
||||
projectPath: string,
|
||||
workingDirectory?: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
session?: {
|
||||
id: string;
|
||||
name: string;
|
||||
projectPath: string;
|
||||
workingDirectory?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.post("/api/sessions", { name, projectPath, workingDirectory }),
|
||||
|
||||
update: (
|
||||
sessionId: string,
|
||||
name?: string,
|
||||
tags?: string[]
|
||||
): Promise<{ success: boolean; error?: string }> =>
|
||||
this.put(`/api/sessions/${sessionId}`, { name, tags }),
|
||||
|
||||
archive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post(`/api/sessions/${sessionId}/archive`, {}),
|
||||
|
||||
unarchive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post(`/api/sessions/${sessionId}/unarchive`, {}),
|
||||
|
||||
delete: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||
this.httpDelete(`/api/sessions/${sessionId}`),
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
1
apps/app/src/types/electron.d.ts
vendored
1
apps/app/src/types/electron.d.ts
vendored
@@ -192,6 +192,7 @@ export type AutoModeEvent =
|
||||
| {
|
||||
type: "auto_mode_error";
|
||||
error: string;
|
||||
errorType?: "authentication" | "execution";
|
||||
featureId?: string;
|
||||
projectId?: string;
|
||||
projectPath?: string;
|
||||
|
||||
Reference in New Issue
Block a user