mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
refactor: move from next js to vite and tanstack router
This commit is contained in:
391
apps/ui/src/hooks/use-auto-mode.ts
Normal file
391
apps/ui/src/hooks/use-auto-mode.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { useEffect, useCallback, useMemo } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import type { AutoModeEvent } from "@/types/electron";
|
||||
|
||||
/**
|
||||
* Hook for managing auto mode (scoped per project)
|
||||
*/
|
||||
export function useAutoMode() {
|
||||
const {
|
||||
autoModeByProject,
|
||||
setAutoModeRunning,
|
||||
addRunningTask,
|
||||
removeRunningTask,
|
||||
clearRunningTasks,
|
||||
currentProject,
|
||||
addAutoModeActivity,
|
||||
maxConcurrency,
|
||||
projects,
|
||||
} = useAppStore(
|
||||
useShallow((state) => ({
|
||||
autoModeByProject: state.autoModeByProject,
|
||||
setAutoModeRunning: state.setAutoModeRunning,
|
||||
addRunningTask: state.addRunningTask,
|
||||
removeRunningTask: state.removeRunningTask,
|
||||
clearRunningTasks: state.clearRunningTasks,
|
||||
currentProject: state.currentProject,
|
||||
addAutoModeActivity: state.addAutoModeActivity,
|
||||
maxConcurrency: state.maxConcurrency,
|
||||
projects: state.projects,
|
||||
}))
|
||||
);
|
||||
|
||||
// Helper to look up project ID from path
|
||||
const getProjectIdFromPath = useCallback(
|
||||
(path: string): string | undefined => {
|
||||
const project = projects.find((p) => p.path === path);
|
||||
return project?.id;
|
||||
},
|
||||
[projects]
|
||||
);
|
||||
|
||||
// Get project-specific auto mode state
|
||||
const projectId = currentProject?.id;
|
||||
const projectAutoModeState = useMemo(() => {
|
||||
if (!projectId) return { isRunning: false, runningTasks: [] };
|
||||
return (
|
||||
autoModeByProject[projectId] || { isRunning: false, runningTasks: [] }
|
||||
);
|
||||
}, [autoModeByProject, projectId]);
|
||||
|
||||
const isAutoModeRunning = projectAutoModeState.isRunning;
|
||||
const runningAutoTasks = projectAutoModeState.runningTasks;
|
||||
|
||||
// Check if we can start a new task based on concurrency limit
|
||||
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
|
||||
|
||||
// Handle auto mode events - listen globally for all projects
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
|
||||
console.log("[AutoMode Event]", event);
|
||||
|
||||
// Events include projectPath from backend - use it to look up project ID
|
||||
// Fall back to current projectId if not provided in event
|
||||
let eventProjectId: string | undefined;
|
||||
if ("projectPath" in event && event.projectPath) {
|
||||
eventProjectId = getProjectIdFromPath(event.projectPath);
|
||||
}
|
||||
if (!eventProjectId && "projectId" in event && event.projectId) {
|
||||
eventProjectId = event.projectId;
|
||||
}
|
||||
if (!eventProjectId) {
|
||||
eventProjectId = projectId;
|
||||
}
|
||||
|
||||
// Skip event if we couldn't determine the project
|
||||
if (!eventProjectId) {
|
||||
console.warn(
|
||||
"[AutoMode] Could not determine project for event:",
|
||||
event
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case "auto_mode_feature_start":
|
||||
if (event.featureId) {
|
||||
addRunningTask(eventProjectId, event.featureId);
|
||||
addAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: "start",
|
||||
message: `Started working on feature`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "auto_mode_feature_complete":
|
||||
// Feature completed - remove from running tasks and UI will reload features on its own
|
||||
if (event.featureId) {
|
||||
console.log(
|
||||
"[AutoMode] Feature completed:",
|
||||
event.featureId,
|
||||
"passes:",
|
||||
event.passes
|
||||
);
|
||||
removeRunningTask(eventProjectId, event.featureId);
|
||||
addAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: "complete",
|
||||
message: event.passes
|
||||
? "Feature completed successfully"
|
||||
: "Feature completed with failures",
|
||||
passes: event.passes,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "auto_mode_stopped":
|
||||
// Auto mode was explicitly stopped (by user or error)
|
||||
setAutoModeRunning(eventProjectId, false);
|
||||
clearRunningTasks(eventProjectId);
|
||||
console.log("[AutoMode] Auto mode stopped");
|
||||
break;
|
||||
|
||||
case "auto_mode_started":
|
||||
// Auto mode started - ensure UI reflects running state
|
||||
console.log("[AutoMode] Auto mode started:", event.message);
|
||||
break;
|
||||
|
||||
case "auto_mode_idle":
|
||||
// Auto mode is running but has no pending features to pick up
|
||||
// This is NOT a stop - auto mode keeps running and will pick up new features
|
||||
console.log("[AutoMode] Auto mode idle - waiting for new features");
|
||||
break;
|
||||
|
||||
case "auto_mode_complete":
|
||||
// Legacy event - only handle if it looks like a stop (for backwards compatibility)
|
||||
if (event.message === "Auto mode stopped") {
|
||||
setAutoModeRunning(eventProjectId, false);
|
||||
clearRunningTasks(eventProjectId);
|
||||
console.log("[AutoMode] Auto mode stopped (legacy event)");
|
||||
}
|
||||
break;
|
||||
|
||||
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: errorMessage,
|
||||
errorType: isAuthError ? "authentication" : "execution",
|
||||
});
|
||||
|
||||
// Remove the task from running since it failed
|
||||
if (eventProjectId) {
|
||||
removeRunningTask(eventProjectId, event.featureId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "auto_mode_progress":
|
||||
// Log progress updates (throttle to avoid spam)
|
||||
if (event.featureId && event.content && event.content.length > 10) {
|
||||
addAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: "progress",
|
||||
message: event.content.substring(0, 200), // Limit message length
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "auto_mode_tool":
|
||||
// Log tool usage
|
||||
if (event.featureId && event.tool) {
|
||||
addAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: "tool",
|
||||
message: `Using tool: ${event.tool}`,
|
||||
tool: event.tool,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "auto_mode_phase":
|
||||
// Log phase transitions (Planning, Action, Verification)
|
||||
if (event.featureId && event.phase && event.message) {
|
||||
console.log(
|
||||
`[AutoMode] Phase: ${event.phase} for ${event.featureId}`
|
||||
);
|
||||
addAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: event.phase,
|
||||
message: event.message,
|
||||
phase: event.phase,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [
|
||||
projectId,
|
||||
addRunningTask,
|
||||
removeRunningTask,
|
||||
clearRunningTasks,
|
||||
setAutoModeRunning,
|
||||
addAutoModeActivity,
|
||||
getProjectIdFromPath,
|
||||
]);
|
||||
|
||||
// Restore auto mode for all projects that were running when app was closed
|
||||
// This runs once on mount to restart auto loops for persisted running states
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
// Find all projects that have auto mode marked as running
|
||||
const projectsToRestart: Array<{ projectId: string; projectPath: string }> =
|
||||
[];
|
||||
for (const [projectId, state] of Object.entries(autoModeByProject)) {
|
||||
if (state.isRunning) {
|
||||
// Find the project path for this project ID
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
if (project) {
|
||||
projectsToRestart.push({ projectId, projectPath: project.path });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restart auto mode for each project
|
||||
for (const { projectId, projectPath } of projectsToRestart) {
|
||||
console.log(`[AutoMode] Restoring auto mode for project: ${projectPath}`);
|
||||
api.autoMode
|
||||
.start(projectPath, maxConcurrency)
|
||||
.then((result) => {
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
`[AutoMode] Failed to restore auto mode for ${projectPath}:`,
|
||||
result.error
|
||||
);
|
||||
// Mark as not running if we couldn't restart
|
||||
setAutoModeRunning(projectId, false);
|
||||
} else {
|
||||
console.log(`[AutoMode] Restored auto mode for ${projectPath}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`[AutoMode] Error restoring auto mode for ${projectPath}:`,
|
||||
error
|
||||
);
|
||||
setAutoModeRunning(projectId, false);
|
||||
});
|
||||
}
|
||||
// Only run once on mount - intentionally empty dependency array
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Start auto mode
|
||||
const start = useCallback(async () => {
|
||||
if (!currentProject) {
|
||||
console.error("No project selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) {
|
||||
throw new Error("Auto mode API not available");
|
||||
}
|
||||
|
||||
const result = await api.autoMode.start(
|
||||
currentProject.path,
|
||||
maxConcurrency
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setAutoModeRunning(currentProject.id, true);
|
||||
console.log(
|
||||
`[AutoMode] Started successfully with maxConcurrency: ${maxConcurrency}`
|
||||
);
|
||||
} else {
|
||||
console.error("[AutoMode] Failed to start:", result.error);
|
||||
throw new Error(result.error || "Failed to start auto mode");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[AutoMode] Error starting:", error);
|
||||
if (currentProject) {
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, [currentProject, setAutoModeRunning, maxConcurrency]);
|
||||
|
||||
// Stop auto mode - only turns off the toggle, running tasks continue
|
||||
const stop = useCallback(async () => {
|
||||
if (!currentProject) {
|
||||
console.error("No project selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) {
|
||||
throw new Error("Auto mode API not available");
|
||||
}
|
||||
|
||||
const result = await api.autoMode.stop(currentProject.path);
|
||||
|
||||
if (result.success) {
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
// NOTE: We intentionally do NOT clear running tasks here.
|
||||
// Stopping auto mode only turns off the toggle to prevent new features
|
||||
// from being picked up. Running tasks will complete naturally and be
|
||||
// removed via the auto_mode_feature_complete event.
|
||||
console.log(
|
||||
"[AutoMode] Stopped successfully - running tasks will continue"
|
||||
);
|
||||
} else {
|
||||
console.error("[AutoMode] Failed to stop:", result.error);
|
||||
throw new Error(result.error || "Failed to stop auto mode");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[AutoMode] Error stopping:", error);
|
||||
throw error;
|
||||
}
|
||||
}, [currentProject, setAutoModeRunning]);
|
||||
|
||||
// Stop a specific feature
|
||||
const stopFeature = useCallback(
|
||||
async (featureId: string) => {
|
||||
if (!currentProject) {
|
||||
console.error("No project selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.stopFeature) {
|
||||
throw new Error("Stop feature API not available");
|
||||
}
|
||||
|
||||
const result = await api.autoMode.stopFeature(featureId);
|
||||
|
||||
if (result.success) {
|
||||
removeRunningTask(currentProject.id, featureId);
|
||||
console.log("[AutoMode] Feature stopped successfully:", featureId);
|
||||
addAutoModeActivity({
|
||||
featureId,
|
||||
type: "complete",
|
||||
message: "Feature stopped by user",
|
||||
passes: false,
|
||||
});
|
||||
} else {
|
||||
console.error("[AutoMode] Failed to stop feature:", result.error);
|
||||
throw new Error(result.error || "Failed to stop feature");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[AutoMode] Error stopping feature:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[currentProject, removeRunningTask, addAutoModeActivity]
|
||||
);
|
||||
|
||||
return {
|
||||
isRunning: isAutoModeRunning,
|
||||
runningTasks: runningAutoTasks,
|
||||
maxConcurrency,
|
||||
canStartNewTask,
|
||||
start,
|
||||
stop,
|
||||
stopFeature,
|
||||
};
|
||||
}
|
||||
428
apps/ui/src/hooks/use-electron-agent.ts
Normal file
428
apps/ui/src/hooks/use-electron-agent.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
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;
|
||||
workingDirectory?: string;
|
||||
model?: string;
|
||||
onToolUse?: (toolName: string, toolInput: unknown) => void;
|
||||
}
|
||||
|
||||
interface UseElectronAgentResult {
|
||||
messages: Message[];
|
||||
isProcessing: boolean;
|
||||
isConnected: boolean;
|
||||
sendMessage: (content: string, images?: ImageAttachment[]) => Promise<void>;
|
||||
stopExecution: () => Promise<void>;
|
||||
clearHistory: () => Promise<void>;
|
||||
error: string | null;
|
||||
// Queue-related state
|
||||
queuedMessages: { id: string; content: string; images?: ImageAttachment[]; timestamp: Date }[];
|
||||
isQueueProcessing: boolean;
|
||||
clearMessageQueue: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for interacting with the Electron-based Claude agent
|
||||
*
|
||||
* This hook provides a clean interface to the agent running in the Electron
|
||||
* main process, which survives Next.js restarts.
|
||||
*/
|
||||
export function useElectronAgent({
|
||||
sessionId,
|
||||
workingDirectory,
|
||||
model,
|
||||
onToolUse,
|
||||
}: UseElectronAgentOptions): UseElectronAgentResult {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||||
const currentMessageRef = useRef<Message | null>(null);
|
||||
|
||||
// Send message directly to the agent (bypassing queue)
|
||||
const sendMessageDirectly = useCallback(
|
||||
async (content: string, images?: ImageAttachment[]) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) {
|
||||
setError("API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isProcessing) {
|
||||
throw new Error("Agent is already processing a message");
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
console.log("[useElectronAgent] Sending message directly", {
|
||||
hasImages: images && images.length > 0,
|
||||
imageCount: images?.length || 0
|
||||
});
|
||||
|
||||
// Save images to .automaker/images and get paths
|
||||
let imagePaths: string[] | undefined;
|
||||
if (images && images.length > 0 && api.saveImageToTemp) {
|
||||
imagePaths = [];
|
||||
for (const image of images) {
|
||||
const result = await api.saveImageToTemp(
|
||||
image.data,
|
||||
image.filename,
|
||||
image.mimeType,
|
||||
workingDirectory // Pass workingDirectory as projectPath
|
||||
);
|
||||
if (result.success && result.path) {
|
||||
imagePaths.push(result.path);
|
||||
console.log("[useElectronAgent] Saved image to .automaker/images:", result.path);
|
||||
} else {
|
||||
console.error("[useElectronAgent] Failed to save image:", result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await api.agent!.send(
|
||||
sessionId,
|
||||
content,
|
||||
workingDirectory,
|
||||
imagePaths,
|
||||
model
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || "Failed to send message");
|
||||
setIsProcessing(false);
|
||||
}
|
||||
// Note: We don't set isProcessing to false here because
|
||||
// it will be set by the "complete" or "error" stream event
|
||||
} catch (err) {
|
||||
console.error("[useElectronAgent] Failed to send message:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to send message");
|
||||
setIsProcessing(false);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[sessionId, workingDirectory, model, isProcessing]
|
||||
);
|
||||
|
||||
// Message queue for queuing messages when agent is busy
|
||||
const {
|
||||
queuedMessages,
|
||||
isProcessingQueue,
|
||||
addToQueue,
|
||||
clearQueue,
|
||||
processNext,
|
||||
} = useMessageQueue({
|
||||
onProcessNext: async (queuedMessage) => {
|
||||
await sendMessageDirectly(queuedMessage.content, queuedMessage.images);
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize connection and load history
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) {
|
||||
setError("API not available.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
// No session selected - reset state
|
||||
setMessages([]);
|
||||
setIsConnected(false);
|
||||
setIsProcessing(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
|
||||
const initialize = async () => {
|
||||
// Reset error state when switching sessions
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
console.log("[useElectronAgent] Starting session:", sessionId);
|
||||
const result = await api.agent!.start(
|
||||
sessionId,
|
||||
workingDirectory
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (result.success && result.messages) {
|
||||
console.log("[useElectronAgent] Loaded", result.messages.length, "messages");
|
||||
setMessages(result.messages);
|
||||
setIsConnected(true);
|
||||
|
||||
// Check if the agent is currently running for this session
|
||||
const historyResult = await api.agent!.getHistory(sessionId);
|
||||
if (mounted && historyResult.success) {
|
||||
const isRunning = historyResult.isRunning || false;
|
||||
console.log("[useElectronAgent] Session running state:", isRunning);
|
||||
setIsProcessing(isRunning);
|
||||
}
|
||||
} else {
|
||||
setError(result.error || "Failed to start session");
|
||||
setIsProcessing(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
console.error("[useElectronAgent] Failed to initialize:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to initialize");
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
initialize();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [sessionId, workingDirectory]);
|
||||
|
||||
// Auto-process queue when agent finishes processing
|
||||
useEffect(() => {
|
||||
if (!isProcessing && !isProcessingQueue && queuedMessages.length > 0) {
|
||||
console.log("[useElectronAgent] Auto-processing next queued message");
|
||||
processNext();
|
||||
}
|
||||
}, [isProcessing, isProcessingQueue, queuedMessages.length, processNext]);
|
||||
|
||||
// Subscribe to streaming events
|
||||
useEffect(() => {
|
||||
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);
|
||||
|
||||
const handleStream = (event: StreamEvent) => {
|
||||
// CRITICAL: Only process events for our specific session
|
||||
if (event.sessionId !== sessionId) {
|
||||
console.log("[useElectronAgent] Ignoring event for different session:", event.sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[useElectronAgent] Stream event for", sessionId, ":", event.type);
|
||||
|
||||
switch (event.type) {
|
||||
case "message":
|
||||
// User message added
|
||||
setMessages((prev) => [...prev, event.message]);
|
||||
break;
|
||||
|
||||
case "stream":
|
||||
// Assistant message streaming
|
||||
if (event.isComplete) {
|
||||
// Final update
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === event.messageId
|
||||
? { ...msg, content: event.content }
|
||||
: msg
|
||||
)
|
||||
);
|
||||
currentMessageRef.current = null;
|
||||
} else {
|
||||
// Streaming update
|
||||
setMessages((prev) => {
|
||||
const existingIndex = prev.findIndex((m) => m.id === event.messageId);
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing message
|
||||
return prev.map((msg) =>
|
||||
msg.id === event.messageId
|
||||
? { ...msg, content: event.content }
|
||||
: msg
|
||||
);
|
||||
} else {
|
||||
// Create new message
|
||||
const newMessage: Message = {
|
||||
id: event.messageId,
|
||||
role: "assistant",
|
||||
content: event.content,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
currentMessageRef.current = newMessage;
|
||||
return [...prev, newMessage];
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "tool_use":
|
||||
// Tool being used
|
||||
console.log("[useElectronAgent] Tool use:", event.tool.name);
|
||||
onToolUse?.(event.tool.name, event.tool.input);
|
||||
break;
|
||||
|
||||
case "complete":
|
||||
// Agent finished processing for THIS session
|
||||
console.log("[useElectronAgent] Processing complete for session:", sessionId);
|
||||
setIsProcessing(false);
|
||||
if (event.messageId) {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === event.messageId
|
||||
? { ...msg, content: event.content }
|
||||
: msg
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "error":
|
||||
// Error occurred for THIS session
|
||||
console.error("[useElectronAgent] Agent error for session:", sessionId, event.error);
|
||||
setIsProcessing(false);
|
||||
setError(event.error);
|
||||
if (event.message) {
|
||||
const errorMessage = event.message;
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
unsubscribeRef.current = api.agent!.onStream(handleStream as (data: unknown) => void);
|
||||
|
||||
return () => {
|
||||
if (unsubscribeRef.current) {
|
||||
console.log("[useElectronAgent] Unsubscribing from stream events for session:", sessionId);
|
||||
unsubscribeRef.current();
|
||||
unsubscribeRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [sessionId, onToolUse]);
|
||||
|
||||
// Send a message to the agent
|
||||
const sendMessage = useCallback(
|
||||
async (content: string, images?: ImageAttachment[]) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) {
|
||||
setError("API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isProcessing) {
|
||||
console.warn("[useElectronAgent] Already processing a message");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
console.log("[useElectronAgent] Sending message", {
|
||||
hasImages: images && images.length > 0,
|
||||
imageCount: images?.length || 0
|
||||
});
|
||||
|
||||
// Save images to .automaker/images and get paths
|
||||
let imagePaths: string[] | undefined;
|
||||
if (images && images.length > 0 && api.saveImageToTemp) {
|
||||
imagePaths = [];
|
||||
for (const image of images) {
|
||||
const result = await api.saveImageToTemp(
|
||||
image.data,
|
||||
image.filename,
|
||||
image.mimeType,
|
||||
workingDirectory // Pass workingDirectory as projectPath
|
||||
);
|
||||
if (result.success && result.path) {
|
||||
imagePaths.push(result.path);
|
||||
console.log("[useElectronAgent] Saved image to .automaker/images:", result.path);
|
||||
} else {
|
||||
console.error("[useElectronAgent] Failed to save image:", result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await api.agent!.send(
|
||||
sessionId,
|
||||
content,
|
||||
workingDirectory,
|
||||
imagePaths,
|
||||
model
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || "Failed to send message");
|
||||
setIsProcessing(false);
|
||||
}
|
||||
// Note: We don't set isProcessing to false here because
|
||||
// it will be set by the "complete" or "error" stream event
|
||||
} catch (err) {
|
||||
console.error("[useElectronAgent] Failed to send message:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to send message");
|
||||
setIsProcessing(false);
|
||||
}
|
||||
},
|
||||
[sessionId, workingDirectory, model, isProcessing]
|
||||
);
|
||||
|
||||
// Stop current execution
|
||||
const stopExecution = useCallback(async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) {
|
||||
setError("API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[useElectronAgent] Stopping execution");
|
||||
const result = await api.agent!.stop(sessionId);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || "Failed to stop execution");
|
||||
} else {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useElectronAgent] Failed to stop:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to stop execution");
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
// Clear conversation history
|
||||
const clearHistory = useCallback(async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) {
|
||||
setError("API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[useElectronAgent] Clearing history");
|
||||
const result = await api.agent!.clear(sessionId);
|
||||
|
||||
if (result.success) {
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(result.error || "Failed to clear history");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useElectronAgent] Failed to clear:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to clear history");
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isProcessing,
|
||||
isConnected,
|
||||
sendMessage,
|
||||
stopExecution,
|
||||
clearHistory,
|
||||
error,
|
||||
queuedMessages,
|
||||
isQueueProcessing: isProcessingQueue,
|
||||
clearMessageQueue: clearQueue,
|
||||
};
|
||||
}
|
||||
155
apps/ui/src/hooks/use-keyboard-shortcuts.ts
Normal file
155
apps/ui/src/hooks/use-keyboard-shortcuts.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { useAppStore, parseShortcut } from "@/store/app-store";
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
key: string; // Can be simple "K" or with modifiers "Shift+N", "Cmd+K"
|
||||
action: () => void;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the currently focused element is an input, textarea, or contenteditable element
|
||||
* or if an autocomplete/typeahead dropdown is open
|
||||
*/
|
||||
function isInputFocused(): boolean {
|
||||
const activeElement = document.activeElement;
|
||||
if (!activeElement) return false;
|
||||
|
||||
// Check if it's a form input element
|
||||
const tagName = activeElement.tagName.toLowerCase();
|
||||
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a contenteditable element
|
||||
if (activeElement.getAttribute("contenteditable") === "true") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it has a role of textbox or searchbox
|
||||
const role = activeElement.getAttribute("role");
|
||||
if (role === "textbox" || role === "searchbox" || role === "combobox") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if focus is inside an xterm terminal (they use a hidden textarea)
|
||||
const xtermContainer = activeElement.closest(".xterm");
|
||||
if (xtermContainer) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check if any parent has data-terminal-container attribute
|
||||
const terminalContainer = activeElement.closest("[data-terminal-container]");
|
||||
if (terminalContainer) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for autocomplete/typeahead dropdowns being open
|
||||
const autocompleteList = document.querySelector(
|
||||
'[data-testid="category-autocomplete-list"]'
|
||||
);
|
||||
if (autocompleteList) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for any open dialogs
|
||||
const dialog = document.querySelector('[role="dialog"][data-state="open"]');
|
||||
if (dialog) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for project picker dropdown being open
|
||||
const projectPickerDropdown = document.querySelector(
|
||||
'[data-testid="project-picker-dropdown"]'
|
||||
);
|
||||
if (projectPickerDropdown) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for any open dropdown menus (Radix UI uses role="menu")
|
||||
// This prevents shortcuts from firing when user is typing in dropdown filters
|
||||
const dropdownMenu = document.querySelector('[role="menu"]');
|
||||
if (dropdownMenu) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a keyboard event matches a shortcut definition
|
||||
*/
|
||||
function matchesShortcut(event: KeyboardEvent, shortcutStr: string): boolean {
|
||||
const shortcut = parseShortcut(shortcutStr);
|
||||
|
||||
// Check if the key matches (case-insensitive)
|
||||
if (event.key.toLowerCase() !== shortcut.key.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check modifier keys
|
||||
const cmdCtrlPressed = event.metaKey || event.ctrlKey;
|
||||
const shiftPressed = event.shiftKey;
|
||||
const altPressed = event.altKey;
|
||||
|
||||
// If shortcut requires cmdCtrl, it must be pressed
|
||||
if (shortcut.cmdCtrl && !cmdCtrlPressed) return false;
|
||||
// If shortcut doesn't require cmdCtrl, it shouldn't be pressed
|
||||
if (!shortcut.cmdCtrl && cmdCtrlPressed) return false;
|
||||
|
||||
// If shortcut requires shift, it must be pressed
|
||||
if (shortcut.shift && !shiftPressed) return false;
|
||||
// If shortcut doesn't require shift, it shouldn't be pressed
|
||||
if (!shortcut.shift && shiftPressed) return false;
|
||||
|
||||
// If shortcut requires alt, it must be pressed
|
||||
if (shortcut.alt && !altPressed) return false;
|
||||
// If shortcut doesn't require alt, it shouldn't be pressed
|
||||
if (!shortcut.alt && altPressed) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage keyboard shortcuts
|
||||
* Shortcuts won't fire when user is typing in inputs, textareas, or when dialogs are open
|
||||
* Supports modifier keys: Shift, Cmd/Ctrl, Alt/Option
|
||||
*/
|
||||
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// Don't trigger shortcuts when typing in inputs
|
||||
if (isInputFocused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find matching shortcut
|
||||
const matchingShortcut = shortcuts.find(
|
||||
(shortcut) => matchesShortcut(event, shortcut.key)
|
||||
);
|
||||
|
||||
if (matchingShortcut) {
|
||||
event.preventDefault();
|
||||
matchingShortcut.action();
|
||||
}
|
||||
},
|
||||
[shortcuts]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get current keyboard shortcuts from store
|
||||
* This replaces the static constants and allows customization
|
||||
*/
|
||||
export function useKeyboardShortcutsConfig() {
|
||||
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
|
||||
return keyboardShortcuts;
|
||||
}
|
||||
81
apps/ui/src/hooks/use-message-queue.ts
Normal file
81
apps/ui/src/hooks/use-message-queue.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { ImageAttachment } from '@/store/app-store';
|
||||
|
||||
export interface QueuedMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
images?: ImageAttachment[];
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
interface UseMessageQueueOptions {
|
||||
onProcessNext: (message: QueuedMessage) => Promise<void>;
|
||||
}
|
||||
|
||||
interface UseMessageQueueResult {
|
||||
queuedMessages: QueuedMessage[];
|
||||
isProcessingQueue: boolean;
|
||||
addToQueue: (content: string, images?: ImageAttachment[]) => void;
|
||||
clearQueue: () => void;
|
||||
removeFromQueue: (messageId: string) => void;
|
||||
processNext: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for managing a queue of messages to be sent to the agent
|
||||
*
|
||||
* This allows users to queue up multiple messages while one is being processed,
|
||||
* improving the chat experience by removing blocking behavior.
|
||||
*/
|
||||
export function useMessageQueue({ onProcessNext }: UseMessageQueueOptions): UseMessageQueueResult {
|
||||
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
|
||||
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
|
||||
|
||||
const addToQueue = useCallback((content: string, images?: ImageAttachment[]) => {
|
||||
const queuedMessage: QueuedMessage = {
|
||||
id: `queued-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
content: content.trim(),
|
||||
images,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setQueuedMessages(prev => [...prev, queuedMessage]);
|
||||
}, []);
|
||||
|
||||
const removeFromQueue = useCallback((messageId: string) => {
|
||||
setQueuedMessages(prev => prev.filter(msg => msg.id !== messageId));
|
||||
}, []);
|
||||
|
||||
const clearQueue = useCallback(() => {
|
||||
setQueuedMessages([]);
|
||||
}, []);
|
||||
|
||||
const processNext = useCallback(async () => {
|
||||
if (queuedMessages.length === 0 || isProcessingQueue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextMessage = queuedMessages[0];
|
||||
setIsProcessingQueue(true);
|
||||
|
||||
try {
|
||||
await onProcessNext(nextMessage);
|
||||
// Remove the processed message from queue
|
||||
setQueuedMessages(prev => prev.slice(1));
|
||||
} catch (error) {
|
||||
console.error('Error processing queued message:', error);
|
||||
// Keep the message in queue for retry or manual removal
|
||||
} finally {
|
||||
setIsProcessingQueue(false);
|
||||
}
|
||||
}, [queuedMessages, isProcessingQueue, onProcessNext]);
|
||||
|
||||
return {
|
||||
queuedMessages,
|
||||
isProcessingQueue,
|
||||
addToQueue,
|
||||
clearQueue,
|
||||
removeFromQueue,
|
||||
processNext,
|
||||
};
|
||||
}
|
||||
104
apps/ui/src/hooks/use-scroll-tracking.ts
Normal file
104
apps/ui/src/hooks/use-scroll-tracking.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface ScrollTrackingItem {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface UseScrollTrackingOptions<T extends ScrollTrackingItem> {
|
||||
/** Navigation items with at least an id property */
|
||||
items: T[];
|
||||
/** Optional filter function to determine which items should be tracked */
|
||||
filterFn?: (item: T) => boolean;
|
||||
/** Optional initial active section (defaults to first item's id) */
|
||||
initialSection?: string;
|
||||
/** Optional offset from top when scrolling to section (defaults to 24) */
|
||||
scrollOffset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic custom hook for managing scroll-based navigation tracking
|
||||
* Automatically highlights the active section based on scroll position
|
||||
* and provides smooth scrolling to sections
|
||||
*/
|
||||
export function useScrollTracking<T extends ScrollTrackingItem>({
|
||||
items,
|
||||
filterFn = () => true,
|
||||
initialSection,
|
||||
scrollOffset = 24,
|
||||
}: UseScrollTrackingOptions<T>) {
|
||||
const [activeSection, setActiveSection] = useState(
|
||||
initialSection || items[0]?.id || ""
|
||||
);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Track scroll position to highlight active nav item
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const sections = items
|
||||
.filter(filterFn)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
element: document.getElementById(item.id),
|
||||
}))
|
||||
.filter((s) => s.element);
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const scrollTop = container.scrollTop;
|
||||
const scrollHeight = container.scrollHeight;
|
||||
const clientHeight = container.clientHeight;
|
||||
|
||||
// Check if scrolled to bottom (within a small threshold)
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50;
|
||||
|
||||
if (isAtBottom && sections.length > 0) {
|
||||
// If at bottom, highlight the last visible section
|
||||
setActiveSection(sections[sections.length - 1].id);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = sections.length - 1; i >= 0; i--) {
|
||||
const section = sections[i];
|
||||
if (section.element) {
|
||||
const rect = section.element.getBoundingClientRect();
|
||||
const relativeTop = rect.top - containerRect.top + scrollTop;
|
||||
if (scrollTop >= relativeTop - 100) {
|
||||
setActiveSection(section.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleScroll);
|
||||
return () => container.removeEventListener("scroll", handleScroll);
|
||||
}, [items, filterFn]);
|
||||
|
||||
// Scroll to a specific section with smooth animation
|
||||
const scrollToSection = useCallback(
|
||||
(sectionId: string) => {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element && scrollContainerRef.current) {
|
||||
const container = scrollContainerRef.current;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const relativeTop =
|
||||
elementRect.top - containerRect.top + container.scrollTop;
|
||||
|
||||
container.scrollTo({
|
||||
top: relativeTop - scrollOffset,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
},
|
||||
[scrollOffset]
|
||||
);
|
||||
|
||||
return {
|
||||
activeSection,
|
||||
scrollToSection,
|
||||
scrollContainerRef,
|
||||
};
|
||||
}
|
||||
56
apps/ui/src/hooks/use-window-state.ts
Normal file
56
apps/ui/src/hooks/use-window-state.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export interface WindowState {
|
||||
isMaximized: boolean;
|
||||
windowWidth: number;
|
||||
windowHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to track window state (dimensions and maximized status)
|
||||
* For Electron apps, considers window maximized if width > 1400px
|
||||
* Also listens for window resize events to update state
|
||||
*/
|
||||
export function useWindowState(): WindowState {
|
||||
const [windowState, setWindowState] = useState<WindowState>(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return { isMaximized: false, windowWidth: 0, windowHeight: 0 };
|
||||
}
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
return {
|
||||
isMaximized: width > 1400,
|
||||
windowWidth: width,
|
||||
windowHeight: height,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const updateWindowState = () => {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
setWindowState({
|
||||
isMaximized: width > 1400,
|
||||
windowWidth: width,
|
||||
windowHeight: height,
|
||||
});
|
||||
};
|
||||
|
||||
// Set initial state
|
||||
updateWindowState();
|
||||
|
||||
// Listen for resize events
|
||||
window.addEventListener("resize", updateWindowState);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateWindowState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return windowState;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user