Merge main into kanban-scaling

Resolves merge conflicts while preserving:
- Kanban scaling improvements (window sizing, bounce prevention, debouncing)
- Main's sidebar refactoring into hooks
- Main's openInEditor functionality for VS Code integration

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
trueheads
2025-12-22 01:49:45 -06:00
599 changed files with 26666 additions and 24168 deletions

View File

@@ -0,0 +1,9 @@
export { useAutoMode } from './use-auto-mode';
export { useBoardBackgroundSettings } from './use-board-background-settings';
export { useElectronAgent } from './use-electron-agent';
export { useKeyboardShortcuts } from './use-keyboard-shortcuts';
export { useMessageQueue } from './use-message-queue';
export { useResponsiveKanban } from './use-responsive-kanban';
export { useScrollTracking } from './use-scroll-tracking';
export { useSettingsMigration } from './use-settings-migration';
export { useWindowState } from './use-window-state';

View File

@@ -1,12 +1,14 @@
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";
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';
// Type guard for plan_approval_required event
function isPlanApprovalEvent(event: AutoModeEvent): event is Extract<AutoModeEvent, { type: "plan_approval_required" }> {
return event.type === "plan_approval_required";
function isPlanApprovalEvent(
event: AutoModeEvent
): event is Extract<AutoModeEvent, { type: 'plan_approval_required' }> {
return event.type === 'plan_approval_required';
}
/**
@@ -50,9 +52,7 @@ export function useAutoMode() {
const projectId = currentProject?.id;
const projectAutoModeState = useMemo(() => {
if (!projectId) return { isRunning: false, runningTasks: [] };
return (
autoModeByProject[projectId] || { isRunning: false, runningTasks: [] }
);
return autoModeByProject[projectId] || { isRunning: false, runningTasks: [] };
}, [autoModeByProject, projectId]);
const isAutoModeRunning = projectAutoModeState.isRunning;
@@ -67,15 +67,15 @@ export function useAutoMode() {
if (!api?.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
console.log("[AutoMode Event]", event);
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) {
if ('projectPath' in event && event.projectPath) {
eventProjectId = getProjectIdFromPath(event.projectPath);
}
if (!eventProjectId && "projectId" in event && event.projectId) {
if (!eventProjectId && 'projectId' in event && event.projectId) {
eventProjectId = event.projectId;
}
if (!eventProjectId) {
@@ -84,52 +84,44 @@ export function useAutoMode() {
// Skip event if we couldn't determine the project
if (!eventProjectId) {
console.warn(
"[AutoMode] Could not determine project for event:",
event
);
console.warn('[AutoMode] Could not determine project for event:', event);
return;
}
switch (event.type) {
case "auto_mode_feature_start":
case 'auto_mode_feature_start':
if (event.featureId) {
addRunningTask(eventProjectId, event.featureId);
addAutoModeActivity({
featureId: event.featureId,
type: "start",
type: 'start',
message: `Started working on feature`,
});
}
break;
case "auto_mode_feature_complete":
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
);
console.log('[AutoMode] Feature completed:', event.featureId, 'passes:', event.passes);
removeRunningTask(eventProjectId, event.featureId);
addAutoModeActivity({
featureId: event.featureId,
type: "complete",
type: 'complete',
message: event.passes
? "Feature completed successfully"
: "Feature completed with failures",
? 'Feature completed successfully'
: 'Feature completed with failures',
passes: event.passes,
});
}
break;
case "auto_mode_error":
case 'auto_mode_error':
if (event.featureId && event.error) {
// Check if this is a user-initiated cancellation or abort (not a real error)
if (event.errorType === "cancellation" || event.errorType === "abort") {
if (event.errorType === 'cancellation' || event.errorType === 'abort') {
// User cancelled/aborted the feature - just log as info, not an error
console.log("[AutoMode] Feature cancelled/aborted:", event.error);
console.log('[AutoMode] Feature cancelled/aborted:', event.error);
// Remove from running tasks
if (eventProjectId) {
removeRunningTask(eventProjectId, event.featureId);
@@ -138,13 +130,13 @@ export function useAutoMode() {
}
// Real error - log and show to user
console.error("[AutoMode Error]", event.error);
console.error('[AutoMode Error]', 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");
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.`
@@ -152,9 +144,9 @@ export function useAutoMode() {
addAutoModeActivity({
featureId: event.featureId,
type: "error",
type: 'error',
message: errorMessage,
errorType: isAuthError ? "authentication" : "execution",
errorType: isAuthError ? 'authentication' : 'execution',
});
// Remove the task from running since it failed
@@ -164,35 +156,33 @@ export function useAutoMode() {
}
break;
case "auto_mode_progress":
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",
type: 'progress',
message: event.content.substring(0, 200), // Limit message length
});
}
break;
case "auto_mode_tool":
case 'auto_mode_tool':
// Log tool usage
if (event.featureId && event.tool) {
addAutoModeActivity({
featureId: event.featureId,
type: "tool",
type: 'tool',
message: `Using tool: ${event.tool}`,
tool: event.tool,
});
}
break;
case "auto_mode_phase":
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}`
);
console.log(`[AutoMode] Phase: ${event.phase} for ${event.featureId}`);
addAutoModeActivity({
featureId: event.featureId,
type: event.phase,
@@ -202,120 +192,124 @@ export function useAutoMode() {
}
break;
case "plan_approval_required":
case 'plan_approval_required':
// Plan requires user approval before proceeding
if (isPlanApprovalEvent(event)) {
console.log(
`[AutoMode] Plan approval required for ${event.featureId}`
);
console.log(`[AutoMode] Plan approval required for ${event.featureId}`);
setPendingPlanApproval({
featureId: event.featureId,
projectPath: event.projectPath || currentProject?.path || "",
projectPath: event.projectPath || currentProject?.path || '',
planContent: event.planContent,
planningMode: event.planningMode,
});
}
break;
case "planning_started":
case 'planning_started':
// Log when planning phase begins
if (event.featureId && event.mode && event.message) {
console.log(
`[AutoMode] Planning started (${event.mode}) for ${event.featureId}`
);
console.log(`[AutoMode] Planning started (${event.mode}) for ${event.featureId}`);
addAutoModeActivity({
featureId: event.featureId,
type: "planning",
type: 'planning',
message: event.message,
phase: "planning",
phase: 'planning',
});
}
break;
case "plan_approved":
case 'plan_approved':
// Log when plan is approved by user
if (event.featureId) {
console.log(`[AutoMode] Plan approved for ${event.featureId}`);
addAutoModeActivity({
featureId: event.featureId,
type: "action",
type: 'action',
message: event.hasEdits
? "Plan approved with edits, starting implementation..."
: "Plan approved, starting implementation...",
phase: "action",
? 'Plan approved with edits, starting implementation...'
: 'Plan approved, starting implementation...',
phase: 'action',
});
}
break;
case "plan_auto_approved":
case 'plan_auto_approved':
// Log when plan is auto-approved (requirePlanApproval=false)
if (event.featureId) {
console.log(`[AutoMode] Plan auto-approved for ${event.featureId}`);
addAutoModeActivity({
featureId: event.featureId,
type: "action",
message: "Plan auto-approved, starting implementation...",
phase: "action",
type: 'action',
message: 'Plan auto-approved, starting implementation...',
phase: 'action',
});
}
break;
case "plan_revision_requested":
case 'plan_revision_requested':
// Log when user requests plan revision with feedback
if (event.featureId) {
const revisionEvent = event as Extract<AutoModeEvent, { type: "plan_revision_requested" }>;
console.log(`[AutoMode] Plan revision requested for ${event.featureId} (v${revisionEvent.planVersion})`);
const revisionEvent = event as Extract<
AutoModeEvent,
{ type: 'plan_revision_requested' }
>;
console.log(
`[AutoMode] Plan revision requested for ${event.featureId} (v${revisionEvent.planVersion})`
);
addAutoModeActivity({
featureId: event.featureId,
type: "planning",
type: 'planning',
message: `Revising plan based on feedback (v${revisionEvent.planVersion})...`,
phase: "planning",
phase: 'planning',
});
}
break;
case "auto_mode_task_started":
case 'auto_mode_task_started':
// Task started - show which task is being worked on
if (event.featureId && "taskId" in event && "taskDescription" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
if (event.featureId && 'taskId' in event && 'taskDescription' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_started' }>;
console.log(
`[AutoMode] Task ${taskEvent.taskId} started for ${event.featureId}: ${taskEvent.taskDescription}`
);
addAutoModeActivity({
featureId: event.featureId,
type: "progress",
type: 'progress',
message: `▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}`,
});
}
break;
case "auto_mode_task_complete":
case 'auto_mode_task_complete':
// Task completed - show progress
if (event.featureId && "taskId" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
if (event.featureId && 'taskId' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_complete' }>;
console.log(
`[AutoMode] Task ${taskEvent.taskId} completed for ${event.featureId} (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})`
);
addAutoModeActivity({
featureId: event.featureId,
type: "progress",
type: 'progress',
message: `${taskEvent.taskId} done (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})`,
});
}
break;
case "auto_mode_phase_complete":
case 'auto_mode_phase_complete':
// Phase completed (for full mode with phased tasks)
if (event.featureId && "phaseNumber" in event) {
const phaseEvent = event as Extract<AutoModeEvent, { type: "auto_mode_phase_complete" }>;
if (event.featureId && 'phaseNumber' in event) {
const phaseEvent = event as Extract<
AutoModeEvent,
{ type: 'auto_mode_phase_complete' }
>;
console.log(
`[AutoMode] Phase ${phaseEvent.phaseNumber} completed for ${event.featureId}`
);
addAutoModeActivity({
featureId: event.featureId,
type: "action",
type: 'action',
message: `Phase ${phaseEvent.phaseNumber} completed`,
phase: "action",
phase: 'action',
});
}
break;
@@ -336,7 +330,7 @@ export function useAutoMode() {
// Start auto mode - UI only, feature pickup is handled in board-view.tsx
const start = useCallback(() => {
if (!currentProject) {
console.error("No project selected");
console.error('No project selected');
return;
}
@@ -347,7 +341,7 @@ export function useAutoMode() {
// Stop auto mode - UI only, running tasks continue until natural completion
const stop = useCallback(() => {
if (!currentProject) {
console.error("No project selected");
console.error('No project selected');
return;
}
@@ -356,40 +350,40 @@ export function useAutoMode() {
// 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 - running tasks will continue");
console.log('[AutoMode] Stopped - running tasks will continue');
}, [currentProject, setAutoModeRunning]);
// Stop a specific feature
const stopFeature = useCallback(
async (featureId: string) => {
if (!currentProject) {
console.error("No project selected");
console.error('No project selected');
return;
}
try {
const api = getElectronAPI();
if (!api?.autoMode?.stopFeature) {
throw new Error("Stop feature API not available");
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);
console.log('[AutoMode] Feature stopped successfully:', featureId);
addAutoModeActivity({
featureId,
type: "complete",
message: "Feature stopped by user",
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");
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);
console.error('[AutoMode] Error stopping feature:', error);
throw error;
}
},

View File

@@ -1,7 +1,7 @@
import { useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { getHttpApiClient } from "@/lib/http-api-client";
import { toast } from "sonner";
import { useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
/**
* Hook for managing board background settings with automatic persistence to server
@@ -14,20 +14,17 @@ export function useBoardBackgroundSettings() {
const persistSettings = useCallback(
async (projectPath: string, settingsToUpdate: Record<string, unknown>) => {
try {
const result = await httpClient.settings.updateProject(
projectPath,
{
boardBackground: settingsToUpdate,
}
);
const result = await httpClient.settings.updateProject(projectPath, {
boardBackground: settingsToUpdate,
});
if (!result.success) {
console.error("Failed to persist settings:", result.error);
toast.error("Failed to save settings");
console.error('Failed to persist settings:', result.error);
toast.error('Failed to save settings');
}
} catch (error) {
console.error("Failed to persist settings:", error);
toast.error("Failed to save settings");
console.error('Failed to persist settings:', error);
toast.error('Failed to save settings');
}
},
[httpClient]
@@ -37,16 +34,18 @@ export function useBoardBackgroundSettings() {
const getCurrentSettings = useCallback(
(projectPath: string) => {
const current = store.boardBackgroundByProject[projectPath];
return current || {
imagePath: null,
cardOpacity: 100,
columnOpacity: 100,
columnBorderEnabled: true,
cardGlassmorphism: true,
cardBorderEnabled: true,
cardBorderOpacity: 100,
hideScrollbar: false,
};
return (
current || {
imagePath: null,
cardOpacity: 100,
columnOpacity: 100,
columnBorderEnabled: true,
cardGlassmorphism: true,
cardBorderEnabled: true,
cardBorderOpacity: 100,
hideScrollbar: false,
}
);
},
[store.boardBackgroundByProject]
);

View File

@@ -1,8 +1,9 @@
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";
import { useState, useEffect, useCallback, useRef } from 'react';
import type { Message, StreamEvent } from '@/types/electron';
import { useMessageQueue } from './use-message-queue';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { sanitizeFilename } from '@/lib/image-utils';
interface UseElectronAgentOptions {
sessionId: string;
@@ -15,12 +16,22 @@ interface UseElectronAgentResult {
messages: Message[];
isProcessing: boolean;
isConnected: boolean;
sendMessage: (content: string, images?: ImageAttachment[]) => Promise<void>;
sendMessage: (
content: string,
images?: ImageAttachment[],
textFiles?: TextFileAttachment[]
) => Promise<void>;
stopExecution: () => Promise<void>;
clearHistory: () => Promise<void>;
error: string | null;
// Queue-related state
queuedMessages: { id: string; content: string; images?: ImageAttachment[]; timestamp: Date }[];
queuedMessages: {
id: string;
content: string;
images?: ImageAttachment[];
textFiles?: TextFileAttachment[];
timestamp: Date;
}[];
isQueueProcessing: boolean;
clearMessageQueue: () => void;
}
@@ -46,26 +57,38 @@ export function useElectronAgent({
// Send message directly to the agent (bypassing queue)
const sendMessageDirectly = useCallback(
async (content: string, images?: ImageAttachment[]) => {
async (content: string, images?: ImageAttachment[], textFiles?: TextFileAttachment[]) => {
const api = getElectronAPI();
if (!api?.agent) {
setError("API not available");
setError('API not available');
return;
}
if (isProcessing) {
throw new Error("Agent is already processing a message");
throw new Error('Agent is already processing a message');
}
setIsProcessing(true);
setError(null);
try {
console.log("[useElectronAgent] Sending message directly", {
console.log('[useElectronAgent] Sending message directly', {
hasImages: images && images.length > 0,
imageCount: images?.length || 0
imageCount: images?.length || 0,
hasTextFiles: textFiles && textFiles.length > 0,
textFileCount: textFiles?.length || 0,
});
// Build message content with text file context prepended
let messageContent = content;
if (textFiles && textFiles.length > 0) {
const contextParts = textFiles.map((file) => {
return `<file name="${file.filename}">\n${file.content}\n</file>`;
});
const contextBlock = `Here are some files for context:\n\n${contextParts.join('\n\n')}\n\n`;
messageContent = contextBlock + content;
}
// Save images to .automaker/images and get paths
let imagePaths: string[] | undefined;
if (images && images.length > 0 && api.saveImageToTemp) {
@@ -73,36 +96,36 @@ export function useElectronAgent({
for (const image of images) {
const result = await api.saveImageToTemp(
image.data,
image.filename,
sanitizeFilename(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);
console.log('[useElectronAgent] Saved image to .automaker/images:', result.path);
} else {
console.error("[useElectronAgent] Failed to save image:", result.error);
console.error('[useElectronAgent] Failed to save image:', result.error);
}
}
}
const result = await api.agent!.send(
sessionId,
content,
messageContent,
workingDirectory,
imagePaths,
model
);
if (!result.success) {
setError(result.error || "Failed to send message");
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");
console.error('[useElectronAgent] Failed to send message:', err);
setError(err instanceof Error ? err.message : 'Failed to send message');
setIsProcessing(false);
throw err;
}
@@ -111,23 +134,22 @@ export function useElectronAgent({
);
// 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);
},
});
const { queuedMessages, isProcessingQueue, addToQueue, clearQueue, processNext } =
useMessageQueue({
onProcessNext: async (queuedMessage) => {
await sendMessageDirectly(
queuedMessage.content,
queuedMessage.images,
queuedMessage.textFiles
);
},
});
// Initialize connection and load history
useEffect(() => {
const api = getElectronAPI();
if (!api?.agent) {
setError("API not available.");
setError('API not available.');
return;
}
@@ -147,16 +169,13 @@ export function useElectronAgent({
setError(null);
try {
console.log("[useElectronAgent] Starting session:", sessionId);
const result = await api.agent!.start(
sessionId,
workingDirectory
);
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");
console.log('[useElectronAgent] Loaded', result.messages.length, 'messages');
setMessages(result.messages);
setIsConnected(true);
@@ -164,17 +183,17 @@ export function useElectronAgent({
const historyResult = await api.agent!.getHistory(sessionId);
if (mounted && historyResult.success) {
const isRunning = historyResult.isRunning || false;
console.log("[useElectronAgent] Session running state:", isRunning);
console.log('[useElectronAgent] Session running state:', isRunning);
setIsProcessing(isRunning);
}
} else {
setError(result.error || "Failed to start session");
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");
console.error('[useElectronAgent] Failed to initialize:', err);
setError(err instanceof Error ? err.message : 'Failed to initialize');
setIsProcessing(false);
}
};
@@ -189,7 +208,7 @@ export function useElectronAgent({
// Auto-process queue when agent finishes processing
useEffect(() => {
if (!isProcessing && !isProcessingQueue && queuedMessages.length > 0) {
console.log("[useElectronAgent] Auto-processing next queued message");
console.log('[useElectronAgent] Auto-processing next queued message');
processNext();
}
}, [isProcessing, isProcessingQueue, queuedMessages.length, processNext]);
@@ -200,32 +219,30 @@ export function useElectronAgent({
if (!api?.agent) return;
if (!sessionId) return; // Don't subscribe if no session
console.log("[useElectronAgent] Subscribing to stream events for session:", sessionId);
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);
console.log('[useElectronAgent] Ignoring event for different session:', event.sessionId);
return;
}
console.log("[useElectronAgent] Stream event for", sessionId, ":", event.type);
console.log('[useElectronAgent] Stream event for', sessionId, ':', event.type);
switch (event.type) {
case "message":
case 'message':
// User message added
setMessages((prev) => [...prev, event.message]);
break;
case "stream":
case 'stream':
// Assistant message streaming
if (event.isComplete) {
// Final update
setMessages((prev) =>
prev.map((msg) =>
msg.id === event.messageId
? { ...msg, content: event.content }
: msg
msg.id === event.messageId ? { ...msg, content: event.content } : msg
)
);
currentMessageRef.current = null;
@@ -236,15 +253,13 @@ export function useElectronAgent({
if (existingIndex >= 0) {
// Update existing message
return prev.map((msg) =>
msg.id === event.messageId
? { ...msg, content: event.content }
: msg
msg.id === event.messageId ? { ...msg, content: event.content } : msg
);
} else {
// Create new message
const newMessage: Message = {
id: event.messageId,
role: "assistant",
role: 'assistant',
content: event.content,
timestamp: new Date().toISOString(),
};
@@ -255,30 +270,28 @@ export function useElectronAgent({
}
break;
case "tool_use":
case 'tool_use':
// Tool being used
console.log("[useElectronAgent] Tool use:", event.tool.name);
console.log('[useElectronAgent] Tool use:', event.tool.name);
onToolUse?.(event.tool.name, event.tool.input);
break;
case "complete":
case 'complete':
// Agent finished processing for THIS session
console.log("[useElectronAgent] Processing complete for session:", sessionId);
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
msg.id === event.messageId ? { ...msg, content: event.content } : msg
)
);
}
break;
case "error":
case 'error':
// Error occurred for THIS session
console.error("[useElectronAgent] Agent error for session:", sessionId, event.error);
console.error('[useElectronAgent] Agent error for session:', sessionId, event.error);
setIsProcessing(false);
setError(event.error);
if (event.message) {
@@ -293,7 +306,7 @@ export function useElectronAgent({
return () => {
if (unsubscribeRef.current) {
console.log("[useElectronAgent] Unsubscribing from stream events for session:", sessionId);
console.log('[useElectronAgent] Unsubscribing from stream events for session:', sessionId);
unsubscribeRef.current();
unsubscribeRef.current = null;
}
@@ -302,15 +315,15 @@ export function useElectronAgent({
// Send a message to the agent
const sendMessage = useCallback(
async (content: string, images?: ImageAttachment[]) => {
async (content: string, images?: ImageAttachment[], textFiles?: TextFileAttachment[]) => {
const api = getElectronAPI();
if (!api?.agent) {
setError("API not available");
setError('API not available');
return;
}
if (isProcessing) {
console.warn("[useElectronAgent] Already processing a message");
console.warn('[useElectronAgent] Already processing a message');
return;
}
@@ -318,11 +331,23 @@ export function useElectronAgent({
setError(null);
try {
console.log("[useElectronAgent] Sending message", {
console.log('[useElectronAgent] Sending message', {
hasImages: images && images.length > 0,
imageCount: images?.length || 0
imageCount: images?.length || 0,
hasTextFiles: textFiles && textFiles.length > 0,
textFileCount: textFiles?.length || 0,
});
// Build message content with text file context prepended
let messageContent = content;
if (textFiles && textFiles.length > 0) {
const contextParts = textFiles.map((file) => {
return `<file name="${file.filename}">\n${file.content}\n</file>`;
});
const contextBlock = `Here are some files for context:\n\n${contextParts.join('\n\n')}\n\n`;
messageContent = contextBlock + content;
}
// Save images to .automaker/images and get paths
let imagePaths: string[] | undefined;
if (images && images.length > 0 && api.saveImageToTemp) {
@@ -330,36 +355,36 @@ export function useElectronAgent({
for (const image of images) {
const result = await api.saveImageToTemp(
image.data,
image.filename,
sanitizeFilename(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);
console.log('[useElectronAgent] Saved image to .automaker/images:', result.path);
} else {
console.error("[useElectronAgent] Failed to save image:", result.error);
console.error('[useElectronAgent] Failed to save image:', result.error);
}
}
}
const result = await api.agent!.send(
sessionId,
content,
messageContent,
workingDirectory,
imagePaths,
model
);
if (!result.success) {
setError(result.error || "Failed to send message");
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");
console.error('[useElectronAgent] Failed to send message:', err);
setError(err instanceof Error ? err.message : 'Failed to send message');
setIsProcessing(false);
}
},
@@ -370,22 +395,22 @@ export function useElectronAgent({
const stopExecution = useCallback(async () => {
const api = getElectronAPI();
if (!api?.agent) {
setError("API not available");
setError('API not available');
return;
}
try {
console.log("[useElectronAgent] Stopping execution");
console.log('[useElectronAgent] Stopping execution');
const result = await api.agent!.stop(sessionId);
if (!result.success) {
setError(result.error || "Failed to stop execution");
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");
console.error('[useElectronAgent] Failed to stop:', err);
setError(err instanceof Error ? err.message : 'Failed to stop execution');
}
}, [sessionId]);
@@ -393,23 +418,23 @@ export function useElectronAgent({
const clearHistory = useCallback(async () => {
const api = getElectronAPI();
if (!api?.agent) {
setError("API not available");
setError('API not available');
return;
}
try {
console.log("[useElectronAgent] Clearing history");
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");
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");
console.error('[useElectronAgent] Failed to clear:', err);
setError(err instanceof Error ? err.message : 'Failed to clear history');
}
}, [sessionId]);

View File

@@ -1,9 +1,8 @@
import { useEffect, useCallback } from "react";
import { useAppStore, parseShortcut } from "@/store/app-store";
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"
key: string; // Can be simple "K" or with modifiers "Shift+N", "Cmd+K"
action: () => void;
description?: string;
}
@@ -18,37 +17,35 @@ function isInputFocused(): boolean {
// Check if it's a form input element
const tagName = activeElement.tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
return true;
}
// Check if it's a contenteditable element
if (activeElement.getAttribute("contenteditable") === "true") {
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") {
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");
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]");
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"]'
);
const autocompleteList = document.querySelector('[data-testid="category-autocomplete-list"]');
if (autocompleteList) {
return true;
}
@@ -60,9 +57,7 @@ function isInputFocused(): boolean {
}
// Check for project picker dropdown being open
const projectPickerDropdown = document.querySelector(
'[data-testid="project-picker-dropdown"]'
);
const projectPickerDropdown = document.querySelector('[data-testid="project-picker-dropdown"]');
if (projectPickerDropdown) {
return true;
}
@@ -77,6 +72,102 @@ function isInputFocused(): boolean {
return false;
}
/**
* Convert a key character to its corresponding event.code
* This is used for keyboard-layout independent matching in terminals
*/
function keyToCode(key: string): string {
const upperKey = key.toUpperCase();
// Letters A-Z map to KeyA-KeyZ
if (/^[A-Z]$/.test(upperKey)) {
return `Key${upperKey}`;
}
// Numbers 0-9 on main row map to Digit0-Digit9
if (/^[0-9]$/.test(key)) {
return `Digit${key}`;
}
// Special key mappings
const specialMappings: Record<string, string> = {
'`': 'Backquote',
'~': 'Backquote',
'-': 'Minus',
_: 'Minus',
'=': 'Equal',
'+': 'Equal',
'[': 'BracketLeft',
'{': 'BracketLeft',
']': 'BracketRight',
'}': 'BracketRight',
'\\': 'Backslash',
'|': 'Backslash',
';': 'Semicolon',
':': 'Semicolon',
"'": 'Quote',
'"': 'Quote',
',': 'Comma',
'<': 'Comma',
'.': 'Period',
'>': 'Period',
'/': 'Slash',
'?': 'Slash',
' ': 'Space',
Enter: 'Enter',
Tab: 'Tab',
Escape: 'Escape',
Backspace: 'Backspace',
Delete: 'Delete',
ArrowUp: 'ArrowUp',
ArrowDown: 'ArrowDown',
ArrowLeft: 'ArrowLeft',
ArrowRight: 'ArrowRight',
};
return specialMappings[key] || specialMappings[upperKey] || key;
}
/**
* Check if a keyboard event matches a shortcut definition using event.code
* This is keyboard-layout independent - useful for terminals where Alt+key
* combinations can produce special characters with event.key
*/
export function matchesShortcutWithCode(event: KeyboardEvent, shortcutStr: string): boolean {
const shortcut = parseShortcut(shortcutStr);
if (!shortcut.key) return false;
// Convert the shortcut key to event.code format
const expectedCode = keyToCode(shortcut.key);
// Check if the code matches
if (event.code !== expectedCode) {
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;
}
/**
* Check if a keyboard event matches a shortcut definition
*/
@@ -125,9 +216,7 @@ export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
}
// Find matching shortcut
const matchingShortcut = shortcuts.find(
(shortcut) => matchesShortcut(event, shortcut.key)
);
const matchingShortcut = shortcuts.find((shortcut) => matchesShortcut(event, shortcut.key));
if (matchingShortcut) {
event.preventDefault();
@@ -138,9 +227,9 @@ export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
}

View File

@@ -1,10 +1,11 @@
import { useState, useCallback } from 'react';
import type { ImageAttachment } from '@/store/app-store';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
export interface QueuedMessage {
id: string;
content: string;
images?: ImageAttachment[];
textFiles?: TextFileAttachment[];
timestamp: Date;
}
@@ -15,7 +16,11 @@ interface UseMessageQueueOptions {
interface UseMessageQueueResult {
queuedMessages: QueuedMessage[];
isProcessingQueue: boolean;
addToQueue: (content: string, images?: ImageAttachment[]) => void;
addToQueue: (
content: string,
images?: ImageAttachment[],
textFiles?: TextFileAttachment[]
) => void;
clearQueue: () => void;
removeFromQueue: (messageId: string) => void;
processNext: () => Promise<void>;
@@ -31,19 +36,23 @@ export function useMessageQueue({ onProcessNext }: UseMessageQueueOptions): UseM
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(),
};
const addToQueue = useCallback(
(content: string, images?: ImageAttachment[], textFiles?: TextFileAttachment[]) => {
const queuedMessage: QueuedMessage = {
id: `queued-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
content: content.trim(),
images,
textFiles,
timestamp: new Date(),
};
setQueuedMessages(prev => [...prev, queuedMessage]);
}, []);
setQueuedMessages((prev) => [...prev, queuedMessage]);
},
[]
);
const removeFromQueue = useCallback((messageId: string) => {
setQueuedMessages(prev => prev.filter(msg => msg.id !== messageId));
setQueuedMessages((prev) => prev.filter((msg) => msg.id !== messageId));
}, []);
const clearQueue = useCallback(() => {
@@ -61,7 +70,7 @@ export function useMessageQueue({ onProcessNext }: UseMessageQueueOptions): UseM
try {
await onProcessNext(nextMessage);
// Remove the processed message from queue
setQueuedMessages(prev => prev.slice(1));
setQueuedMessages((prev) => prev.slice(1));
} catch (error) {
console.error('Error processing queued message:', error);
// Keep the message in queue for retry or manual removal
@@ -78,4 +87,4 @@ export function useMessageQueue({ onProcessNext }: UseMessageQueueOptions): UseM
removeFromQueue,
processNext,
};
}
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback } from 'react';
interface ScrollTrackingItem {
id: string;
@@ -26,9 +26,7 @@ export function useScrollTracking<T extends ScrollTrackingItem>({
initialSection,
scrollOffset = 24,
}: UseScrollTrackingOptions<T>) {
const [activeSection, setActiveSection] = useState(
initialSection || items[0]?.id || ""
);
const [activeSection, setActiveSection] = useState(initialSection || items[0]?.id || '');
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Track scroll position to highlight active nav item
@@ -72,8 +70,8 @@ export function useScrollTracking<T extends ScrollTrackingItem>({
}
};
container.addEventListener("scroll", handleScroll);
return () => container.removeEventListener("scroll", handleScroll);
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [items, filterFn]);
// Scroll to a specific section with smooth animation
@@ -84,12 +82,11 @@ export function useScrollTracking<T extends ScrollTrackingItem>({
const container = scrollContainerRef.current;
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const relativeTop =
elementRect.top - containerRect.top + container.scrollTop;
const relativeTop = elementRect.top - containerRect.top + container.scrollTop;
container.scrollTo({
top: relativeTop - scrollOffset,
behavior: "smooth",
behavior: 'smooth',
});
}
},

View File

@@ -17,10 +17,10 @@
* - syncProjectSettingsToServer: Writes project-specific overrides
*/
import { useEffect, useState, useRef } from "react";
import { getHttpApiClient } from "@/lib/http-api-client";
import { isElectron } from "@/lib/electron";
import { getItem, removeItem } from "@/lib/storage";
import { useEffect, useState, useRef } from 'react';
import { getHttpApiClient } from '@/lib/http-api-client';
import { isElectron } from '@/lib/electron';
import { getItem, removeItem } from '@/lib/storage';
/**
* State returned by useSettingsMigration hook
@@ -41,11 +41,11 @@ interface MigrationState {
* The automaker-storage key is handled specially as it's still used by Zustand.
*/
const LOCALSTORAGE_KEYS = [
"automaker-storage",
"automaker-setup",
"worktree-panel-collapsed",
"file-browser-recent-folders",
"automaker:lastProjectDir",
'automaker-storage',
'automaker-setup',
'worktree-panel-collapsed',
'file-browser-recent-folders',
'automaker:lastProjectDir',
] as const;
/**
@@ -55,13 +55,13 @@ const LOCALSTORAGE_KEYS = [
* as a cache. These other keys have been migrated and are no longer needed.
*/
const KEYS_TO_CLEAR_AFTER_MIGRATION = [
"worktree-panel-collapsed",
"file-browser-recent-folders",
"automaker:lastProjectDir",
'worktree-panel-collapsed',
'file-browser-recent-folders',
'automaker:lastProjectDir',
// Legacy keys from older versions
"automaker_projects",
"automaker_current_project",
"automaker_trashed_projects",
'automaker_projects',
'automaker_current_project',
'automaker_trashed_projects',
] as const;
/**
@@ -104,35 +104,31 @@ export function useSettingsMigration(): MigrationState {
const status = await api.settings.getStatus();
if (!status.success) {
console.error("[Settings Migration] Failed to get status:", status);
console.error('[Settings Migration] Failed to get status:', status);
setState({
checked: true,
migrated: false,
error: "Failed to check settings status",
error: 'Failed to check settings status',
});
return;
}
// If settings files already exist, no migration needed
if (!status.needsMigration) {
console.log(
"[Settings Migration] Settings files exist, no migration needed"
);
console.log('[Settings Migration] Settings files exist, no migration needed');
setState({ checked: true, migrated: false, error: null });
return;
}
// Check if we have localStorage data to migrate
const automakerStorage = getItem("automaker-storage");
const automakerStorage = getItem('automaker-storage');
if (!automakerStorage) {
console.log(
"[Settings Migration] No localStorage data to migrate"
);
console.log('[Settings Migration] No localStorage data to migrate');
setState({ checked: true, migrated: false, error: null });
return;
}
console.log("[Settings Migration] Starting migration...");
console.log('[Settings Migration] Starting migration...');
// Collect all localStorage data
const localStorageData: Record<string, string> = {};
@@ -147,7 +143,7 @@ export function useSettingsMigration(): MigrationState {
const result = await api.settings.migrate(localStorageData);
if (result.success) {
console.log("[Settings Migration] Migration successful:", {
console.log('[Settings Migration] Migration successful:', {
globalSettings: result.migratedGlobalSettings,
credentials: result.migratedCredentials,
projects: result.migratedProjectCount,
@@ -160,22 +156,19 @@ export function useSettingsMigration(): MigrationState {
setState({ checked: true, migrated: true, error: null });
} else {
console.warn(
"[Settings Migration] Migration had errors:",
result.errors
);
console.warn('[Settings Migration] Migration had errors:', result.errors);
setState({
checked: true,
migrated: false,
error: result.errors.join(", "),
error: result.errors.join(', '),
});
}
} catch (error) {
console.error("[Settings Migration] Migration failed:", error);
console.error('[Settings Migration] Migration failed:', error);
setState({
checked: true,
migrated: false,
error: error instanceof Error ? error.message : "Unknown error",
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
@@ -204,7 +197,7 @@ export async function syncSettingsToServer(): Promise<boolean> {
try {
const api = getHttpApiClient();
const automakerStorage = getItem("automaker-storage");
const automakerStorage = getItem('automaker-storage');
if (!automakerStorage) {
return false;
@@ -241,7 +234,7 @@ export async function syncSettingsToServer(): Promise<boolean> {
const result = await api.settings.updateGlobal(updates);
return result.success;
} catch (error) {
console.error("[Settings Sync] Failed to sync settings:", error);
console.error('[Settings Sync] Failed to sync settings:', error);
return false;
}
}
@@ -272,7 +265,7 @@ export async function syncCredentialsToServer(apiKeys: {
const result = await api.settings.updateCredentials({ apiKeys });
return result.success;
} catch (error) {
console.error("[Settings Sync] Failed to sync credentials:", error);
console.error('[Settings Sync] Failed to sync credentials:', error);
return false;
}
}
@@ -316,10 +309,7 @@ export async function syncProjectSettingsToServer(
const result = await api.settings.updateProject(projectPath, updates);
return result.success;
} catch (error) {
console.error(
"[Settings Sync] Failed to sync project settings:",
error
);
console.error('[Settings Sync] Failed to sync project settings:', error);
return false;
}
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect } from 'react';
export interface WindowState {
isMaximized: boolean;
@@ -13,7 +13,7 @@ export interface WindowState {
*/
export function useWindowState(): WindowState {
const [windowState, setWindowState] = useState<WindowState>(() => {
if (typeof window === "undefined") {
if (typeof window === 'undefined') {
return { isMaximized: false, windowWidth: 0, windowHeight: 0 };
}
const width = window.innerWidth;
@@ -26,7 +26,7 @@ export function useWindowState(): WindowState {
});
useEffect(() => {
if (typeof window === "undefined") return;
if (typeof window === 'undefined') return;
const updateWindowState = () => {
const width = window.innerWidth;
@@ -42,15 +42,12 @@ export function useWindowState(): WindowState {
updateWindowState();
// Listen for resize events
window.addEventListener("resize", updateWindowState);
window.addEventListener('resize', updateWindowState);
return () => {
window.removeEventListener("resize", updateWindowState);
window.removeEventListener('resize', updateWindowState);
};
}, []);
return windowState;
}