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:
Cody Seibert
2025-12-12 02:14:52 -05:00
parent 4b9bd2641f
commit 8e65f0b338
24 changed files with 1217 additions and 2390 deletions

View File

@@ -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) {

View 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>
</>
);
}

View File

@@ -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 */}

View File

@@ -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",
});
}
}
});

View File

@@ -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

View 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>
);
}