From 195b98e68820c8b9de6a288ebf46ac80caee5b7e Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 20 Dec 2025 22:56:25 -0500 Subject: [PATCH 01/59] feat: enhance terminal functionality and settings - Added new endpoints for terminal settings: GET and PUT /settings to retrieve and update terminal configurations. - Implemented session limit checks during session creation, returning a 429 status when the limit is reached. - Introduced a new TerminalSection in settings view for customizing terminal appearance and behavior, including font family, default font size, line height, and screen reader mode. - Added support for new terminal features such as search functionality and improved error handling with a TerminalErrorBoundary component. - Updated terminal layout persistence to include session IDs for reconnection and enhanced terminal state management. - Introduced new keyboard shortcuts for terminal actions, including creating new terminal tabs. - Enhanced UI with scrollbar theming for terminal components. --- apps/server/src/routes/terminal/index.ts | 6 + .../src/routes/terminal/routes/sessions.ts | 15 + .../src/routes/terminal/routes/settings.ts | 55 + apps/server/src/services/terminal-service.ts | 56 +- apps/ui/eslint.config.mjs | 5 + apps/ui/package.json | 2 + apps/ui/src/components/ui/keyboard-map.tsx | 2 + .../ui/src/components/views/settings-view.tsx | 3 + .../views/settings-view/config/navigation.ts | 2 + .../settings-view/hooks/use-settings-view.ts | 1 + .../terminal/terminal-section.tsx | 175 +++ .../setup-view/components/terminal-output.tsx | 8 +- .../ui/src/components/views/terminal-view.tsx | 868 ++++++++++++-- .../terminal-view/terminal-error-boundary.tsx | 94 ++ .../views/terminal-view/terminal-panel.tsx | 1043 ++++++++++++++++- apps/ui/src/config/terminal-themes.ts | 30 +- apps/ui/src/lib/electron.ts | 5 + apps/ui/src/lib/http-api-client.ts | 38 + apps/ui/src/main.ts | 23 + apps/ui/src/preload.ts | 2 + apps/ui/src/routes/__root.tsx | 4 + apps/ui/src/store/app-store.ts | 399 ++++++- apps/ui/src/styles/global.css | 20 + package-lock.json | 22 +- 24 files changed, 2729 insertions(+), 149 deletions(-) create mode 100644 apps/server/src/routes/terminal/routes/settings.ts create mode 100644 apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx create mode 100644 apps/ui/src/components/views/terminal-view/terminal-error-boundary.tsx diff --git a/apps/server/src/routes/terminal/index.ts b/apps/server/src/routes/terminal/index.ts index 7ee0e978..404189d7 100644 --- a/apps/server/src/routes/terminal/index.ts +++ b/apps/server/src/routes/terminal/index.ts @@ -21,6 +21,10 @@ import { } from "./routes/sessions.js"; import { createSessionDeleteHandler } from "./routes/session-delete.js"; import { createSessionResizeHandler } from "./routes/session-resize.js"; +import { + createSettingsGetHandler, + createSettingsUpdateHandler, +} from "./routes/settings.js"; // Re-export for use in main index.ts export { validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired }; @@ -39,6 +43,8 @@ export function createTerminalRoutes(): Router { router.post("/sessions", createSessionsCreateHandler()); router.delete("/sessions/:id", createSessionDeleteHandler()); router.post("/sessions/:id/resize", createSessionResizeHandler()); + router.get("/settings", createSettingsGetHandler()); + router.put("/settings", createSettingsUpdateHandler()); return router; } diff --git a/apps/server/src/routes/terminal/routes/sessions.ts b/apps/server/src/routes/terminal/routes/sessions.ts index 1c1138c0..43a2b10f 100644 --- a/apps/server/src/routes/terminal/routes/sessions.ts +++ b/apps/server/src/routes/terminal/routes/sessions.ts @@ -34,6 +34,21 @@ export function createSessionsCreateHandler() { shell, }); + // Check if session creation was refused due to limit + if (!session) { + const maxSessions = terminalService.getMaxSessions(); + const currentSessions = terminalService.getSessionCount(); + logger.warn(`Session limit reached: ${currentSessions}/${maxSessions}`); + res.status(429).json({ + success: false, + error: "Maximum terminal sessions reached", + details: `Server limit is ${maxSessions} concurrent sessions. Please close unused terminals.`, + currentSessions, + maxSessions, + }); + return; + } + res.json({ success: true, data: { diff --git a/apps/server/src/routes/terminal/routes/settings.ts b/apps/server/src/routes/terminal/routes/settings.ts new file mode 100644 index 00000000..9bd493c8 --- /dev/null +++ b/apps/server/src/routes/terminal/routes/settings.ts @@ -0,0 +1,55 @@ +/** + * GET/PUT /settings endpoint - Get/Update terminal settings + */ + +import type { Request, Response } from "express"; +import { getTerminalService } from "../../../services/terminal-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createSettingsGetHandler() { + return (_req: Request, res: Response): void => { + const terminalService = getTerminalService(); + res.json({ + success: true, + data: { + maxSessions: terminalService.getMaxSessions(), + currentSessions: terminalService.getSessionCount(), + }, + }); + }; +} + +export function createSettingsUpdateHandler() { + return (req: Request, res: Response): void => { + try { + const terminalService = getTerminalService(); + const { maxSessions } = req.body; + + if (typeof maxSessions === "number") { + if (maxSessions < 1 || maxSessions > 500) { + res.status(400).json({ + success: false, + error: "maxSessions must be between 1 and 500", + }); + return; + } + terminalService.setMaxSessions(maxSessions); + } + + res.json({ + success: true, + data: { + maxSessions: terminalService.getMaxSessions(), + currentSessions: terminalService.getSessionCount(), + }, + }); + } catch (error) { + logError(error, "Update terminal settings failed"); + res.status(500).json({ + success: false, + error: "Failed to update terminal settings", + details: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index 6d8faa7f..8d671f79 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -13,9 +13,16 @@ import * as fs from "fs"; // Maximum scrollback buffer size (characters) const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal +// Maximum number of concurrent terminal sessions +// Can be overridden via TERMINAL_MAX_SESSIONS environment variable +// Default set to 1000 - effectively unlimited for most use cases +let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || "1000", 10); + // Throttle output to prevent overwhelming WebSocket under heavy load -const OUTPUT_THROTTLE_MS = 16; // ~60fps max update rate -const OUTPUT_BATCH_SIZE = 8192; // Max bytes to send per batch +// Using 4ms for responsive input feedback while still preventing flood +// Note: 16ms caused perceived input lag, especially with backspace +const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input +const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency export interface TerminalSession { id: string; @@ -176,9 +183,40 @@ export class TerminalService extends EventEmitter { } /** - * Create a new terminal session + * Get current session count */ - createSession(options: TerminalOptions = {}): TerminalSession { + getSessionCount(): number { + return this.sessions.size; + } + + /** + * Get maximum allowed sessions + */ + getMaxSessions(): number { + return maxSessions; + } + + /** + * Set maximum allowed sessions (can be called dynamically) + */ + setMaxSessions(limit: number): void { + if (limit >= 1 && limit <= 500) { + maxSessions = limit; + console.log(`[Terminal] Max sessions limit updated to ${limit}`); + } + } + + /** + * Create a new terminal session + * Returns null if the maximum session limit has been reached + */ + createSession(options: TerminalOptions = {}): TerminalSession | null { + // Check session limit + if (this.sessions.size >= maxSessions) { + console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`); + return null; + } + const id = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const { shell: detectedShell, args: shellArgs } = this.detectShell(); @@ -188,11 +226,15 @@ export class TerminalService extends EventEmitter { const cwd = this.resolveWorkingDirectory(options.cwd); // Build environment with some useful defaults + // These settings ensure consistent terminal behavior across platforms const env: Record = { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor", TERM_PROGRAM: "automaker-terminal", + // Ensure proper locale for character handling + LANG: process.env.LANG || "en_US.UTF-8", + LC_ALL: process.env.LC_ALL || process.env.LANG || "en_US.UTF-8", ...options.env, }; @@ -350,12 +392,16 @@ export class TerminalService extends EventEmitter { clearTimeout(session.resizeDebounceTimeout); session.resizeDebounceTimeout = null; } - session.pty.kill(); + // Use SIGKILL for forceful termination - shell processes may ignore SIGTERM/SIGHUP + // This ensures the PTY process is actually killed, especially on WSL + session.pty.kill("SIGKILL"); this.sessions.delete(sessionId); console.log(`[Terminal] Session ${sessionId} killed`); return true; } catch (error) { console.error(`[Terminal] Error killing session ${sessionId}:`, error); + // Still try to remove from map even if kill fails + this.sessions.delete(sessionId); return false; } } diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs index 150f0bad..aed07d98 100644 --- a/apps/ui/eslint.config.mjs +++ b/apps/ui/eslint.config.mjs @@ -2,6 +2,7 @@ import { defineConfig, globalIgnores } from "eslint/config"; import js from "@eslint/js"; import ts from "@typescript-eslint/eslint-plugin"; import tsParser from "@typescript-eslint/parser"; +import globals from "globals"; const eslintConfig = defineConfig([ js.configs.recommended, @@ -13,6 +14,10 @@ const eslintConfig = defineConfig([ ecmaVersion: "latest", sourceType: "module", }, + globals: { + ...globals.browser, + ...globals.es2021, + }, }, plugins: { "@typescript-eslint": ts, diff --git a/apps/ui/package.json b/apps/ui/package.json index d5d81131..2c228020 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -60,6 +60,8 @@ "@tanstack/react-router": "^1.141.6", "@uiw/react-codemirror": "^4.25.4", "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-search": "^0.15.0", + "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.1", diff --git a/apps/ui/src/components/ui/keyboard-map.tsx b/apps/ui/src/components/ui/keyboard-map.tsx index c9ab6845..eaae645e 100644 --- a/apps/ui/src/components/ui/keyboard-map.tsx +++ b/apps/ui/src/components/ui/keyboard-map.tsx @@ -103,6 +103,7 @@ const SHORTCUT_LABELS: Record = { splitTerminalRight: "Split Right", splitTerminalDown: "Split Down", closeTerminal: "Close Terminal", + newTerminalTab: "New Tab", }; // Categorize shortcuts for color coding @@ -127,6 +128,7 @@ const SHORTCUT_CATEGORIES: Record ); + case "terminal": + return ; case "keyboard": return ( +
+
+
+ +
+

Terminal

+
+

+ Customize terminal appearance and behavior. Theme follows your app theme in Appearance settings. +

+
+
+ {/* Font Family */} +
+ + +
+ + {/* Default Font Size */} +
+
+ + {defaultFontSize}px +
+ setTerminalDefaultFontSize(value)} + className="flex-1" + /> +
+ + {/* Line Height */} +
+
+ + {lineHeight.toFixed(1)} +
+ { + setTerminalLineHeight(value); + }} + onValueCommit={() => { + toast.info("Line height changed", { + description: "Restart terminal for changes to take effect", + }); + }} + className="flex-1" + /> +
+ + {/* Scrollback Lines */} +
+
+ + + {(scrollbackLines / 1000).toFixed(0)}k lines + +
+ setTerminalScrollbackLines(value)} + onValueCommit={() => { + toast.info("Scrollback changed", { + description: "Restart terminal for changes to take effect", + }); + }} + className="flex-1" + /> +
+ + {/* Default Run Script */} +
+ +

+ Command to run automatically when opening a new terminal (e.g., "claude", "codex") +

+ setTerminalDefaultRunScript(e.target.value)} + placeholder="e.g., claude, codex, npm run dev" + className="bg-accent/30 border-border/50" + /> +
+ + {/* Screen Reader Mode */} +
+
+ +

+ Enable accessibility mode for screen readers +

+
+ { + setTerminalScreenReaderMode(checked); + toast.success(checked ? "Screen reader mode enabled" : "Screen reader mode disabled", { + description: "Restart terminal for changes to take effect", + }); + }} + /> +
+
+ + ); +} diff --git a/apps/ui/src/components/views/setup-view/components/terminal-output.tsx b/apps/ui/src/components/views/setup-view/components/terminal-output.tsx index ae7ac7f9..c7a36bce 100644 --- a/apps/ui/src/components/views/setup-view/components/terminal-output.tsx +++ b/apps/ui/src/components/views/setup-view/components/terminal-output.tsx @@ -4,14 +4,14 @@ interface TerminalOutputProps { export function TerminalOutput({ lines }: TerminalOutputProps) { return ( -
+
{lines.map((line, index) => ( -
- $ {line} +
+ $ {line}
))} {lines.length === 0 && ( -
Waiting for output...
+
Waiting for output...
)}
); diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 34d5879b..8c405d13 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -12,28 +12,42 @@ import { RefreshCw, X, SquarePlus, + Settings, } from "lucide-react"; -import { useAppStore, type TerminalPanelContent, type TerminalTab } from "@/store/app-store"; -import { useKeyboardShortcutsConfig, type KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts"; +import { useAppStore, type TerminalPanelContent, type TerminalTab, type PersistedTerminalPanel } from "@/store/app-store"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Slider } from "@/components/ui/slider"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { TERMINAL_FONT_OPTIONS } from "@/config/terminal-themes"; +import { toast } from "sonner"; import { Panel, PanelGroup, PanelResizeHandle, } from "react-resizable-panels"; import { TerminalPanel } from "./terminal-view/terminal-panel"; +import { TerminalErrorBoundary } from "./terminal-view/terminal-error-boundary"; import { DndContext, DragEndEvent, DragStartEvent, DragOverEvent, PointerSensor, + TouchSensor, + KeyboardSensor, useSensor, useSensors, closestCenter, DragOverlay, useDroppable, + useDraggable, + defaultDropAnimationSideEffects, } from "@dnd-kit/core"; import { cn } from "@/lib/utils"; @@ -48,39 +62,114 @@ interface TerminalStatus { }; } -// Tab component with drop target support +// Tab component with drag-drop support and double-click to rename function TerminalTabButton({ tab, isActive, onClick, onClose, + onRename, isDropTarget, + isDraggingTab, }: { tab: TerminalTab; isActive: boolean; onClick: () => void; onClose: () => void; + onRename: (newName: string) => void; isDropTarget: boolean; + isDraggingTab: boolean; }) { - const { setNodeRef, isOver } = useDroppable({ + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(tab.name); + const inputRef = useRef(null); + + const { setNodeRef: setDropRef, isOver } = useDroppable({ id: `tab-${tab.id}`, data: { type: "tab", tabId: tab.id }, }); + const { + attributes: dragAttributes, + listeners: dragListeners, + setNodeRef: setDragRef, + isDragging, + } = useDraggable({ + id: `drag-tab-${tab.id}`, + data: { type: "drag-tab", tabId: tab.id }, + }); + + // Combine refs + const setRefs = (node: HTMLDivElement | null) => { + setDropRef(node); + setDragRef(node); + }; + + // Focus input when entering edit mode + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const handleDoubleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setEditName(tab.name); + setIsEditing(true); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + finishEditing(); + } else if (e.key === "Escape") { + e.preventDefault(); + setIsEditing(false); + setEditName(tab.name); + } + }; + + const finishEditing = () => { + const trimmedName = editName.trim(); + if (trimmedName && trimmedName !== tab.name) { + onRename(trimmedName); + } + setIsEditing(false); + }; + return (
- {tab.name} + {isEditing ? ( + setEditName(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={finishEditing} + onClick={(e) => e.stopPropagation()} + className="w-20 px-1 py-0 text-sm bg-background border border-border rounded focus:outline-none focus:ring-1 focus:ring-brand-500" + /> + ) : ( + {tab.name} + )} + + {/* Global Terminal Settings */} + + + + + +
+
+

Terminal Settings

+

+ Configure global terminal appearance +

+
+ + {/* Default Font Size */} +
+
+ + {terminalState.defaultFontSize}px +
+ setTerminalDefaultFontSize(value)} + onValueCommit={() => { + toast.info("Font size changed", { + description: "New terminals will use this size", + }); + }} + /> +
+ + {/* Font Family */} +
+ + +
+ + {/* Line Height */} +
+
+ + {terminalState.lineHeight.toFixed(1)} +
+ setTerminalLineHeight(value)} + onValueCommit={() => { + toast.info("Line height changed", { + description: "Restart terminal for changes to take effect", + }); + }} + /> +
+ + {/* Default Run Script */} +
+ + setTerminalDefaultRunScript(e.target.value)} + placeholder="e.g., claude, npm run dev" + className="h-8 text-sm" + /> +

+ Command to run when opening new terminals +

+
+ +
+
+
{/* Active tab content */}
- {activeTab?.layout ? ( + {terminalState.maximizedSessionId ? ( + // When a terminal is maximized, render only that terminal + { + const sessionId = terminalState.maximizedSessionId!; + toggleTerminalMaximized(sessionId); + killTerminal(sessionId); + createTerminal(); + }} + > + setActiveTerminalSession(terminalState.maximizedSessionId!)} + onClose={() => killTerminal(terminalState.maximizedSessionId!)} + onSplitHorizontal={() => createTerminal("horizontal", terminalState.maximizedSessionId!)} + onSplitVertical={() => createTerminal("vertical", terminalState.maximizedSessionId!)} + onNewTab={createTerminalInNewTab} + isDragging={false} + isDropTarget={false} + fontSize={findTerminalFontSize(terminalState.maximizedSessionId)} + onFontSizeChange={(size) => setTerminalPanelFontSize(terminalState.maximizedSessionId!, size)} + isMaximized={true} + onToggleMaximize={() => toggleTerminalMaximized(terminalState.maximizedSessionId!)} + /> + + ) : activeTab?.layout ? ( renderPanelContent(activeTab.layout) ) : (
@@ -702,7 +1377,14 @@ export function TerminalView() {
{/* Drag overlay */} - + {activeDragId ? (
diff --git a/apps/ui/src/components/views/terminal-view/terminal-error-boundary.tsx b/apps/ui/src/components/views/terminal-view/terminal-error-boundary.tsx new file mode 100644 index 00000000..a0fd3d12 --- /dev/null +++ b/apps/ui/src/components/views/terminal-view/terminal-error-boundary.tsx @@ -0,0 +1,94 @@ +import React, { Component, ErrorInfo } from "react"; +import { AlertCircle, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface Props { + children: React.ReactNode; + sessionId: string; + onRestart?: () => void; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +/** + * BUG-06 fix: Error boundary for terminal components + * Catches xterm.js errors (WebGL context loss, canvas errors, etc.) + * and displays a friendly recovery UI instead of crashing the app. + */ +export class TerminalErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("[TerminalErrorBoundary] Terminal crashed:", { + sessionId: this.props.sessionId, + error: error.message, + stack: error.stack, + componentStack: errorInfo.componentStack, + }); + } + + handleRestart = () => { + this.setState({ hasError: false, error: null }); + this.props.onRestart?.(); + }; + + render() { + if (this.state.hasError) { + return ( +
+
+ +
+
+

+ Terminal Crashed +

+

+ {this.state.error?.message?.includes("WebGL") + ? "WebGL context was lost. This can happen with GPU driver issues." + : "An unexpected error occurred in the terminal."} +

+
+ + {this.state.error && ( +
+ + Technical details + +
+                {this.state.error.message}
+              
+
+ )} +
+ ); + } + + return this.props.children; + } +} diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index dbab56e0..2ee46236 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -11,12 +11,30 @@ import { ClipboardPaste, CheckSquare, Trash2, + ImageIcon, + Loader2, + Settings, + RotateCcw, + Search, + ChevronUp, + ChevronDown, + Maximize2, + Minimize2, + ArrowDown, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Slider } from "@/components/ui/slider"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { useDraggable, useDroppable } from "@dnd-kit/core"; import { useAppStore } from "@/store/app-store"; -import { getTerminalTheme } from "@/config/terminal-themes"; +import { useShallow } from "zustand/react/shallow"; +import { getTerminalTheme, TERMINAL_FONT_OPTIONS, DEFAULT_TERMINAL_FONT } from "@/config/terminal-themes"; +import { toast } from "sonner"; +import { getElectronAPI } from "@/lib/electron"; // Font size constraints const MIN_FONT_SIZE = 8; @@ -26,6 +44,15 @@ const DEFAULT_FONT_SIZE = 14; // Resize constraints const RESIZE_DEBOUNCE_MS = 100; // Short debounce for responsive feel +// Image drag-drop constants +const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; +const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB + +// Large paste handling constants +const LARGE_PASTE_WARNING_THRESHOLD = 1024 * 1024; // 1MB - show warning for pastes this size or larger +const PASTE_CHUNK_SIZE = 8 * 1024; // 8KB chunks for large pastes +const PASTE_CHUNK_DELAY_MS = 10; // Small delay between chunks to prevent overwhelming WebSocket + interface TerminalPanelProps { sessionId: string; authToken: string | null; @@ -34,15 +61,22 @@ interface TerminalPanelProps { onClose: () => void; onSplitHorizontal: () => void; onSplitVertical: () => void; + onNewTab?: () => void; isDragging?: boolean; isDropTarget?: boolean; fontSize: number; onFontSizeChange: (size: number) => void; + runCommandOnConnect?: string; // Command to run when terminal first connects (for new terminals) + onCommandRan?: () => void; // Callback when the initial command has been sent + isMaximized?: boolean; + onToggleMaximize?: () => void; } // Type for xterm Terminal - we'll use any since we're dynamically importing type XTerminal = InstanceType; type XFitAddon = InstanceType; +type XSearchAddon = InstanceType; +type XWebLinksAddon = InstanceType; export function TerminalPanel({ sessionId, @@ -52,10 +86,15 @@ export function TerminalPanel({ onClose, onSplitHorizontal, onSplitVertical, + onNewTab, isDragging = false, isDropTarget = false, fontSize, onFontSizeChange, + runCommandOnConnect, + onCommandRan, + isMaximized = false, + onToggleMaximize, }: TerminalPanelProps) { const terminalRef = useRef(null); const containerRef = useRef(null); @@ -63,9 +102,11 @@ export function TerminalPanel({ const fitAddonRef = useRef(null); const wsRef = useRef(null); const reconnectTimeoutRef = useRef(null); + const heartbeatIntervalRef = useRef(null); const lastShortcutTimeRef = useRef(0); const resizeDebounceRef = useRef(null); const focusHandlerRef = useRef<{ dispose: () => void } | null>(null); + const linkProviderRef = useRef<{ dispose: () => void } | null>(null); const [isTerminalReady, setIsTerminalReady] = useState(false); const [shellName, setShellName] = useState("shell"); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); @@ -74,6 +115,48 @@ export function TerminalPanel({ const contextMenuRef = useRef(null); const [focusedMenuIndex, setFocusedMenuIndex] = useState(0); const focusedMenuIndexRef = useRef(0); + const [isImageDragOver, setIsImageDragOver] = useState(false); + const [isProcessingImage, setIsProcessingImage] = useState(false); + const hasRunInitialCommandRef = useRef(false); + const searchAddonRef = useRef(null); + const searchInputRef = useRef(null); + const [showSearch, setShowSearch] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const showSearchRef = useRef(false); + const [isAtBottom, setIsAtBottom] = useState(true); + + const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "reconnecting" | "disconnected" | "auth_failed">("connecting"); + const reconnectAttemptsRef = useRef(0); + const MAX_RECONNECT_ATTEMPTS = 5; + const INITIAL_RECONNECT_DELAY = 1000; + const [processExitCode, setProcessExitCode] = useState(null); + + // Get current project for image saving + const currentProject = useAppStore((state) => state.currentProject); + + // Get terminal settings from store - grouped with shallow comparison to reduce re-renders + const { + defaultRunScript, + screenReaderMode, + fontFamily, + scrollbackLines, + lineHeight, + } = useAppStore( + useShallow((state) => ({ + defaultRunScript: state.terminalState.defaultRunScript, + screenReaderMode: state.terminalState.screenReaderMode, + fontFamily: state.terminalState.fontFamily, + scrollbackLines: state.terminalState.scrollbackLines, + lineHeight: state.terminalState.lineHeight, + })) + ); + + // Action setters are stable references, can use individual selectors + const setTerminalDefaultRunScript = useAppStore((state) => state.setTerminalDefaultRunScript); + const setTerminalScreenReaderMode = useAppStore((state) => state.setTerminalScreenReaderMode); + const setTerminalFontFamily = useAppStore((state) => state.setTerminalFontFamily); + const setTerminalScrollbackLines = useAppStore((state) => state.setTerminalScrollbackLines); + const setTerminalLineHeight = useAppStore((state) => state.setTerminalLineHeight); // Detect platform on mount useEffect(() => { @@ -94,6 +177,30 @@ export function TerminalPanel({ const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme); const effectiveTheme = getEffectiveTheme(); + // Track system dark mode preference for "system" theme + const [systemIsDark, setSystemIsDark] = useState(() => { + if (typeof window !== "undefined") { + return window.matchMedia("(prefers-color-scheme: dark)").matches; + } + return false; + }); + + // Listen for system theme changes + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleChange = (e: MediaQueryListEvent) => { + setSystemIsDark(e.matches); + }; + + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + }, []); + + // Resolve "system" theme to actual light/dark + const resolvedTheme = effectiveTheme === "system" + ? (systemIsDark ? "dark" : "light") + : effectiveTheme; + // Use refs for callbacks and values to avoid effect re-runs const onFocusRef = useRef(onFocus); onFocusRef.current = onFocus; @@ -103,10 +210,12 @@ export function TerminalPanel({ onSplitHorizontalRef.current = onSplitHorizontal; const onSplitVerticalRef = useRef(onSplitVertical); onSplitVerticalRef.current = onSplitVertical; + const onNewTabRef = useRef(onNewTab); + onNewTabRef.current = onNewTab; const fontSizeRef = useRef(fontSize); fontSizeRef.current = fontSize; - const themeRef = useRef(effectiveTheme); - themeRef.current = effectiveTheme; + const themeRef = useRef(resolvedTheme); + themeRef.current = resolvedTheme; const copySelectionRef = useRef<() => Promise>(() => Promise.resolve(false)); const pasteFromClipboardRef = useRef<() => Promise>(() => Promise.resolve()); @@ -123,24 +232,71 @@ export function TerminalPanel({ onFontSizeChange(DEFAULT_FONT_SIZE); }, [onFontSizeChange]); + // Strip ANSI escape codes from text + const stripAnsi = (text: string): string => { + // Match ANSI escape sequences: + // - CSI sequences: \x1b[...letter + // - OSC sequences: \x1b]...ST + // - Other escape sequences: \x1b followed by various characters + // eslint-disable-next-line no-control-regex + return text.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b[>=<]|\x1b[78HM]|\x1b#[0-9]|\x1b./g, ''); + }; + // Copy selected text to clipboard const copySelection = useCallback(async (): Promise => { const terminal = xtermRef.current; if (!terminal) return false; const selection = terminal.getSelection(); - if (!selection) return false; + if (!selection) { + toast.error("Nothing to copy", { + description: "Select some text first", + }); + return false; + } try { - await navigator.clipboard.writeText(selection); + // Strip any ANSI escape codes that might be in the selection + const cleanText = stripAnsi(selection); + await navigator.clipboard.writeText(cleanText); + toast.success("Copied to clipboard"); return true; } catch (err) { console.error("[Terminal] Copy failed:", err); + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + toast.error("Copy failed", { + description: errorMessage.includes("permission") + ? "Clipboard permission denied" + : "Could not access clipboard", + }); return false; } }, []); copySelectionRef.current = copySelection; + // Helper function to send text in chunks with delay + const sendTextInChunks = useCallback(async (text: string) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + // For small pastes, send all at once + if (text.length <= PASTE_CHUNK_SIZE) { + ws.send(JSON.stringify({ type: "input", data: text })); + return; + } + + // For large pastes, chunk it + for (let i = 0; i < text.length; i += PASTE_CHUNK_SIZE) { + if (ws.readyState !== WebSocket.OPEN) break; + const chunk = text.slice(i, i + PASTE_CHUNK_SIZE); + ws.send(JSON.stringify({ type: "input", data: chunk })); + // Small delay between chunks to prevent overwhelming the WebSocket + if (i + PASTE_CHUNK_SIZE < text.length) { + await new Promise(resolve => setTimeout(resolve, PASTE_CHUNK_DELAY_MS)); + } + } + }, []); + // Paste from clipboard const pasteFromClipboard = useCallback(async () => { const terminal = xtermRef.current; @@ -148,13 +304,38 @@ export function TerminalPanel({ try { const text = await navigator.clipboard.readText(); - if (text && wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ type: "input", data: text })); + if (!text) { + toast.error("Nothing to paste", { + description: "Clipboard is empty", + }); + return; } + + if (wsRef.current.readyState !== WebSocket.OPEN) { + toast.error("Terminal not connected"); + return; + } + + // Warn for large pastes + if (text.length >= LARGE_PASTE_WARNING_THRESHOLD) { + const sizeMB = (text.length / (1024 * 1024)).toFixed(1); + toast.warning(`Large paste (${sizeMB}MB)`, { + description: "Sending in chunks, this may take a moment...", + duration: 3000, + }); + } + + await sendTextInChunks(text); } catch (err) { console.error("[Terminal] Paste failed:", err); + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + toast.error("Paste failed", { + description: errorMessage.includes("permission") + ? "Clipboard permission denied" + : "Could not read from clipboard", + }); } - }, []); + }, [sendTextInChunks]); pasteFromClipboardRef.current = pasteFromClipboard; // Select all terminal content @@ -167,6 +348,35 @@ export function TerminalPanel({ xtermRef.current?.clear(); }, []); + // Search functions + const searchNext = useCallback(() => { + if (searchAddonRef.current && searchQuery) { + searchAddonRef.current.findNext(searchQuery, { caseSensitive: false, regex: false }); + } + }, [searchQuery]); + + const searchPrevious = useCallback(() => { + if (searchAddonRef.current && searchQuery) { + searchAddonRef.current.findPrevious(searchQuery, { caseSensitive: false, regex: false }); + } + }, [searchQuery]); + + const closeSearch = useCallback(() => { + setShowSearch(false); + showSearchRef.current = false; + setSearchQuery(""); + searchAddonRef.current?.clearDecorations(); + xtermRef.current?.focus(); + }, []); + + // Scroll to bottom of terminal + const scrollToBottom = useCallback(() => { + if (xtermRef.current) { + xtermRef.current.scrollToBottom(); + setIsAtBottom(true); + } + }, []); + // Close context menu const closeContextMenu = useCallback(() => { setContextMenu(null); @@ -224,10 +434,14 @@ export function TerminalPanel({ { Terminal }, { FitAddon }, { WebglAddon }, + { SearchAddon }, + { WebLinksAddon }, ] = await Promise.all([ import("@xterm/xterm"), import("@xterm/addon-fit"), import("@xterm/addon-webgl"), + import("@xterm/addon-search"), + import("@xterm/addon-web-links"), ]); // Also import CSS @@ -238,23 +452,149 @@ export function TerminalPanel({ // Get terminal theme matching the app theme const terminalTheme = getTerminalTheme(themeRef.current); + // Get settings from store (read at initialization time) + const terminalSettings = useAppStore.getState().terminalState; + const screenReaderEnabled = terminalSettings.screenReaderMode; + const terminalFontFamily = terminalSettings.fontFamily || DEFAULT_TERMINAL_FONT; + const terminalScrollback = terminalSettings.scrollbackLines || 5000; + const terminalLineHeight = terminalSettings.lineHeight || 1.0; + // Create terminal instance with the current global font size and theme const terminal = new Terminal({ cursorBlink: true, cursorStyle: "block", fontSize: fontSizeRef.current, - fontFamily: "Menlo, Monaco, 'Courier New', monospace", + fontFamily: terminalFontFamily, + lineHeight: terminalLineHeight, + letterSpacing: 0, theme: terminalTheme, allowProposedApi: true, + screenReaderMode: screenReaderEnabled, + scrollback: terminalScrollback, }); // Create fit addon const fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); + // Create search addon + const searchAddon = new SearchAddon(); + terminal.loadAddon(searchAddon); + searchAddonRef.current = searchAddon; + + // Create web links addon for clickable URLs + const webLinksAddon = new WebLinksAddon(); + terminal.loadAddon(webLinksAddon); + // Open terminal terminal.open(terminalRef.current); + // Register custom link provider for file paths + // Detects patterns like /path/to/file.ts:123:45 or ./src/file.js:10 + const filePathLinkProvider = { + provideLinks: (lineNumber: number, callback: (links: { range: { start: { x: number; y: number }; end: { x: number; y: number } }; text: string; activate: (event: MouseEvent, text: string) => void }[] | undefined) => void) => { + const line = terminal.buffer.active.getLine(lineNumber - 1); + if (!line) { + callback(undefined); + return; + } + + const lineText = line.translateToString(true); + const links: { range: { start: { x: number; y: number }; end: { x: number; y: number } }; text: string; activate: (event: MouseEvent, text: string) => void }[] = []; + + // File path patterns: + // 1. Absolute Unix: /path/to/file.ext:line:col or /path/to/file.ext:line + // 2. Home directory: ~/path/to/file.ext:line:col + // 3. Absolute Windows: C:\path\to\file.ext:line:col (less common in terminal output) + // 4. Relative: ./path/file.ext:line or src/file.ext:line + // Common formats from compilers/linters: + // - ESLint: /path/file.ts:10:5 + // - TypeScript: src/file.ts(10,5) + // - Go: /path/file.go:10:5 + const filePathRegex = /(?:^|[\s'"(])(((?:\/|\.\/|\.\.\/|~\/)[^\s:'"()]+|[a-zA-Z]:\\[^\s:'"()]+|[a-zA-Z0-9_-]+\/[^\s:'"()]+)(?:[:(\s](\d+)(?:[:,)](\d+))?)?)/g; + + let match; + while ((match = filePathRegex.exec(lineText)) !== null) { + const fullMatch = match[1]; + const filePath = match[2]; + const lineNum = match[3] ? parseInt(match[3], 10) : undefined; + const colNum = match[4] ? parseInt(match[4], 10) : undefined; + + // Skip common false positives (URLs, etc.) + if (filePath.startsWith('http://') || filePath.startsWith('https://') || filePath.startsWith('ws://')) { + continue; + } + + // Calculate the start position (1-indexed for xterm) + const startX = match.index + (match[0].length - match[1].length) + 1; + const endX = startX + fullMatch.length; + + links.push({ + range: { + start: { x: startX, y: lineNumber }, + end: { x: endX, y: lineNumber }, + }, + text: fullMatch, + activate: async (event: MouseEvent, text: string) => { + // Parse the path and line/column from the matched text + const pathMatch = text.match(/^([^\s:()]+)(?:[:(\s](\d+)(?:[:,)](\d+))?)?/); + if (!pathMatch) return; + + const clickedPath = pathMatch[1]; + const clickedLine = pathMatch[2] ? parseInt(pathMatch[2], 10) : undefined; + const clickedCol = pathMatch[3] ? parseInt(pathMatch[3], 10) : undefined; + + // Resolve paths to absolute paths + let absolutePath = clickedPath; + const api = getElectronAPI(); + + if (clickedPath.startsWith('~/')) { + // Home directory path - expand ~ to user's home directory + try { + const homePath = await api.getPath?.('home'); + if (homePath) { + absolutePath = homePath + clickedPath.slice(1); // Replace ~ with home path + } + } catch { + // If we can't get home path, just use the path as-is + console.warn("[Terminal] Could not resolve home directory path"); + } + } else if (!clickedPath.startsWith('/') && !clickedPath.match(/^[a-zA-Z]:\\/)) { + // Relative path - resolve against project path + const projectPath = useAppStore.getState().currentProject?.path; + if (projectPath) { + absolutePath = `${projectPath}/${clickedPath}`.replace(/\/+/g, '/'); + } else { + toast.warning("Cannot open relative path", { + description: "No project selected. Open a project to click relative file paths.", + }); + return; + } + } + + // Open in editor using VS Code URL scheme + // Works in both web (via anchor click) and Electron (via shell.openExternal) + try { + const result = await api.openInEditor?.(absolutePath, clickedLine, clickedCol); + if (result && !result.success) { + toast.error("Failed to open in editor", { description: result.error }); + } + } catch (error) { + console.error("[Terminal] Failed to open file:", error); + toast.error("Failed to open file", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }, + }); + } + + callback(links.length > 0 ? links : undefined); + }, + }; + + linkProviderRef.current = terminal.registerLinkProvider(filePathLinkProvider); + // Try to load WebGL addon for better performance try { const webglAddon = new WebglAddon(); @@ -267,9 +607,9 @@ export function TerminalPanel({ } // Fit terminal to container - wait for stable dimensions - // Use multiple RAFs to let react-resizable-panels finish layout + // Use initial delay then multiple RAFs to let react-resizable-panels finish layout let fitAttempts = 0; - const MAX_FIT_ATTEMPTS = 5; + const MAX_FIT_ATTEMPTS = 10; let lastWidth = 0; let lastHeight = 0; @@ -300,7 +640,8 @@ export function TerminalPanel({ requestAnimationFrame(attemptFit); }; - requestAnimationFrame(attemptFit); + // Initial delay allows complex layouts to settle before attempting fit + setTimeout(() => requestAnimationFrame(attemptFit), 50); xtermRef.current = terminal; fitAddonRef.current = fitAddon; @@ -327,8 +668,8 @@ export function TerminalPanel({ // Use event.code for keyboard-layout-independent key detection const code = event.code; - // Alt+D - Split right - if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyD') { + // Ctrl+Shift+D - Split right (uses Ctrl+Shift to avoid Alt+D readline conflict) + if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && code === 'KeyD') { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -337,8 +678,8 @@ export function TerminalPanel({ return false; } - // Alt+S - Split down - if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyS') { + // Ctrl+Shift+S - Split down (uses Ctrl+Shift to avoid readline conflicts) + if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && code === 'KeyS') { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -347,8 +688,8 @@ export function TerminalPanel({ return false; } - // Alt+W - Close terminal - if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyW') { + // Ctrl+Shift+W - Close terminal (uses Ctrl+Shift to avoid readline conflicts) + if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && code === 'KeyW') { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -357,6 +698,16 @@ export function TerminalPanel({ return false; } + // Ctrl+Shift+T - New terminal tab (uses Ctrl+Shift for consistency) + if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && code === 'KeyT') { + event.preventDefault(); + if (canTrigger && onNewTabRef.current) { + lastShortcutTimeRef.current = now; + onNewTabRef.current(); + } + return false; + } + const modKey = isMacRef.current ? event.metaKey : event.ctrlKey; const otherModKey = isMacRef.current ? event.ctrlKey : event.metaKey; @@ -394,6 +745,14 @@ export function TerminalPanel({ return false; } + // Ctrl+Shift+F / Cmd+Shift+F - Toggle search + if (modKey && !otherModKey && event.shiftKey && !event.altKey && code === 'KeyF') { + event.preventDefault(); + showSearchRef.current = !showSearchRef.current; + setShowSearch(showSearchRef.current); + return false; + } + // Let xterm handle all other keys return true; }); @@ -411,6 +770,12 @@ export function TerminalPanel({ focusHandlerRef.current = null; } + // Dispose link provider to prevent memory leak + if (linkProviderRef.current) { + linkProviderRef.current.dispose(); + linkProviderRef.current = null; + } + // Clear resize debounce timer if (resizeDebounceRef.current) { clearTimeout(resizeDebounceRef.current); @@ -422,6 +787,7 @@ export function TerminalPanel({ xtermRef.current = null; } fitAddonRef.current = null; + searchAddonRef.current = null; setIsTerminalReady(false); }; }, []); // No dependencies - only run once on mount @@ -444,6 +810,19 @@ export function TerminalPanel({ ws.onopen = () => { console.log(`[Terminal] WebSocket connected for session ${sessionId}`); + + setConnectionStatus("connected"); + reconnectAttemptsRef.current = 0; + + // Start heartbeat to keep connection alive (prevents proxy/load balancer timeouts) + if (heartbeatIntervalRef.current) { + clearInterval(heartbeatIntervalRef.current); + } + heartbeatIntervalRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "ping" })); + } + }, 30000); // Ping every 30 seconds }; ws.onmessage = (event) => { @@ -460,6 +839,8 @@ export function TerminalPanel({ // Use reset() which is more reliable than clear() or escape sequences terminal.reset(); terminal.write(msg.data); + // Mark as already initialized - don't run initial command for restored sessions + hasRunInitialCommandRef.current = true; } break; case "connected": @@ -469,9 +850,26 @@ export function TerminalPanel({ const name = msg.shell.split("/").pop() || msg.shell; setShellName(name); } + // Run initial command if specified and not already run + // Only run for new terminals (no scrollback received) + if ( + runCommandOnConnect && + !hasRunInitialCommandRef.current && + ws.readyState === WebSocket.OPEN + ) { + hasRunInitialCommandRef.current = true; + // Small delay to let the shell prompt appear + setTimeout(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "input", data: runCommandOnConnect + "\n" })); + onCommandRan?.(); + } + }, 100); + } break; case "exit": terminal.write(`\r\n\x1b[33m[Process exited with code ${msg.exitCode}]\x1b[0m\r\n`); + setProcessExitCode(msg.exitCode); break; case "pong": // Heartbeat response @@ -486,18 +884,66 @@ export function TerminalPanel({ console.log(`[Terminal] WebSocket closed for session ${sessionId}:`, event.code, event.reason); wsRef.current = null; - // Don't reconnect if closed normally or auth failed - if (event.code === 1000 || event.code === 4001 || event.code === 4003) { + // Clear heartbeat interval + if (heartbeatIntervalRef.current) { + clearInterval(heartbeatIntervalRef.current); + heartbeatIntervalRef.current = null; + } + + if (event.code === 4001) { + setConnectionStatus("auth_failed"); + toast.error("Terminal authentication expired", { + description: "Please unlock the terminal again to reconnect.", + duration: 5000, + }); return; } - // Attempt reconnect after a delay + // Don't reconnect if closed normally + if (event.code === 1000 || event.code === 4003) { + setConnectionStatus("disconnected"); + return; + } + + if (event.code === 4004) { + setConnectionStatus("disconnected"); + toast.error("Terminal session not found", { + description: "The session may have expired. Please create a new terminal.", + duration: 5000, + }); + return; + } + + reconnectAttemptsRef.current++; + + if (reconnectAttemptsRef.current > MAX_RECONNECT_ATTEMPTS) { + setConnectionStatus("disconnected"); + toast.error("Terminal disconnected", { + description: "Maximum reconnection attempts reached. Click to retry.", + action: { + label: "Retry", + onClick: () => { + reconnectAttemptsRef.current = 0; + setConnectionStatus("reconnecting"); + connect(); + }, + }, + duration: 10000, + }); + return; + } + + // Exponential backoff: 1s, 2s, 4s, 8s, 16s + const delay = INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttemptsRef.current - 1); + setConnectionStatus("reconnecting"); + + // Attempt reconnect after exponential delay reconnectTimeoutRef.current = setTimeout(() => { if (xtermRef.current) { - console.log(`[Terminal] Attempting reconnect for session ${sessionId}`); + console.log(`[Terminal] Attempting reconnect for session ${sessionId} (attempt ${reconnectAttemptsRef.current}/${MAX_RECONNECT_ATTEMPTS})`); connect(); } - }, 2000); + }, delay); }; ws.onerror = (error) => { @@ -517,6 +963,10 @@ export function TerminalPanel({ // Cleanup return () => { dataHandler.dispose(); + if (heartbeatIntervalRef.current) { + clearInterval(heartbeatIntervalRef.current); + heartbeatIntervalRef.current = null; + } if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); } @@ -580,12 +1030,69 @@ export function TerminalPanel({ }; }, [handleResize]); + useEffect(() => { + if (xtermRef.current && isTerminalReady) { + xtermRef.current.options.fontSize = fontSize; + fitAddonRef.current?.fit(); + } + }, [fontSize, isTerminalReady]); + + useEffect(() => { + if (xtermRef.current && isTerminalReady) { + xtermRef.current.options.fontFamily = fontFamily; + fitAddonRef.current?.fit(); + } + }, [fontFamily, isTerminalReady]); + + useEffect(() => { + if (xtermRef.current && isTerminalReady) { + xtermRef.current.options.lineHeight = lineHeight; + fitAddonRef.current?.fit(); + } + }, [lineHeight, isTerminalReady]); + // Focus terminal when becoming active or when terminal becomes ready useEffect(() => { - if (isActive && isTerminalReady && xtermRef.current) { + if (isActive && isTerminalReady && xtermRef.current && !showSearch) { xtermRef.current.focus(); } - }, [isActive, isTerminalReady]); + }, [isActive, isTerminalReady, showSearch]); + + // Focus search input when search bar opens + useEffect(() => { + if (showSearch && searchInputRef.current) { + searchInputRef.current.focus(); + searchInputRef.current.select(); + } + }, [showSearch]); + + // Monitor scroll position to show/hide "Jump to bottom" button + useEffect(() => { + if (!isTerminalReady || !terminalRef.current) return; + + // xterm creates a viewport element with class .xterm-viewport + const viewport = terminalRef.current.querySelector('.xterm-viewport') as HTMLElement | null; + if (!viewport) return; + + const checkScrollPosition = () => { + // Check if scrolled to bottom (with small tolerance for rounding) + const scrollTop = viewport.scrollTop; + const scrollHeight = viewport.scrollHeight; + const clientHeight = viewport.clientHeight; + const isBottom = scrollHeight - scrollTop - clientHeight <= 5; + setIsAtBottom(isBottom); + }; + + // Initial check + checkScrollPosition(); + + // Listen for scroll events + viewport.addEventListener('scroll', checkScrollPosition, { passive: true }); + + return () => { + viewport.removeEventListener('scroll', checkScrollPosition); + }; + }, [isTerminalReady]); // Update terminal font size when it changes useEffect(() => { @@ -607,13 +1114,13 @@ export function TerminalPanel({ } }, [fontSize, isTerminalReady]); - // Update terminal theme when app theme changes + // Update terminal theme when app theme changes (including system preference) useEffect(() => { if (xtermRef.current && isTerminalReady) { - const terminalTheme = getTerminalTheme(effectiveTheme); + const terminalTheme = getTerminalTheme(resolvedTheme); xtermRef.current.options.theme = terminalTheme; } - }, [effectiveTheme, isTerminalReady]); + }, [resolvedTheme, isTerminalReady]); // Handle keyboard shortcuts for zoom (Ctrl+Plus, Ctrl+Minus, Ctrl+0) useEffect(() => { @@ -624,24 +1131,24 @@ export function TerminalPanel({ // Only handle if Ctrl (or Cmd on Mac) is pressed if (!e.ctrlKey && !e.metaKey) return; - // Ctrl/Cmd + Plus or Ctrl/Cmd + = (for keyboards without numpad) - if (e.key === "+" || e.key === "=") { + // Ctrl/Cmd + Plus (Equal key or NumpadAdd for international keyboard support) + if (e.code === "Equal" || e.code === "NumpadAdd") { e.preventDefault(); e.stopPropagation(); zoomIn(); return; } - // Ctrl/Cmd + Minus - if (e.key === "-") { + // Ctrl/Cmd + Minus (Minus key or NumpadSubtract) + if (e.code === "Minus" || e.code === "NumpadSubtract") { e.preventDefault(); e.stopPropagation(); zoomOut(); return; } - // Ctrl/Cmd + 0 to reset - if (e.key === "0") { + // Ctrl/Cmd + 0 to reset (Digit0 or Numpad0) + if (e.code === "Digit0" || e.code === "Numpad0") { e.preventDefault(); e.stopPropagation(); resetZoom(); @@ -710,23 +1217,29 @@ export function TerminalPanel({ switch (e.key) { case "Escape": e.preventDefault(); + e.stopPropagation(); closeContextMenu(); + xtermRef.current?.focus(); break; case "ArrowDown": e.preventDefault(); + e.stopPropagation(); updateFocusIndex((focusedMenuIndexRef.current + 1) % menuActions.length); break; case "ArrowUp": e.preventDefault(); + e.stopPropagation(); updateFocusIndex((focusedMenuIndexRef.current - 1 + menuActions.length) % menuActions.length); break; case "Enter": case " ": e.preventDefault(); + e.stopPropagation(); handleContextMenuAction(menuActions[focusedMenuIndexRef.current]); break; case "Tab": e.preventDefault(); + e.stopPropagation(); closeContextMenu(); break; } @@ -781,14 +1294,171 @@ export function TerminalPanel({ setContextMenu({ x, y }); }, []); + // Convert file to base64 + const fileToBase64 = useCallback((file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === 'string') { + resolve(reader.result); + } else { + reject(new Error('Failed to read file as base64')); + } + }; + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsDataURL(file); + }); + }, []); + + // Save image to temp folder via Electron API + const saveImageToTemp = useCallback(async ( + base64Data: string, + filename: string, + mimeType: string + ): Promise => { + try { + const api = getElectronAPI(); + if (!api.saveImageToTemp) { + // Fallback path when Electron API is not available (browser mode) + console.warn('[Terminal] saveImageToTemp not available, returning fallback path'); + return `.automaker/images/${Date.now()}_${filename}`; + } + + const projectPath = currentProject?.path; + const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath); + if (result.success && result.path) { + return result.path; + } + console.error('[Terminal] Failed to save image:', result.error); + return null; + } catch (error) { + console.error('[Terminal] Error saving image:', error); + return null; + } + }, [currentProject?.path]); + + // Check if drag event contains image files + const hasImageFiles = useCallback((e: React.DragEvent): boolean => { + const types = e.dataTransfer.types; + const items = e.dataTransfer.items; + + // Check if Files type is present + if (!types.includes('Files')) return false; + + // Check if any item is an image + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === 'file' && ACCEPTED_IMAGE_TYPES.includes(item.type)) { + return true; + } + } + return false; + }, []); + + // Handle image drag over terminal + const handleImageDragOver = useCallback((e: React.DragEvent) => { + // Only handle if contains image files + if (!hasImageFiles(e)) return; + + e.preventDefault(); + e.stopPropagation(); + setIsImageDragOver(true); + }, [hasImageFiles]); + + // Handle image drag leave + const handleImageDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Only reset if leaving the actual container (not just moving to a child) + const relatedTarget = e.relatedTarget as HTMLElement | null; + if (relatedTarget && terminalRef.current?.contains(relatedTarget)) { + return; + } + + setIsImageDragOver(false); + }, []); + + // Handle image drop on terminal + const handleImageDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsImageDragOver(false); + + if (isProcessingImage) return; + + const files = e.dataTransfer.files; + if (!files.length) return; + + // Filter to only image files + const imageFiles: File[] = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (ACCEPTED_IMAGE_TYPES.includes(file.type)) { + if (file.size > MAX_IMAGE_SIZE) { + toast.error(`Image too large: ${file.name}`, { + description: 'Maximum size is 10MB', + }); + continue; + } + imageFiles.push(file); + } + } + + if (imageFiles.length === 0) { + toast.error('No valid images found', { + description: 'Drop PNG, JPG, GIF, or WebP images', + }); + return; + } + + setIsProcessingImage(true); + const savedPaths: string[] = []; + + for (const file of imageFiles) { + try { + const base64 = await fileToBase64(file); + const savedPath = await saveImageToTemp(base64, file.name, file.type); + if (savedPath) { + savedPaths.push(savedPath); + } else { + toast.error(`Failed to save: ${file.name}`); + } + } catch (error) { + console.error('[Terminal] Error processing image:', error); + toast.error(`Error processing: ${file.name}`); + } + } + + setIsProcessingImage(false); + + if (savedPaths.length === 0) return; + + // Send image paths to terminal as input + // Format: space-separated paths, each wrapped in quotes if containing spaces + const formattedPaths = savedPaths + .map(p => p.includes(' ') ? `"${p}"` : p) + .join(' '); + + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'input', data: formattedPaths })); + toast.success( + savedPaths.length === 1 ? 'Image path inserted' : `${savedPaths.length} image paths inserted`, + { description: 'Press Enter to send' } + ); + } else { + toast.error('Terminal not connected'); + } + }, [isProcessingImage, fileToBase64, saveImageToTemp]); + // Combine refs for the container const setRefs = useCallback((node: HTMLDivElement | null) => { containerRef.current = node; setDropRef(node); }, [setDropRef]); - // Get current terminal theme for xterm styling - const currentTerminalTheme = getTerminalTheme(effectiveTheme); + // Get current terminal theme for xterm styling (resolved for system preference) + const currentTerminalTheme = getTerminalTheme(resolvedTheme); return (
)} + {/* Image drop overlay */} + {isImageDragOver && ( +
+
+ {isProcessingImage ? ( + <> + + Processing... + + ) : ( + <> + + Drop image for Claude Code + + )} +
+
+ )} + {/* Header bar with drag handle - uses app theme CSS variables */}
{/* Drag handle */} @@ -849,6 +1538,32 @@ export function TerminalPanel({ {fontSize}px )} + {connectionStatus === "reconnecting" && ( + + + Reconnecting... + + )} + {connectionStatus === "disconnected" && ( + + Disconnected + + )} + {connectionStatus === "auth_failed" && ( + + Auth Failed + + )} + {processExitCode !== null && ( + + Exited ({processExitCode}) + + )}
{/* Zoom and action buttons */} @@ -881,6 +1596,156 @@ export function TerminalPanel({ + {/* Settings popover */} + + + + + e.stopPropagation()} + > +
+
+
+ + {fontSize}px +
+
+ onFontSizeChange(value)} + className="flex-1" + /> + +
+
+ +
+ + setTerminalDefaultRunScript(e.target.value)} + placeholder="e.g., claude" + className="h-7 text-xs" + /> +

+ Command to run when creating a new terminal +

+
+ +
+ + +
+ +
+
+ + {(scrollbackLines / 1000).toFixed(0)}k lines +
+ { + setTerminalScrollbackLines(value); + }} + onValueCommit={() => { + toast.info("Scrollback changed", { + description: "Restart terminal for changes to take effect", + }); + }} + className="flex-1" + /> +
+ +
+
+ + {lineHeight.toFixed(1)} +
+ { + setTerminalLineHeight(value); + }} + onValueCommit={() => { + toast.info("Line height changed", { + description: "Restart terminal for changes to take effect", + }); + }} + className="flex-1" + /> +
+ +
+
+ +

+ Enable accessibility mode +

+
+ { + setTerminalScreenReaderMode(checked); + toast.info(checked ? "Screen reader enabled" : "Screen reader disabled", { + description: "Restart terminal for changes to take effect", + }); + }} + /> +
+ +
+

Zoom: Ctrl++ / Ctrl+- / Ctrl+0

+

Or use Ctrl+scroll wheel

+
+
+
+
+
{/* Split/close buttons */} @@ -908,6 +1773,24 @@ export function TerminalPanel({ > + {onToggleMaximize && ( + + )}
+ {/* Search bar */} + {showSearch && ( +
+ + { + setSearchQuery(e.target.value); + // Auto-search as user types + if (searchAddonRef.current && e.target.value) { + searchAddonRef.current.findNext(e.target.value, { caseSensitive: false, regex: false }); + } else if (searchAddonRef.current) { + searchAddonRef.current.clearDecorations(); + } + }} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + if (e.shiftKey) { + searchPrevious(); + } else { + searchNext(); + } + } else if (e.key === "Escape") { + e.preventDefault(); + closeSearch(); + } + }} + placeholder="Search..." + className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none min-w-0" + /> + + + +
+ )} + {/* Terminal container - uses terminal theme */}
+ {/* Jump to bottom button - shown when scrolled up */} + {!isAtBottom && ( + + )} + {/* Context menu */} {contextMenu && (
Promise; trashItem?: (filePath: string) => Promise; getPath: (name: string) => Promise; + openInEditor?: ( + filePath: string, + line?: number, + column?: number + ) => Promise<{ success: boolean; error?: string }>; saveImageToTemp?: ( data: string, filename: string, diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 5814fa08..037aea13 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -217,6 +217,44 @@ export class HttpApiClient implements ElectronAPI { return { success: true }; } + async openInEditor( + filePath: string, + line?: number, + column?: number + ): Promise<{ success: boolean; error?: string }> { + // Build VS Code URL scheme: vscode://file/path:line:column + // This works on systems where VS Code's URL handler is registered + // URL encode the path to handle special characters (spaces, brackets, etc.) + // but preserve the leading slash for absolute paths + const encodedPath = filePath.startsWith('/') + ? '/' + filePath.slice(1).split('/').map(encodeURIComponent).join('/') + : filePath.split('/').map(encodeURIComponent).join('/'); + let url = `vscode://file${encodedPath}`; + if (line !== undefined && line > 0) { + url += `:${line}`; + if (column !== undefined && column > 0) { + url += `:${column}`; + } + } + + try { + // Use anchor click approach which is most reliable for custom URL schemes + // This triggers the browser's URL handler without navigation issues + const anchor = document.createElement("a"); + anchor.href = url; + anchor.style.display = "none"; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Failed to open in editor", + }; + } + } + // File picker - uses server-side file browser dialog async openDirectory(): Promise { const fileBrowser = getGlobalFileBrowser(); diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index 4d84ffb7..e650f686 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -411,6 +411,29 @@ ipcMain.handle("shell:openPath", async (_, filePath: string) => { } }); +// Open file in editor (VS Code, etc.) with optional line/column +ipcMain.handle("shell:openInEditor", async (_, filePath: string, line?: number, column?: number) => { + try { + // Build VS Code URL scheme: vscode://file/path:line:column + // This works on all platforms where VS Code is installed + // URL encode the path to handle special characters (spaces, brackets, etc.) + const encodedPath = filePath.startsWith('/') + ? '/' + filePath.slice(1).split('/').map(encodeURIComponent).join('/') + : filePath.split('/').map(encodeURIComponent).join('/'); + let url = `vscode://file${encodedPath}`; + if (line !== undefined && line > 0) { + url += `:${line}`; + if (column !== undefined && column > 0) { + url += `:${column}`; + } + } + await shell.openExternal(url); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } +}); + // App info ipcMain.handle("app:getPath", async (_, name: Parameters[0]) => { return app.getPath(name); diff --git a/apps/ui/src/preload.ts b/apps/ui/src/preload.ts index da755111..79fa0d49 100644 --- a/apps/ui/src/preload.ts +++ b/apps/ui/src/preload.ts @@ -32,6 +32,8 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("shell:openExternal", url), openPath: (filePath: string): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke("shell:openPath", filePath), + openInEditor: (filePath: string, line?: number, column?: number): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke("shell:openInEditor", filePath, line, column), // App info getPath: (name: string): Promise => ipcRenderer.invoke("app:getPath", name), diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index c144dd78..055af47d 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -36,6 +36,10 @@ function RootLayoutContent() { if (role === "textbox" || role === "searchbox" || role === "combobox") { return; } + // Don't intercept when focused inside a terminal + if (activeElement.closest(".xterm") || activeElement.closest("[data-terminal-container]")) { + return; + } } if (event.ctrlKey || event.altKey || event.metaKey) { diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index ee128598..aa1f63f0 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -176,6 +176,7 @@ export interface KeyboardShortcuts { splitTerminalRight: string; splitTerminalDown: string; closeTerminal: string; + newTerminalTab: string; } // Default keyboard shortcuts @@ -210,6 +211,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { splitTerminalRight: "Alt+D", splitTerminalDown: "Alt+S", closeTerminal: "Alt+W", + newTerminalTab: "Alt+T", }; export interface ImageAttachment { @@ -356,6 +358,7 @@ export type TerminalPanelContent = | { type: "terminal"; sessionId: string; size?: number; fontSize?: number } | { type: "split"; + id: string; // Stable ID for React key stability direction: "horizontal" | "vertical"; panels: TerminalPanelContent[]; size?: number; @@ -374,7 +377,57 @@ export interface TerminalState { tabs: TerminalTab[]; activeTabId: string | null; activeSessionId: string | null; + maximizedSessionId: string | null; // Session ID of the maximized terminal pane (null if none) defaultFontSize: number; // Default font size for new terminals + defaultRunScript: string; // Script to run when a new terminal is created (e.g., "claude" to start Claude Code) + screenReaderMode: boolean; // Enable screen reader accessibility mode + fontFamily: string; // Font family for terminal text + scrollbackLines: number; // Number of lines to keep in scrollback buffer + lineHeight: number; // Line height multiplier for terminal text + maxSessions: number; // Maximum concurrent terminal sessions (server setting) +} + +// Persisted terminal layout - now includes sessionIds for reconnection +// Used to restore terminal layout structure when switching projects +export type PersistedTerminalPanel = + | { type: "terminal"; size?: number; fontSize?: number; sessionId?: string } + | { + type: "split"; + id?: string; // Optional for backwards compatibility with older persisted layouts + direction: "horizontal" | "vertical"; + panels: PersistedTerminalPanel[]; + size?: number; + }; + +// Helper to generate unique split IDs +const generateSplitId = () => `split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + +export interface PersistedTerminalTab { + id: string; + name: string; + layout: PersistedTerminalPanel | null; +} + +export interface PersistedTerminalState { + tabs: PersistedTerminalTab[]; + activeTabIndex: number; // Use index instead of ID since IDs are regenerated + defaultFontSize: number; + defaultRunScript?: string; // Optional to support existing persisted data + screenReaderMode?: boolean; // Optional to support existing persisted data + fontFamily?: string; // Optional to support existing persisted data + scrollbackLines?: number; // Optional to support existing persisted data + lineHeight?: number; // Optional to support existing persisted data +} + +// Persisted terminal settings - stored globally (not per-project) +export interface PersistedTerminalSettings { + defaultFontSize: number; + defaultRunScript: string; + screenReaderMode: boolean; + fontFamily: string; + scrollbackLines: number; + lineHeight: number; + maxSessions: number; } export interface AppState { @@ -491,6 +544,10 @@ export interface AppState { // Terminal state terminalState: TerminalState; + // Terminal layout persistence (per-project, keyed by project path) + // Stores the tab/split structure so it can be restored when switching projects + terminalLayoutByProject: Record; + // Spec Creation State (per-project, keyed by project path) // Tracks which project is currently having its spec generated specCreatingForProject: string | null; @@ -720,6 +777,7 @@ export interface AppActions { // Terminal actions setTerminalUnlocked: (unlocked: boolean, token?: string) => void; setActiveTerminalSession: (sessionId: string | null) => void; + toggleTerminalMaximized: (sessionId: string) => void; addTerminalToLayout: ( sessionId: string, direction?: "horizontal" | "vertical", @@ -729,16 +787,37 @@ export interface AppActions { swapTerminals: (sessionId1: string, sessionId2: string) => void; clearTerminalState: () => void; setTerminalPanelFontSize: (sessionId: string, fontSize: number) => void; + setTerminalDefaultFontSize: (fontSize: number) => void; + setTerminalDefaultRunScript: (script: string) => void; + setTerminalScreenReaderMode: (enabled: boolean) => void; + setTerminalFontFamily: (fontFamily: string) => void; + setTerminalScrollbackLines: (lines: number) => void; + setTerminalLineHeight: (lineHeight: number) => void; + setTerminalMaxSessions: (maxSessions: number) => void; addTerminalTab: (name?: string) => string; removeTerminalTab: (tabId: string) => void; setActiveTerminalTab: (tabId: string) => void; renameTerminalTab: (tabId: string, name: string) => void; + reorderTerminalTabs: (fromTabId: string, toTabId: string) => void; moveTerminalToTab: (sessionId: string, targetTabId: string | "new") => void; addTerminalToTab: ( sessionId: string, tabId: string, direction?: "horizontal" | "vertical" ) => void; + setTerminalTabLayout: ( + tabId: string, + layout: TerminalPanelContent, + activeSessionId?: string + ) => void; + updateTerminalPanelSizes: ( + tabId: string, + panelKeys: string[], + sizes: number[] + ) => void; + saveTerminalLayout: (projectPath: string) => void; + getPersistedTerminalLayout: (projectPath: string) => PersistedTerminalState | null; + clearPersistedTerminalLayout: (projectPath: string) => void; // Spec Creation actions setSpecCreatingForProject: (projectPath: string | null) => void; @@ -841,8 +920,16 @@ const initialState: AppState = { tabs: [], activeTabId: null, activeSessionId: null, + maximizedSessionId: null, defaultFontSize: 14, + defaultRunScript: "", + screenReaderMode: false, + fontFamily: "Menlo, Monaco, 'Courier New', monospace", + scrollbackLines: 5000, + lineHeight: 1.0, + maxSessions: 100, }, + terminalLayoutByProject: {}, specCreatingForProject: null, defaultPlanningMode: 'skip' as PlanningMode, defaultRequirePlanApproval: false, @@ -1732,6 +1819,19 @@ export const useAppStore = create()( }); }, + toggleTerminalMaximized: (sessionId) => { + const current = get().terminalState; + const newMaximized = current.maximizedSessionId === sessionId ? null : sessionId; + set({ + terminalState: { + ...current, + maximizedSessionId: newMaximized, + // Also set as active when maximizing + activeSessionId: newMaximized ?? current.activeSessionId, + }, + }); + }, + addTerminalToLayout: ( sessionId, direction = "horizontal", @@ -1781,6 +1881,7 @@ export const useAppStore = create()( // Found the target - split it return { type: "split", + id: generateSplitId(), direction: targetDirection, panels: [{ ...node, size: 50 }, newTerminal], }; @@ -1805,6 +1906,7 @@ export const useAppStore = create()( if (node.type === "terminal") { return { type: "split", + id: generateSplitId(), direction: targetDirection, panels: [{ ...node, size: 50 }, newTerminal], }; @@ -1823,6 +1925,7 @@ export const useAppStore = create()( // Different direction, wrap in new split return { type: "split", + id: generateSplitId(), direction: targetDirection, panels: [{ ...node, size: 50 }, newTerminal], }; @@ -1884,7 +1987,12 @@ export const useAppStore = create()( } if (newPanels.length === 0) return null; if (newPanels.length === 1) return newPanels[0]; - return { ...node, panels: newPanels }; + // Normalize sizes to sum to 100% + const totalSize = newPanels.reduce((sum, p) => sum + (p.size || 0), 0); + const normalizedPanels = totalSize > 0 + ? newPanels.map((p) => ({ ...p, size: ((p.size || 0) / totalSize) * 100 })) + : newPanels.map((p) => ({ ...p, size: 100 / newPanels.length })); + return { ...node, panels: normalizedPanels }; }; let newTabs = current.tabs.map((tab) => { @@ -1948,14 +2056,25 @@ export const useAppStore = create()( }, clearTerminalState: () => { + const current = get().terminalState; set({ terminalState: { - isUnlocked: false, - authToken: null, + // Preserve auth state - user shouldn't need to re-authenticate + isUnlocked: current.isUnlocked, + authToken: current.authToken, + // Clear session-specific state only tabs: [], activeTabId: null, activeSessionId: null, - defaultFontSize: 14, + maximizedSessionId: null, + // Preserve user preferences - these should persist across projects + defaultFontSize: current.defaultFontSize, + defaultRunScript: current.defaultRunScript, + screenReaderMode: current.screenReaderMode, + fontFamily: current.fontFamily, + scrollbackLines: current.scrollbackLines, + lineHeight: current.lineHeight, + maxSessions: current.maxSessions, }, }); }, @@ -1986,6 +2105,62 @@ export const useAppStore = create()( }); }, + setTerminalDefaultFontSize: (fontSize) => { + const current = get().terminalState; + const clampedSize = Math.max(8, Math.min(32, fontSize)); + set({ + terminalState: { ...current, defaultFontSize: clampedSize }, + }); + }, + + setTerminalDefaultRunScript: (script) => { + const current = get().terminalState; + set({ + terminalState: { ...current, defaultRunScript: script }, + }); + }, + + setTerminalScreenReaderMode: (enabled) => { + const current = get().terminalState; + set({ + terminalState: { ...current, screenReaderMode: enabled }, + }); + }, + + setTerminalFontFamily: (fontFamily) => { + const current = get().terminalState; + set({ + terminalState: { ...current, fontFamily }, + }); + }, + + setTerminalScrollbackLines: (lines) => { + const current = get().terminalState; + // Clamp to reasonable range: 1000 - 100000 lines + const clampedLines = Math.max(1000, Math.min(100000, lines)); + set({ + terminalState: { ...current, scrollbackLines: clampedLines }, + }); + }, + + setTerminalLineHeight: (lineHeight) => { + const current = get().terminalState; + // Clamp to reasonable range: 1.0 - 2.0 + const clampedHeight = Math.max(1.0, Math.min(2.0, lineHeight)); + set({ + terminalState: { ...current, lineHeight: clampedHeight }, + }); + }, + + setTerminalMaxSessions: (maxSessions) => { + const current = get().terminalState; + // Clamp to reasonable range: 1 - 500 + const clampedMax = Math.max(1, Math.min(500, maxSessions)); + set({ + terminalState: { ...current, maxSessions: clampedMax }, + }); + }, + addTerminalTab: (name) => { const current = get().terminalState; const newTabId = `tab-${Date.now()}`; @@ -2078,6 +2253,25 @@ export const useAppStore = create()( }); }, + reorderTerminalTabs: (fromTabId, toTabId) => { + const current = get().terminalState; + const fromIndex = current.tabs.findIndex((t) => t.id === fromTabId); + const toIndex = current.tabs.findIndex((t) => t.id === toTabId); + + if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) { + return; + } + + // Reorder tabs by moving fromIndex to toIndex + const newTabs = [...current.tabs]; + const [movedTab] = newTabs.splice(fromIndex, 1); + newTabs.splice(toIndex, 0, movedTab); + + set({ + terminalState: { ...current, tabs: newTabs }, + }); + }, + moveTerminalToTab: (sessionId, targetTabId) => { const current = get().terminalState; @@ -2128,7 +2322,12 @@ export const useAppStore = create()( } if (newPanels.length === 0) return null; if (newPanels.length === 1) return newPanels[0]; - return { ...node, panels: newPanels }; + // Normalize sizes to sum to 100% + const totalSize = newPanels.reduce((sum, p) => sum + (p.size || 0), 0); + const normalizedPanels = totalSize > 0 + ? newPanels.map((p) => ({ ...p, size: ((p.size || 0) / totalSize) * 100 })) + : newPanels.map((p) => ({ ...p, size: 100 / newPanels.length })); + return { ...node, panels: normalizedPanels }; }; const newSourceLayout = removeAndCollapse(sourceTab.layout); @@ -2178,6 +2377,7 @@ export const useAppStore = create()( } else if (targetTab.layout.type === "terminal") { newTargetLayout = { type: "split", + id: generateSplitId(), direction: "horizontal", panels: [{ ...targetTab.layout, size: 50 }, terminalNode], }; @@ -2228,6 +2428,7 @@ export const useAppStore = create()( } else if (tab.layout.type === "terminal") { newLayout = { type: "split", + id: generateSplitId(), direction, panels: [{ ...tab.layout, size: 50 }, terminalNode], }; @@ -2244,6 +2445,7 @@ export const useAppStore = create()( } else { newLayout = { type: "split", + id: generateSplitId(), direction, panels: [{ ...tab.layout, size: 50 }, terminalNode], }; @@ -2264,6 +2466,154 @@ export const useAppStore = create()( }); }, + setTerminalTabLayout: (tabId, layout, activeSessionId) => { + const current = get().terminalState; + const tab = current.tabs.find((t) => t.id === tabId); + if (!tab) return; + + const newTabs = current.tabs.map((t) => + t.id === tabId ? { ...t, layout } : t + ); + + // Find first terminal in layout if no activeSessionId provided + const findFirst = (node: TerminalPanelContent): string | null => { + if (node.type === "terminal") return node.sessionId; + for (const p of node.panels) { + const found = findFirst(p); + if (found) return found; + } + return null; + }; + + const newActiveSessionId = activeSessionId || findFirst(layout); + + set({ + terminalState: { + ...current, + tabs: newTabs, + activeTabId: tabId, + activeSessionId: newActiveSessionId, + }, + }); + }, + + updateTerminalPanelSizes: (tabId, panelKeys, sizes) => { + const current = get().terminalState; + const tab = current.tabs.find((t) => t.id === tabId); + if (!tab || !tab.layout) return; + + // Create a map of panel key to new size + const sizeMap = new Map(); + panelKeys.forEach((key, index) => { + sizeMap.set(key, sizes[index]); + }); + + // Helper to generate panel key (matches getPanelKey in terminal-view.tsx) + const getPanelKey = (panel: TerminalPanelContent): string => { + if (panel.type === "terminal") return panel.sessionId; + const childKeys = panel.panels.map(getPanelKey).join("-"); + return `split-${panel.direction}-${childKeys}`; + }; + + // Recursively update sizes in the layout + const updateSizes = (panel: TerminalPanelContent): TerminalPanelContent => { + const key = getPanelKey(panel); + const newSize = sizeMap.get(key); + + if (panel.type === "terminal") { + return newSize !== undefined ? { ...panel, size: newSize } : panel; + } + + return { + ...panel, + size: newSize !== undefined ? newSize : panel.size, + panels: panel.panels.map(updateSizes), + }; + }; + + const updatedLayout = updateSizes(tab.layout); + + const newTabs = current.tabs.map((t) => + t.id === tabId ? { ...t, layout: updatedLayout } : t + ); + + set({ + terminalState: { ...current, tabs: newTabs }, + }); + }, + + // Convert runtime layout to persisted format (preserves sessionIds for reconnection) + saveTerminalLayout: (projectPath) => { + const current = get().terminalState; + if (current.tabs.length === 0) { + // Nothing to save, clear any existing layout + const { [projectPath]: _, ...rest } = get().terminalLayoutByProject; + set({ terminalLayoutByProject: rest }); + return; + } + + // Convert TerminalPanelContent to PersistedTerminalPanel + // Now preserves sessionId so we can reconnect when switching back + const persistPanel = ( + panel: TerminalPanelContent + ): PersistedTerminalPanel => { + if (panel.type === "terminal") { + return { + type: "terminal", + size: panel.size, + fontSize: panel.fontSize, + sessionId: panel.sessionId, // Preserve for reconnection + }; + } + return { + type: "split", + id: panel.id, // Preserve stable ID + direction: panel.direction, + panels: panel.panels.map(persistPanel), + size: panel.size, + }; + }; + + const persistedTabs: PersistedTerminalTab[] = current.tabs.map( + (tab) => ({ + id: tab.id, + name: tab.name, + layout: tab.layout ? persistPanel(tab.layout) : null, + }) + ); + + const activeTabIndex = current.tabs.findIndex( + (t) => t.id === current.activeTabId + ); + + const persisted: PersistedTerminalState = { + tabs: persistedTabs, + activeTabIndex: activeTabIndex >= 0 ? activeTabIndex : 0, + defaultFontSize: current.defaultFontSize, + defaultRunScript: current.defaultRunScript, + screenReaderMode: current.screenReaderMode, + fontFamily: current.fontFamily, + scrollbackLines: current.scrollbackLines, + lineHeight: current.lineHeight, + }; + + set({ + terminalLayoutByProject: { + ...get().terminalLayoutByProject, + [projectPath]: persisted, + }, + }); + }, + + getPersistedTerminalLayout: (projectPath) => { + return get().terminalLayoutByProject[projectPath] || null; + }, + + clearPersistedTerminalLayout: (projectPath) => { + const { [projectPath]: _, ...rest } = get().terminalLayoutByProject; + set({ terminalLayoutByProject: rest }); + }, + // Spec Creation actions setSpecCreatingForProject: (projectPath) => { set({ specCreatingForProject: projectPath }); @@ -2312,6 +2662,30 @@ export const useAppStore = create()( } } + // Rehydrate terminal settings from persisted state + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const persistedSettings = (state as any).terminalSettings as PersistedTerminalSettings | undefined; + if (persistedSettings) { + state.terminalState = { + ...state.terminalState, + // Preserve session state (tabs, activeTabId, etc.) but restore settings + isUnlocked: state.terminalState?.isUnlocked ?? false, + authToken: state.terminalState?.authToken ?? null, + tabs: state.terminalState?.tabs ?? [], + activeTabId: state.terminalState?.activeTabId ?? null, + activeSessionId: state.terminalState?.activeSessionId ?? null, + maximizedSessionId: state.terminalState?.maximizedSessionId ?? null, + // Restore persisted settings + defaultFontSize: persistedSettings.defaultFontSize ?? 14, + defaultRunScript: persistedSettings.defaultRunScript ?? "", + screenReaderMode: persistedSettings.screenReaderMode ?? false, + fontFamily: persistedSettings.fontFamily ?? "Menlo, Monaco, 'Courier New', monospace", + scrollbackLines: persistedSettings.scrollbackLines ?? 5000, + lineHeight: persistedSettings.lineHeight ?? 1.0, + maxSessions: persistedSettings.maxSessions ?? 100, + }; + } + return state as AppState; }, partialize: (state) => ({ @@ -2349,10 +2723,23 @@ export const useAppStore = create()( lastSelectedSessionByProject: state.lastSelectedSessionByProject, // Board background settings boardBackgroundByProject: state.boardBackgroundByProject, + // Terminal layout persistence (per-project) + terminalLayoutByProject: state.terminalLayoutByProject, + // Terminal settings persistence (global) + terminalSettings: { + defaultFontSize: state.terminalState.defaultFontSize, + defaultRunScript: state.terminalState.defaultRunScript, + screenReaderMode: state.terminalState.screenReaderMode, + fontFamily: state.terminalState.fontFamily, + scrollbackLines: state.terminalState.scrollbackLines, + lineHeight: state.terminalState.lineHeight, + maxSessions: state.terminalState.maxSessions, + } as PersistedTerminalSettings, defaultPlanningMode: state.defaultPlanningMode, defaultRequirePlanApproval: state.defaultRequirePlanApproval, defaultAIProfileId: state.defaultAIProfileId, - }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, } ) ); diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index 04e15212..606feb6f 100644 --- a/apps/ui/src/styles/global.css +++ b/apps/ui/src/styles/global.css @@ -2929,3 +2929,23 @@ .animate-accordion-up { animation: accordion-up 0.2s ease-out forwards; } + +/* Terminal scrollbar theming */ +.xterm-viewport::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.xterm-viewport::-webkit-scrollbar-track { + background: var(--muted); + border-radius: 4px; +} + +.xterm-viewport::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +.xterm-viewport::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); +} diff --git a/package-lock.json b/package-lock.json index 257bd1f2..c2221d93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,8 @@ "@tanstack/react-router": "^1.141.6", "@uiw/react-codemirror": "^4.25.4", "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-search": "^0.15.0", + "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.1", @@ -1003,7 +1005,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", @@ -6290,6 +6292,24 @@ "@xterm/xterm": "^5.0.0" } }, + "node_modules/@xterm/addon-search": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz", + "integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz", + "integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, "node_modules/@xterm/addon-webgl": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz", From 2b1a7660b69f0aba2d71c219bbebb1539c281a1b Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 20 Dec 2025 23:02:31 -0500 Subject: [PATCH 02/59] refactor: update terminal session limits and improve layout saving - Refactored session limit checks in terminal settings to use constants for minimum and maximum session values. - Enhanced terminal layout saving mechanism with debouncing to prevent excessive writes during rapid changes. - Updated error messages to reflect new session limit constants. --- .../src/routes/terminal/routes/settings.ts | 6 +++--- apps/server/src/services/terminal-service.ts | 6 +++++- apps/ui/src/components/views/terminal-view.tsx | 18 ++++++++++++++++-- package-lock.json | 2 +- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/server/src/routes/terminal/routes/settings.ts b/apps/server/src/routes/terminal/routes/settings.ts index 9bd493c8..f4d6c007 100644 --- a/apps/server/src/routes/terminal/routes/settings.ts +++ b/apps/server/src/routes/terminal/routes/settings.ts @@ -3,7 +3,7 @@ */ import type { Request, Response } from "express"; -import { getTerminalService } from "../../../services/terminal-service.js"; +import { getTerminalService, MIN_MAX_SESSIONS, MAX_MAX_SESSIONS } from "../../../services/terminal-service.js"; import { getErrorMessage, logError } from "../common.js"; export function createSettingsGetHandler() { @@ -26,10 +26,10 @@ export function createSettingsUpdateHandler() { const { maxSessions } = req.body; if (typeof maxSessions === "number") { - if (maxSessions < 1 || maxSessions > 500) { + if (maxSessions < MIN_MAX_SESSIONS || maxSessions > MAX_MAX_SESSIONS) { res.status(400).json({ success: false, - error: "maxSessions must be between 1 and 500", + error: `maxSessions must be between ${MIN_MAX_SESSIONS} and ${MAX_MAX_SESSIONS}`, }); return; } diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index 8d671f79..4d506d25 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -13,6 +13,10 @@ import * as fs from "fs"; // Maximum scrollback buffer size (characters) const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal +// Session limit constants - shared with routes/settings.ts +export const MIN_MAX_SESSIONS = 1; +export const MAX_MAX_SESSIONS = 500; + // Maximum number of concurrent terminal sessions // Can be overridden via TERMINAL_MAX_SESSIONS environment variable // Default set to 1000 - effectively unlimited for most use cases @@ -200,7 +204,7 @@ export class TerminalService extends EventEmitter { * Set maximum allowed sessions (can be called dynamically) */ setMaxSessions(limit: number): void { - if (limit >= 1 && limit <= 500) { + if (limit >= MIN_MAX_SESSIONS && limit <= MAX_MAX_SESSIONS) { maxSessions = limit; console.log(`[Terminal] Max sessions limit updated to ${limit}`); } diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 8c405d13..4d801e42 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -627,12 +627,26 @@ export function TerminalView() { } }, [currentProject?.path, saveTerminalLayout, getPersistedTerminalLayout, clearTerminalState, addTerminalTab, serverUrl]); - // Save terminal layout whenever it changes (debounced via the effect) + // Save terminal layout whenever it changes (debounced to prevent excessive writes) // Also save when tabs become empty so closed terminals stay closed on refresh + const saveLayoutTimeoutRef = useRef(null); useEffect(() => { if (currentProject?.path && !isRestoringLayoutRef.current) { - saveTerminalLayout(currentProject.path); + // Debounce saves to prevent excessive localStorage writes during rapid changes + if (saveLayoutTimeoutRef.current) { + clearTimeout(saveLayoutTimeoutRef.current); + } + saveLayoutTimeoutRef.current = setTimeout(() => { + saveTerminalLayout(currentProject.path); + saveLayoutTimeoutRef.current = null; + }, 500); // 500ms debounce } + + return () => { + if (saveLayoutTimeoutRef.current) { + clearTimeout(saveLayoutTimeoutRef.current); + } + }; }, [terminalState.tabs, currentProject?.path, saveTerminalLayout]); // Handle password authentication diff --git a/package-lock.json b/package-lock.json index c2221d93..149743db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1005,7 +1005,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", From 5bd2b705dc9d8ebcbe71d4bbb90292b9c424abc3 Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:03:43 +0400 Subject: [PATCH 03/59] feat: add Claude usage tracking via CLI Adds a Claude usage tracking feature that displays session, weekly, and Sonnet usage stats. Uses the Claude CLI's /usage command to fetch data (no API key required). Features: - Usage popover in board header showing session, weekly, and Sonnet limits - Progress bars with color-coded status (green/orange/red) - Auto-refresh with configurable interval - Caching of usage data with stale indicator - Settings section for refresh interval configuration Server: - ClaudeUsageService: Executes Claude CLI via PTY (expect) to fetch usage - New /api/claude/usage endpoint UI: - ClaudeUsagePopover component with usage cards - ClaudeUsageSection in settings for configuration - Integration with app store for persistence --- apps/server/src/index.ts | 2 + apps/server/src/routes/claude/index.ts | 44 +++ apps/server/src/routes/claude/types.ts | 35 ++ .../src/services/claude-usage-service.ts | 358 ++++++++++++++++++ .../src/components/claude-usage-popover.tsx | 309 +++++++++++++++ .../views/board-view/board-header.tsx | 4 + .../ui/src/components/views/settings-view.tsx | 14 +- .../api-keys/claude-usage-section.tsx | 75 ++++ apps/ui/src/lib/electron.ts | 30 ++ apps/ui/src/lib/http-api-client.ts | 5 + apps/ui/src/store/app-store.ts | 81 ++++ 11 files changed, 952 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/routes/claude/index.ts create mode 100644 apps/server/src/routes/claude/types.ts create mode 100644 apps/server/src/services/claude-usage-service.ts create mode 100644 apps/ui/src/components/claude-usage-popover.tsx create mode 100644 apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 0cbece15..51e92e80 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -44,6 +44,7 @@ import { AutoModeService } from "./services/auto-mode-service.js"; import { getTerminalService } from "./services/terminal-service.js"; import { SettingsService } from "./services/settings-service.js"; import { createSpecRegenerationRoutes } from "./routes/app-spec/index.js"; +import { createClaudeRoutes } from "./routes/claude/index.js"; // Load environment variables dotenv.config(); @@ -141,6 +142,7 @@ app.use("/api/workspace", createWorkspaceRoutes()); app.use("/api/templates", createTemplatesRoutes()); app.use("/api/terminal", createTerminalRoutes()); app.use("/api/settings", createSettingsRoutes(settingsService)); +app.use("/api/claude", createClaudeRoutes()); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/routes/claude/index.ts b/apps/server/src/routes/claude/index.ts new file mode 100644 index 00000000..8f359d5f --- /dev/null +++ b/apps/server/src/routes/claude/index.ts @@ -0,0 +1,44 @@ +import { Router, Request, Response } from "express"; +import { ClaudeUsageService } from "../../services/claude-usage-service.js"; + +export function createClaudeRoutes(): Router { + const router = Router(); + const service = new ClaudeUsageService(); + + // Get current usage (fetches from Claude CLI) + router.get("/usage", async (req: Request, res: Response) => { + try { + // Check if Claude CLI is available first + const isAvailable = await service.isAvailable(); + if (!isAvailable) { + res.status(503).json({ + error: "Claude CLI not found", + message: "Please install Claude Code CLI and run 'claude login' to authenticate" + }); + return; + } + + const usage = await service.fetchUsageData(); + res.json(usage); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + + if (message.includes("Authentication required") || message.includes("token_expired")) { + res.status(401).json({ + error: "Authentication required", + message: "Please run 'claude login' to authenticate" + }); + } else if (message.includes("timed out")) { + res.status(504).json({ + error: "Command timed out", + message: "The Claude CLI took too long to respond" + }); + } else { + console.error("Error fetching usage:", error); + res.status(500).json({ error: message }); + } + } + }); + + return router; +} diff --git a/apps/server/src/routes/claude/types.ts b/apps/server/src/routes/claude/types.ts new file mode 100644 index 00000000..d7baa0c5 --- /dev/null +++ b/apps/server/src/routes/claude/types.ts @@ -0,0 +1,35 @@ +/** + * Claude Usage types for CLI-based usage tracking + */ + +export type ClaudeUsage = { + sessionTokensUsed: number; + sessionLimit: number; + sessionPercentage: number; + sessionResetTime: string; // ISO date string + sessionResetText: string; // Raw text like "Resets 10:59am (Asia/Dubai)" + + weeklyTokensUsed: number; + weeklyLimit: number; + weeklyPercentage: number; + weeklyResetTime: string; // ISO date string + weeklyResetText: string; // Raw text like "Resets Dec 22 at 7:59pm (Asia/Dubai)" + + opusWeeklyTokensUsed: number; + opusWeeklyPercentage: number; + opusResetText: string; // Raw text like "Resets Dec 27 at 9:59am (Asia/Dubai)" + + costUsed: number | null; + costLimit: number | null; + costCurrency: string | null; + + lastUpdated: string; // ISO date string + userTimezone: string; +}; + +export type ClaudeStatus = { + indicator: { + color: "green" | "yellow" | "orange" | "red" | "gray"; + }; + description: string; +}; diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts new file mode 100644 index 00000000..a164a46d --- /dev/null +++ b/apps/server/src/services/claude-usage-service.ts @@ -0,0 +1,358 @@ +import { spawn } from "child_process"; +import { ClaudeUsage } from "../routes/claude/types.js"; + +/** + * Claude Usage Service + * + * Fetches usage data by executing the Claude CLI's /usage command. + * This approach doesn't require any API keys - it relies on the user + * having already authenticated via `claude login`. + * + * Based on ClaudeBar's implementation approach. + */ +export class ClaudeUsageService { + private claudeBinary = "claude"; + private timeout = 30000; // 30 second timeout + + /** + * Check if Claude CLI is available on the system + */ + async isAvailable(): Promise { + return new Promise((resolve) => { + const proc = spawn("which", [this.claudeBinary]); + proc.on("close", (code) => { + resolve(code === 0); + }); + proc.on("error", () => { + resolve(false); + }); + }); + } + + /** + * Fetch usage data by executing the Claude CLI + */ + async fetchUsageData(): Promise { + const output = await this.executeClaudeUsageCommand(); + return this.parseUsageOutput(output); + } + + /** + * Execute the claude /usage command and return the output + * Uses 'expect' to provide a pseudo-TTY since claude requires one + */ + private executeClaudeUsageCommand(): Promise { + return new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + let settled = false; + + // Use a simple working directory (home or tmp) + const workingDirectory = process.env.HOME || "/tmp"; + + // Use 'expect' with an inline script to run claude /usage with a PTY + // Wait for "Current session" header, then wait for full output before exiting + const expectScript = ` + set timeout 20 + spawn claude /usage + expect { + "Current session" { + sleep 2 + send "\\x1b" + } + "Esc to cancel" { + sleep 3 + send "\\x1b" + } + timeout {} + eof {} + } + expect eof + `; + + const proc = spawn("expect", ["-c", expectScript], { + cwd: workingDirectory, + env: { + ...process.env, + TERM: "xterm-256color", + }, + }); + + const timeoutId = setTimeout(() => { + if (!settled) { + settled = true; + proc.kill(); + reject(new Error("Command timed out")); + } + }, this.timeout); + + proc.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + proc.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + clearTimeout(timeoutId); + if (settled) return; + settled = true; + + // Check for authentication errors in output + if (stdout.includes("token_expired") || stdout.includes("authentication_error") || + stderr.includes("token_expired") || stderr.includes("authentication_error")) { + reject(new Error("Authentication required - please run 'claude login'")); + return; + } + + // Even if exit code is non-zero, we might have useful output + if (stdout.trim()) { + resolve(stdout); + } else if (code !== 0) { + reject(new Error(stderr || `Command exited with code ${code}`)); + } else { + reject(new Error("No output from claude command")); + } + }); + + proc.on("error", (err) => { + clearTimeout(timeoutId); + if (!settled) { + settled = true; + reject(new Error(`Failed to execute claude: ${err.message}`)); + } + }); + }); + } + + /** + * Strip ANSI escape codes from text + */ + private stripAnsiCodes(text: string): string { + // eslint-disable-next-line no-control-regex + return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ""); + } + + /** + * Parse the Claude CLI output to extract usage information + * + * Expected output format: + * ``` + * Claude Code v1.0.27 + * + * Current session + * ████████████████░░░░ 65% left + * Resets in 2h 15m + * + * Current week (all models) + * ██████████░░░░░░░░░░ 35% left + * Resets Jan 15, 3:30pm (America/Los_Angeles) + * + * Current week (Opus) + * ████████████████████ 80% left + * Resets Jan 15, 3:30pm (America/Los_Angeles) + * ``` + */ + private parseUsageOutput(rawOutput: string): ClaudeUsage { + const output = this.stripAnsiCodes(rawOutput); + const lines = output.split("\n").map(l => l.trim()).filter(l => l); + + // Parse session usage + const sessionData = this.parseSection(lines, "Current session", "session"); + + // Parse weekly usage (all models) + const weeklyData = this.parseSection(lines, "Current week (all models)", "weekly"); + + // Parse Sonnet/Opus usage - try different labels + let opusData = this.parseSection(lines, "Current week (Sonnet only)", "opus"); + if (opusData.percentage === 0) { + opusData = this.parseSection(lines, "Current week (Sonnet)", "opus"); + } + if (opusData.percentage === 0) { + opusData = this.parseSection(lines, "Current week (Opus)", "opus"); + } + + return { + sessionTokensUsed: 0, // Not available from CLI + sessionLimit: 0, // Not available from CLI + sessionPercentage: sessionData.percentage, + sessionResetTime: sessionData.resetTime, + sessionResetText: sessionData.resetText, + + weeklyTokensUsed: 0, // Not available from CLI + weeklyLimit: 0, // Not available from CLI + weeklyPercentage: weeklyData.percentage, + weeklyResetTime: weeklyData.resetTime, + weeklyResetText: weeklyData.resetText, + + opusWeeklyTokensUsed: 0, // Not available from CLI + opusWeeklyPercentage: opusData.percentage, + opusResetText: opusData.resetText, + + costUsed: null, // Not available from CLI + costLimit: null, + costCurrency: null, + + lastUpdated: new Date().toISOString(), + userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }; + } + + /** + * Parse a section of the usage output to extract percentage and reset time + */ + private parseSection(lines: string[], sectionLabel: string, type: string): { percentage: number; resetTime: string; resetText: string } { + let percentage = 0; + let resetTime = this.getDefaultResetTime(type); + let resetText = ""; + + // Find the LAST occurrence of the section (terminal output has multiple screen refreshes) + let sectionIndex = -1; + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].toLowerCase().includes(sectionLabel.toLowerCase())) { + sectionIndex = i; + break; + } + } + + if (sectionIndex === -1) { + return { percentage, resetTime, resetText }; + } + + // Look at the lines following the section header (within a window of 5 lines) + const searchWindow = lines.slice(sectionIndex, sectionIndex + 5); + + for (const line of searchWindow) { + // Extract percentage - look for patterns like "65% left" or "35% used" + const percentMatch = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i); + if (percentMatch) { + const value = parseInt(percentMatch[1], 10); + const isUsed = percentMatch[2].toLowerCase() === "used"; + // Convert "left" to "used" percentage (our UI shows % used) + percentage = isUsed ? value : (100 - value); + } + + // Extract reset time + if (line.toLowerCase().includes("reset")) { + resetText = line; + } + } + + // Parse the reset time if we found one + if (resetText) { + resetTime = this.parseResetTime(resetText, type); + // Strip timezone like "(Asia/Dubai)" from the display text + resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, "").trim(); + } + + return { percentage, resetTime, resetText }; + } + + /** + * Parse reset time from text like "Resets in 2h 15m", "Resets 11am", or "Resets Dec 22 at 8pm" + */ + private parseResetTime(text: string, type: string): string { + const now = new Date(); + + // Try to parse duration format: "Resets in 2h 15m" or "Resets in 30m" + const durationMatch = text.match(/(\d+)\s*h(?:ours?)?(?:\s+(\d+)\s*m(?:in)?)?|(\d+)\s*m(?:in)?/i); + if (durationMatch) { + let hours = 0; + let minutes = 0; + + if (durationMatch[1]) { + hours = parseInt(durationMatch[1], 10); + minutes = durationMatch[2] ? parseInt(durationMatch[2], 10) : 0; + } else if (durationMatch[3]) { + minutes = parseInt(durationMatch[3], 10); + } + + const resetDate = new Date(now.getTime() + (hours * 60 + minutes) * 60 * 1000); + return resetDate.toISOString(); + } + + // Try to parse simple time-only format: "Resets 11am" or "Resets 3pm" + const simpleTimeMatch = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i); + if (simpleTimeMatch) { + let hours = parseInt(simpleTimeMatch[1], 10); + const minutes = simpleTimeMatch[2] ? parseInt(simpleTimeMatch[2], 10) : 0; + const ampm = simpleTimeMatch[3].toLowerCase(); + + // Convert 12-hour to 24-hour + if (ampm === "pm" && hours !== 12) { + hours += 12; + } else if (ampm === "am" && hours === 12) { + hours = 0; + } + + // Create date for today at specified time + const resetDate = new Date(now); + resetDate.setHours(hours, minutes, 0, 0); + + // If time has passed, use tomorrow + if (resetDate <= now) { + resetDate.setDate(resetDate.getDate() + 1); + } + return resetDate.toISOString(); + } + + // Try to parse date format: "Resets Dec 22 at 8pm" or "Resets Jan 15, 3:30pm" + const dateMatch = text.match(/([A-Za-z]{3,})\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i); + if (dateMatch) { + const monthName = dateMatch[1]; + const day = parseInt(dateMatch[2], 10); + let hours = parseInt(dateMatch[3], 10); + const minutes = dateMatch[4] ? parseInt(dateMatch[4], 10) : 0; + const ampm = dateMatch[5].toLowerCase(); + + // Convert 12-hour to 24-hour + if (ampm === "pm" && hours !== 12) { + hours += 12; + } else if (ampm === "am" && hours === 12) { + hours = 0; + } + + // Parse month name + const months: Record = { + jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, + jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 + }; + const month = months[monthName.toLowerCase().substring(0, 3)]; + + if (month !== undefined) { + let year = now.getFullYear(); + // If the date appears to be in the past, assume next year + const resetDate = new Date(year, month, day, hours, minutes); + if (resetDate < now) { + resetDate.setFullYear(year + 1); + } + return resetDate.toISOString(); + } + } + + // Fallback to default + return this.getDefaultResetTime(type); + } + + /** + * Get default reset time based on usage type + */ + private getDefaultResetTime(type: string): string { + const now = new Date(); + + if (type === "session") { + // Session resets in ~5 hours + return new Date(now.getTime() + 5 * 60 * 60 * 1000).toISOString(); + } else { + // Weekly resets on next Monday around noon + const result = new Date(now); + const currentDay = now.getDay(); + let daysUntilMonday = (1 + 7 - currentDay) % 7; + if (daysUntilMonday === 0) daysUntilMonday = 7; + result.setDate(result.getDate() + daysUntilMonday); + result.setHours(12, 59, 0, 0); + return result.toISOString(); + } + } +} diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx new file mode 100644 index 00000000..23182744 --- /dev/null +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -0,0 +1,309 @@ +import { useState, useEffect, useMemo } from "react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { + RefreshCw, + AlertTriangle, + CheckCircle, + XCircle, + Clock, + ExternalLink, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { getElectronAPI } from "@/lib/electron"; +import { useAppStore } from "@/store/app-store"; + +export function ClaudeUsagePopover() { + const { claudeRefreshInterval, claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = + useAppStore(); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes + const isStale = useMemo(() => { + return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; + }, [claudeUsageLastUpdated]); + + const fetchUsage = async (isAutoRefresh = false) => { + if (!isAutoRefresh) setLoading(true); + setError(null); + try { + const api = getElectronAPI(); + if (!api.claude) { + throw new Error("Claude API not available"); + } + const data = await api.claude.getUsage(); + if (data.error) { + throw new Error(data.message || data.error); + } + setClaudeUsage(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch usage"); + } finally { + if (!isAutoRefresh) setLoading(false); + } + }; + + // Auto-fetch on mount if data is stale + useEffect(() => { + if (isStale) { + fetchUsage(true); + } + }, []); + + useEffect(() => { + // Initial fetch when opened + if (open) { + if (!claudeUsage) { + fetchUsage(); + } else { + const now = Date.now(); + const stale = !claudeUsageLastUpdated || now - claudeUsageLastUpdated > 2 * 60 * 1000; + if (stale) { + fetchUsage(false); + } + } + } + + // Auto-refresh interval (only when open) + let intervalId: NodeJS.Timeout | null = null; + if (open && claudeRefreshInterval > 0) { + intervalId = setInterval(() => { + fetchUsage(true); + }, claudeRefreshInterval * 1000); + } + + return () => { + if (intervalId) clearInterval(intervalId); + }; + }, [open]); + + // Derived status color/icon helper + const getStatusInfo = (percentage: number) => { + if (percentage >= 80) + return { color: "text-red-500", icon: XCircle, bg: "bg-red-500" }; + if (percentage >= 50) + return { color: "text-orange-500", icon: AlertTriangle, bg: "bg-orange-500" }; + return { color: "text-green-500", icon: CheckCircle, bg: "bg-green-500" }; + }; + + // Helper component for the progress bar + const ProgressBar = ({ + percentage, + colorClass, + }: { + percentage: number; + colorClass: string; + }) => ( +
+
+
+ ); + + const UsageCard = ({ + title, + subtitle, + percentage, + resetText, + isPrimary = false, + stale = false, + }: { + title: string; + subtitle: string; + percentage: number; + resetText?: string; + isPrimary?: boolean; + stale?: boolean; + }) => { + const status = getStatusInfo(percentage); + const StatusIcon = status.icon; + + return ( +
+
+
+

+ {title} +

+

{subtitle}

+
+
+ + + {Math.round(percentage)}% + +
+
+ + {resetText && ( +
+

+ + {resetText} +

+
+ )} +
+ ); + }; + + // Header Button + const maxPercentage = claudeUsage + ? Math.max(claudeUsage.sessionPercentage || 0, claudeUsage.weeklyPercentage || 0) + : 0; + + const getProgressBarColor = (percentage: number) => { + if (percentage >= 100) return "bg-red-500"; + if (percentage >= 75) return "bg-yellow-500"; + return "bg-green-500"; + }; + + const trigger = ( + + ); + + return ( + + {trigger} + + {/* Header */} +
+
+ Claude Usage +
+ +
+ + {/* Content */} +
+ {error ? ( +
+ +
+

{error}

+

+ Make sure Claude CLI is installed and authenticated via claude login +

+
+
+ ) : !claudeUsage ? ( + // Loading state +
+ +

Loading usage data...

+
+ ) : ( + <> + {/* Primary Card */} + + + {/* Secondary Cards Grid */} +
+ + +
+ + {/* Extra Usage / Cost */} + {claudeUsage.costLimit && claudeUsage.costLimit > 0 && ( + 0 + ? ((claudeUsage.costUsed ?? 0) / claudeUsage.costLimit) * 100 + : 0 + } + stale={isStale} + /> + )} + + )} +
+ + {/* Footer */} +
+ + Claude Status + + +
{/* Could add quick settings link here */}
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index b70c615d..044b6151 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -6,6 +6,7 @@ import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { Plus, Bot } from "lucide-react"; import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts"; +import { ClaudeUsagePopover } from "@/components/claude-usage-popover"; interface BoardHeaderProps { projectName: string; @@ -37,6 +38,9 @@ export function BoardHeader({

{projectName}

+ {/* Usage Popover */} + {isMounted && } + {/* Concurrency Slider - only show after mount to prevent hydration issues */} {isMounted && (
+
+ + +
); case "ai-enhancement": return ; diff --git a/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx new file mode 100644 index 00000000..2ca061da --- /dev/null +++ b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx @@ -0,0 +1,75 @@ +import { Clock } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useState, useEffect } from "react"; +import { Slider } from "@/components/ui/slider"; +import { useAppStore } from "@/store/app-store"; + +export function ClaudeUsageSection() { + const { claudeRefreshInterval, setClaudeRefreshInterval } = useAppStore(); + const [localInterval, setLocalInterval] = useState(claudeRefreshInterval); + + // Sync local state with store when store changes (e.g. initial load) + useEffect(() => { + setLocalInterval(claudeRefreshInterval); + }, [claudeRefreshInterval]); + + return ( +
+
+
+
+
+
+

Claude Usage Tracking

+
+

+ Track your Claude Code usage limits. Uses the Claude CLI for data. +

+
+
+ {/* Info about CLI requirement */} +
+

Usage tracking requires Claude Code CLI to be installed and authenticated:

+
    +
  1. Install Claude Code CLI if not already installed
  2. +
  3. Run claude login to authenticate
  4. +
  5. Usage data will be fetched automatically
  6. +
+
+ + {/* Refresh Interval Section */} +
+
+

+ + Refresh Interval +

+

+ How often to check for usage updates. +

+
+ +
+ setLocalInterval(vals[0])} + onValueCommit={(vals) => setClaudeRefreshInterval(vals[0])} + min={30} + max={120} + step={5} + className="flex-1" + /> + {Math.max(30, Math.min(120, localInterval || 30))}s +
+
+
+
+ ); +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index cdaaf67c..a085e8c6 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -482,6 +482,9 @@ export interface ElectronAPI { sessionId: string ) => Promise<{ success: boolean; error?: string }>; }; + claude?: { + getUsage: () => Promise; + }; } // Note: Window interface is declared in @/types/electron.d.ts @@ -879,6 +882,33 @@ const getMockElectronAPI = (): ElectronAPI => { // Mock Running Agents API runningAgents: createMockRunningAgentsAPI(), + + // Mock Claude API + claude: { + getUsage: async () => { + console.log("[Mock] Getting Claude usage"); + return { + sessionTokensUsed: 0, + sessionLimit: 0, + sessionPercentage: 15, + sessionResetTime: new Date(Date.now() + 3600000).toISOString(), + sessionResetText: "Resets in 1h", + weeklyTokensUsed: 0, + weeklyLimit: 0, + weeklyPercentage: 5, + weeklyResetTime: new Date(Date.now() + 86400000 * 2).toISOString(), + weeklyResetText: "Resets Dec 23", + opusWeeklyTokensUsed: 0, + opusWeeklyPercentage: 1, + opusResetText: "Resets Dec 27", + costUsed: null, + costLimit: null, + costCurrency: null, + lastUpdated: new Date().toISOString(), + userTimezone: "UTC" + }; + }, + } }; }; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 59c9305d..b78e8596 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1016,6 +1016,11 @@ export class HttpApiClient implements ElectronAPI { ): Promise<{ success: boolean; error?: string }> => this.httpDelete(`/api/sessions/${sessionId}`), }; + + // Claude API + claude = { + getUsage: (): Promise => this.get("/api/claude/usage"), + }; } // Singleton instance diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index f433578a..1601b1e8 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -507,6 +507,67 @@ export interface AppState { planContent: string; planningMode: "lite" | "spec" | "full"; } | null; + + // Claude Usage Tracking + claudeRefreshInterval: number; // Refresh interval in seconds (default: 60) + claudeUsage: ClaudeUsage | null; + claudeUsageLastUpdated: number | null; +} + +// Claude Usage interface matching the server response +export interface ClaudeUsage { + sessionTokensUsed: number; + sessionLimit: number; + sessionPercentage: number; + sessionResetTime: string; + sessionResetText: string; + + weeklyTokensUsed: number; + weeklyLimit: number; + weeklyPercentage: number; + weeklyResetTime: string; + weeklyResetText: string; + + opusWeeklyTokensUsed: number; + opusWeeklyPercentage: number; + opusResetText: string; + + costUsed: number | null; + costLimit: number | null; + costCurrency: string | null; +} + +/** + * Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit) + * Returns true if any limit is reached, meaning auto mode should pause feature pickup. + */ +export function isClaudeUsageAtLimit(claudeUsage: ClaudeUsage | null): boolean { + if (!claudeUsage) { + // No usage data available - don't block + return false; + } + + // Check session limit (5-hour window) + if (claudeUsage.sessionPercentage >= 100) { + return true; + } + + // Check weekly limit + if (claudeUsage.weeklyPercentage >= 100) { + return true; + } + + // Check cost limit (if configured) + if ( + claudeUsage.costLimit !== null && + claudeUsage.costLimit > 0 && + claudeUsage.costUsed !== null && + claudeUsage.costUsed >= claudeUsage.costLimit + ) { + return true; + } + + return false; } // Default background settings for board backgrounds @@ -756,6 +817,11 @@ export interface AppActions { planningMode: "lite" | "spec" | "full"; } | null) => void; + // Claude Usage Tracking actions + setClaudeRefreshInterval: (interval: number) => void; + setClaudeUsageLastUpdated: (timestamp: number) => void; + setClaudeUsage: (usage: ClaudeUsage | null) => void; + // Reset reset: () => void; } @@ -848,6 +914,9 @@ const initialState: AppState = { defaultRequirePlanApproval: false, defaultAIProfileId: null, pendingPlanApproval: null, + claudeRefreshInterval: 60, + claudeUsage: null, + claudeUsageLastUpdated: null, }; export const useAppStore = create()( @@ -2280,6 +2349,14 @@ export const useAppStore = create()( // Plan Approval actions setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }), + // Claude Usage Tracking actions + setClaudeRefreshInterval: (interval: number) => set({ claudeRefreshInterval: interval }), + setClaudeUsageLastUpdated: (timestamp: number) => set({ claudeUsageLastUpdated: timestamp }), + setClaudeUsage: (usage: ClaudeUsage | null) => set({ + claudeUsage: usage, + claudeUsageLastUpdated: usage ? Date.now() : null, + }), + // Reset reset: () => set(initialState), }), @@ -2352,6 +2429,10 @@ export const useAppStore = create()( defaultPlanningMode: state.defaultPlanningMode, defaultRequirePlanApproval: state.defaultRequirePlanApproval, defaultAIProfileId: state.defaultAIProfileId, + // Claude usage tracking + claudeUsage: state.claudeUsage, + claudeUsageLastUpdated: state.claudeUsageLastUpdated, + claudeRefreshInterval: state.claudeRefreshInterval, }), } ) From ebc7c9a7a08824462815fa9c39af25fa681cf18a Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:09:00 +0400 Subject: [PATCH 04/59] feat: hide usage tracking UI when API key is configured Usage tracking via CLI only works for Claude Code subscription users. Hide the Usage button and settings section when an Anthropic API key is set. --- .../src/components/views/board-view/board-header.tsx | 10 ++++++++-- apps/ui/src/components/views/settings-view.tsx | 6 +++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 044b6151..a7c74b3c 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label"; import { Plus, Bot } from "lucide-react"; import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts"; import { ClaudeUsagePopover } from "@/components/claude-usage-popover"; +import { useAppStore } from "@/store/app-store"; interface BoardHeaderProps { projectName: string; @@ -31,6 +32,11 @@ export function BoardHeader({ addFeatureShortcut, isMounted, }: BoardHeaderProps) { + const apiKeys = useAppStore((state) => state.apiKeys); + + // Hide usage tracking when using API key (only show for Claude Code CLI users) + const showUsageTracking = !apiKeys.anthropic; + return (
@@ -38,8 +44,8 @@ export function BoardHeader({

{projectName}

- {/* Usage Popover */} - {isMounted && } + {/* Usage Popover - only show for CLI users (not API key users) */} + {isMounted && showUsageTracking && } {/* Concurrency Slider - only show after mount to prevent hydration issues */} {isMounted && ( diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index b438672a..5100e7e6 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -47,8 +47,12 @@ export function SettingsView() { defaultAIProfileId, setDefaultAIProfileId, aiProfiles, + apiKeys, } = useAppStore(); + // Hide usage tracking when using API key (only show for Claude Code CLI users) + const showUsageTracking = !apiKeys.anthropic; + // Convert electron Project to settings-view Project type const convertProject = ( project: ElectronProject | null @@ -99,7 +103,7 @@ export function SettingsView() { isChecking={isCheckingClaudeCli} onRefresh={handleRefreshClaudeCli} /> - + {showUsageTracking && }
); case "ai-enhancement": From 18ccfa21e0978f7c212e1a17be08b07d3ff584f7 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 20 Dec 2025 23:10:19 -0500 Subject: [PATCH 05/59] feat: enhance terminal service with path validation and session termination improvements - Added path validation in resolveWorkingDirectory to reject paths with null bytes and normalize paths. - Improved killSession method to attempt graceful termination with SIGTERM before falling back to SIGKILL after a delay. - Enhanced logging for session termination to provide clearer feedback on the process. --- apps/server/src/services/terminal-service.ts | 39 +++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index 4d506d25..2a2602ec 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -9,6 +9,7 @@ import * as pty from "node-pty"; import { EventEmitter } from "events"; import * as os from "os"; import * as fs from "fs"; +import * as path from "path"; // Maximum scrollback buffer size (characters) const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal @@ -155,6 +156,7 @@ export class TerminalService extends EventEmitter { /** * Validate and resolve a working directory path + * Includes basic sanitization against null bytes and path normalization */ private resolveWorkingDirectory(requestedCwd?: string): string { const homeDir = os.homedir(); @@ -167,11 +169,21 @@ export class TerminalService extends EventEmitter { // Clean up the path let cwd = requestedCwd.trim(); + // Reject paths with null bytes (could bypass path checks) + if (cwd.includes("\0")) { + console.warn(`[Terminal] Rejecting path with null byte: ${cwd.replace(/\0/g, "\\0")}`); + return homeDir; + } + // Fix double slashes at start (but not for Windows UNC paths) if (cwd.startsWith("//") && !cwd.startsWith("//wsl")) { cwd = cwd.slice(1); } + // Normalize the path to resolve . and .. segments + // This converts relative paths to absolute and cleans up the path + cwd = path.resolve(cwd); + // Check if path exists and is a directory try { const stat = fs.statSync(cwd); @@ -379,6 +391,7 @@ export class TerminalService extends EventEmitter { /** * Kill a terminal session + * Attempts graceful SIGTERM first, then SIGKILL after 1 second if still alive */ killSession(sessionId: string): boolean { const session = this.sessions.get(sessionId); @@ -396,11 +409,27 @@ export class TerminalService extends EventEmitter { clearTimeout(session.resizeDebounceTimeout); session.resizeDebounceTimeout = null; } - // Use SIGKILL for forceful termination - shell processes may ignore SIGTERM/SIGHUP - // This ensures the PTY process is actually killed, especially on WSL - session.pty.kill("SIGKILL"); - this.sessions.delete(sessionId); - console.log(`[Terminal] Session ${sessionId} killed`); + + // First try graceful SIGTERM to allow process cleanup + console.log(`[Terminal] Session ${sessionId} sending SIGTERM`); + session.pty.kill("SIGTERM"); + + // Schedule SIGKILL fallback if process doesn't exit gracefully + // The onExit handler will remove session from map when it actually exits + setTimeout(() => { + if (this.sessions.has(sessionId)) { + console.log(`[Terminal] Session ${sessionId} still alive after SIGTERM, sending SIGKILL`); + try { + session.pty.kill("SIGKILL"); + } catch { + // Process may have already exited + } + // Force remove from map if still present + this.sessions.delete(sessionId); + } + }, 1000); + + console.log(`[Terminal] Session ${sessionId} kill initiated`); return true; } catch (error) { console.error(`[Terminal] Error killing session ${sessionId}:`, error); From 0a2b4287ffed7f3441925c65012ab57fb959f4fb Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:11:16 +0400 Subject: [PATCH 06/59] Update apps/server/src/routes/claude/types.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- apps/server/src/routes/claude/types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/routes/claude/types.ts b/apps/server/src/routes/claude/types.ts index d7baa0c5..2f6eb597 100644 --- a/apps/server/src/routes/claude/types.ts +++ b/apps/server/src/routes/claude/types.ts @@ -15,9 +15,9 @@ export type ClaudeUsage = { weeklyResetTime: string; // ISO date string weeklyResetText: string; // Raw text like "Resets Dec 22 at 7:59pm (Asia/Dubai)" - opusWeeklyTokensUsed: number; - opusWeeklyPercentage: number; - opusResetText: string; // Raw text like "Resets Dec 27 at 9:59am (Asia/Dubai)" + sonnetWeeklyTokensUsed: number; + sonnetWeeklyPercentage: number; + sonnetResetText: string; // Raw text like "Resets Dec 27 at 9:59am (Asia/Dubai)" costUsed: number | null; costLimit: number | null; From 6150926a75dc79e8c4c4058ae42358546238dfae Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:11:24 +0400 Subject: [PATCH 07/59] Update apps/ui/src/lib/electron.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- apps/ui/src/lib/electron.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index a085e8c6..6baa67f6 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -483,7 +483,7 @@ export interface ElectronAPI { ) => Promise<{ success: boolean; error?: string }>; }; claude?: { - getUsage: () => Promise; + getUsage: () => Promise; }; } From 5e789c281721603f1518caa836b079cfdc0f7c4e Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:12:22 +0400 Subject: [PATCH 08/59] refactor: use node-pty instead of expect for cross-platform support Replace Unix-only 'expect' command with node-pty library which works on Windows, macOS, and Linux. Also fixes 'which' command to use 'where' on Windows for checking if Claude CLI is available. --- .../src/services/claude-usage-service.ts | 102 +++++++++--------- 1 file changed, 52 insertions(+), 50 deletions(-) diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index a164a46d..8eaa630d 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -1,4 +1,6 @@ import { spawn } from "child_process"; +import * as os from "os"; +import * as pty from "node-pty"; import { ClaudeUsage } from "../routes/claude/types.js"; /** @@ -8,7 +10,7 @@ import { ClaudeUsage } from "../routes/claude/types.js"; * This approach doesn't require any API keys - it relies on the user * having already authenticated via `claude login`. * - * Based on ClaudeBar's implementation approach. + * Uses node-pty for cross-platform PTY support (Windows, macOS, Linux). */ export class ClaudeUsageService { private claudeBinary = "claude"; @@ -19,7 +21,10 @@ export class ClaudeUsageService { */ async isAvailable(): Promise { return new Promise((resolve) => { - const proc = spawn("which", [this.claudeBinary]); + const isWindows = os.platform() === "win32"; + const checkCmd = isWindows ? "where" : "which"; + + const proc = spawn(checkCmd, [this.claudeBinary]); proc.on("close", (code) => { resolve(code === 0); }); @@ -39,90 +44,87 @@ export class ClaudeUsageService { /** * Execute the claude /usage command and return the output - * Uses 'expect' to provide a pseudo-TTY since claude requires one + * Uses node-pty to provide a pseudo-TTY (cross-platform) */ private executeClaudeUsageCommand(): Promise { return new Promise((resolve, reject) => { - let stdout = ""; - let stderr = ""; + let output = ""; let settled = false; + let hasSeenUsageData = false; - // Use a simple working directory (home or tmp) - const workingDirectory = process.env.HOME || "/tmp"; + // Use home directory as working directory + const workingDirectory = os.homedir() || (os.platform() === "win32" ? process.env.USERPROFILE : "/tmp") || "/tmp"; - // Use 'expect' with an inline script to run claude /usage with a PTY - // Wait for "Current session" header, then wait for full output before exiting - const expectScript = ` - set timeout 20 - spawn claude /usage - expect { - "Current session" { - sleep 2 - send "\\x1b" - } - "Esc to cancel" { - sleep 3 - send "\\x1b" - } - timeout {} - eof {} - } - expect eof - `; + // Determine shell based on platform + const isWindows = os.platform() === "win32"; + const shell = isWindows ? "cmd.exe" : (process.env.SHELL || "/bin/bash"); + const shellArgs = isWindows ? ["/c", "claude", "/usage"] : ["-c", "claude /usage"]; - const proc = spawn("expect", ["-c", expectScript], { + const ptyProcess = pty.spawn(shell, shellArgs, { + name: "xterm-256color", + cols: 120, + rows: 30, cwd: workingDirectory, env: { ...process.env, TERM: "xterm-256color", - }, + } as Record, }); const timeoutId = setTimeout(() => { if (!settled) { settled = true; - proc.kill(); + ptyProcess.kill(); reject(new Error("Command timed out")); } }, this.timeout); - proc.stdout.on("data", (data) => { - stdout += data.toString(); + // Collect output + ptyProcess.onData((data) => { + output += data; + + // Check if we've seen the usage data (look for "Current session") + if (!hasSeenUsageData && output.includes("Current session")) { + hasSeenUsageData = true; + + // Wait a bit for full output, then send escape to exit + setTimeout(() => { + if (!settled) { + ptyProcess.write("\x1b"); // Send escape key + } + }, 2000); + } + + // Fallback: if we see "Esc to cancel" but haven't seen usage data yet + if (!hasSeenUsageData && output.includes("Esc to cancel")) { + setTimeout(() => { + if (!settled) { + ptyProcess.write("\x1b"); // Send escape key + } + }, 3000); + } }); - proc.stderr.on("data", (data) => { - stderr += data.toString(); - }); - - proc.on("close", (code) => { + ptyProcess.onExit(({ exitCode }) => { clearTimeout(timeoutId); if (settled) return; settled = true; // Check for authentication errors in output - if (stdout.includes("token_expired") || stdout.includes("authentication_error") || - stderr.includes("token_expired") || stderr.includes("authentication_error")) { + if (output.includes("token_expired") || output.includes("authentication_error")) { reject(new Error("Authentication required - please run 'claude login'")); return; } // Even if exit code is non-zero, we might have useful output - if (stdout.trim()) { - resolve(stdout); - } else if (code !== 0) { - reject(new Error(stderr || `Command exited with code ${code}`)); + if (output.trim()) { + resolve(output); + } else if (exitCode !== 0) { + reject(new Error(`Command exited with code ${exitCode}`)); } else { reject(new Error("No output from claude command")); } }); - - proc.on("error", (err) => { - clearTimeout(timeoutId); - if (!settled) { - settled = true; - reject(new Error(`Failed to execute claude: ${err.message}`)); - } - }); }); } From 0e944e274aa49504f185d5c9e9af30f75a045a5e Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 20 Dec 2025 23:13:30 -0500 Subject: [PATCH 09/59] feat: increase maximum terminal session limit and improve path handling - Updated the maximum terminal session limit from 500 to 1000 to accommodate more concurrent sessions. - Enhanced path handling in the editor and HTTP API client to normalize file paths for both Unix and Windows systems, ensuring consistent URL encoding. --- apps/server/src/services/terminal-service.ts | 2 +- apps/ui/src/lib/http-api-client.ts | 9 +++++---- apps/ui/src/main.ts | 8 +++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index 2a2602ec..b569ec28 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -16,7 +16,7 @@ const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal // Session limit constants - shared with routes/settings.ts export const MIN_MAX_SESSIONS = 1; -export const MAX_MAX_SESSIONS = 500; +export const MAX_MAX_SESSIONS = 1000; // Maximum number of concurrent terminal sessions // Can be overridden via TERMINAL_MAX_SESSIONS environment variable diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 037aea13..c3694f99 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -225,10 +225,11 @@ export class HttpApiClient implements ElectronAPI { // Build VS Code URL scheme: vscode://file/path:line:column // This works on systems where VS Code's URL handler is registered // URL encode the path to handle special characters (spaces, brackets, etc.) - // but preserve the leading slash for absolute paths - const encodedPath = filePath.startsWith('/') - ? '/' + filePath.slice(1).split('/').map(encodeURIComponent).join('/') - : filePath.split('/').map(encodeURIComponent).join('/'); + // Handle both Unix (/) and Windows (\) path separators + const normalizedPath = filePath.replace(/\\/g, '/'); + const encodedPath = normalizedPath.startsWith('/') + ? '/' + normalizedPath.slice(1).split('/').map(encodeURIComponent).join('/') + : normalizedPath.split('/').map(encodeURIComponent).join('/'); let url = `vscode://file${encodedPath}`; if (line !== undefined && line > 0) { url += `:${line}`; diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index e650f686..a07ec8ab 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -417,9 +417,11 @@ ipcMain.handle("shell:openInEditor", async (_, filePath: string, line?: number, // Build VS Code URL scheme: vscode://file/path:line:column // This works on all platforms where VS Code is installed // URL encode the path to handle special characters (spaces, brackets, etc.) - const encodedPath = filePath.startsWith('/') - ? '/' + filePath.slice(1).split('/').map(encodeURIComponent).join('/') - : filePath.split('/').map(encodeURIComponent).join('/'); + // Handle both Unix (/) and Windows (\) path separators + const normalizedPath = filePath.replace(/\\/g, '/'); + const encodedPath = normalizedPath.startsWith('/') + ? '/' + normalizedPath.slice(1).split('/').map(encodeURIComponent).join('/') + : normalizedPath.split('/').map(encodeURIComponent).join('/'); let url = `vscode://file${encodedPath}`; if (line !== undefined && line > 0) { url += `:${line}`; From 86cbb2f9708258c51809d4b1e46c306c3dadc948 Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:17:51 +0400 Subject: [PATCH 10/59] Revert "refactor: use node-pty instead of expect for cross-platform support" This reverts commit 5e789c281721603f1518caa836b079cfdc0f7c4e. --- .../src/services/claude-usage-service.ts | 102 +++++++++--------- 1 file changed, 50 insertions(+), 52 deletions(-) diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 8eaa630d..a164a46d 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -1,6 +1,4 @@ import { spawn } from "child_process"; -import * as os from "os"; -import * as pty from "node-pty"; import { ClaudeUsage } from "../routes/claude/types.js"; /** @@ -10,7 +8,7 @@ import { ClaudeUsage } from "../routes/claude/types.js"; * This approach doesn't require any API keys - it relies on the user * having already authenticated via `claude login`. * - * Uses node-pty for cross-platform PTY support (Windows, macOS, Linux). + * Based on ClaudeBar's implementation approach. */ export class ClaudeUsageService { private claudeBinary = "claude"; @@ -21,10 +19,7 @@ export class ClaudeUsageService { */ async isAvailable(): Promise { return new Promise((resolve) => { - const isWindows = os.platform() === "win32"; - const checkCmd = isWindows ? "where" : "which"; - - const proc = spawn(checkCmd, [this.claudeBinary]); + const proc = spawn("which", [this.claudeBinary]); proc.on("close", (code) => { resolve(code === 0); }); @@ -44,87 +39,90 @@ export class ClaudeUsageService { /** * Execute the claude /usage command and return the output - * Uses node-pty to provide a pseudo-TTY (cross-platform) + * Uses 'expect' to provide a pseudo-TTY since claude requires one */ private executeClaudeUsageCommand(): Promise { return new Promise((resolve, reject) => { - let output = ""; + let stdout = ""; + let stderr = ""; let settled = false; - let hasSeenUsageData = false; - // Use home directory as working directory - const workingDirectory = os.homedir() || (os.platform() === "win32" ? process.env.USERPROFILE : "/tmp") || "/tmp"; + // Use a simple working directory (home or tmp) + const workingDirectory = process.env.HOME || "/tmp"; - // Determine shell based on platform - const isWindows = os.platform() === "win32"; - const shell = isWindows ? "cmd.exe" : (process.env.SHELL || "/bin/bash"); - const shellArgs = isWindows ? ["/c", "claude", "/usage"] : ["-c", "claude /usage"]; + // Use 'expect' with an inline script to run claude /usage with a PTY + // Wait for "Current session" header, then wait for full output before exiting + const expectScript = ` + set timeout 20 + spawn claude /usage + expect { + "Current session" { + sleep 2 + send "\\x1b" + } + "Esc to cancel" { + sleep 3 + send "\\x1b" + } + timeout {} + eof {} + } + expect eof + `; - const ptyProcess = pty.spawn(shell, shellArgs, { - name: "xterm-256color", - cols: 120, - rows: 30, + const proc = spawn("expect", ["-c", expectScript], { cwd: workingDirectory, env: { ...process.env, TERM: "xterm-256color", - } as Record, + }, }); const timeoutId = setTimeout(() => { if (!settled) { settled = true; - ptyProcess.kill(); + proc.kill(); reject(new Error("Command timed out")); } }, this.timeout); - // Collect output - ptyProcess.onData((data) => { - output += data; - - // Check if we've seen the usage data (look for "Current session") - if (!hasSeenUsageData && output.includes("Current session")) { - hasSeenUsageData = true; - - // Wait a bit for full output, then send escape to exit - setTimeout(() => { - if (!settled) { - ptyProcess.write("\x1b"); // Send escape key - } - }, 2000); - } - - // Fallback: if we see "Esc to cancel" but haven't seen usage data yet - if (!hasSeenUsageData && output.includes("Esc to cancel")) { - setTimeout(() => { - if (!settled) { - ptyProcess.write("\x1b"); // Send escape key - } - }, 3000); - } + proc.stdout.on("data", (data) => { + stdout += data.toString(); }); - ptyProcess.onExit(({ exitCode }) => { + proc.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { clearTimeout(timeoutId); if (settled) return; settled = true; // Check for authentication errors in output - if (output.includes("token_expired") || output.includes("authentication_error")) { + if (stdout.includes("token_expired") || stdout.includes("authentication_error") || + stderr.includes("token_expired") || stderr.includes("authentication_error")) { reject(new Error("Authentication required - please run 'claude login'")); return; } // Even if exit code is non-zero, we might have useful output - if (output.trim()) { - resolve(output); - } else if (exitCode !== 0) { - reject(new Error(`Command exited with code ${exitCode}`)); + if (stdout.trim()) { + resolve(stdout); + } else if (code !== 0) { + reject(new Error(stderr || `Command exited with code ${code}`)); } else { reject(new Error("No output from claude command")); } }); + + proc.on("error", (err) => { + clearTimeout(timeoutId); + if (!settled) { + settled = true; + reject(new Error(`Failed to execute claude: ${err.message}`)); + } + }); }); } From 39b21830dc6bb624e08accaebf2d4cfcbadafe40 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 20 Dec 2025 23:18:13 -0500 Subject: [PATCH 11/59] feat: validate maxSessions input in settings update handler - Added validation to ensure maxSessions is an integer before processing the request. - Responds with a 400 status and an error message if the input is not a valid integer. --- .../src/routes/terminal/routes/settings.ts | 7 ++++ .../ui/src/components/views/terminal-view.tsx | 32 +++++++++++++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/server/src/routes/terminal/routes/settings.ts b/apps/server/src/routes/terminal/routes/settings.ts index f4d6c007..21151ee6 100644 --- a/apps/server/src/routes/terminal/routes/settings.ts +++ b/apps/server/src/routes/terminal/routes/settings.ts @@ -26,6 +26,13 @@ export function createSettingsUpdateHandler() { const { maxSessions } = req.body; if (typeof maxSessions === "number") { + if (!Number.isInteger(maxSessions)) { + res.status(400).json({ + success: false, + error: "maxSessions must be an integer", + }); + return; + } if (maxSessions < MIN_MAX_SESSIONS || maxSessions > MAX_MAX_SESSIONS) { res.status(400).json({ success: false, diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 4d801e42..3559d6b7 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -245,7 +245,7 @@ export function TerminalView() { const lastCreateTimeRef = useRef(0); const isCreatingRef = useRef(false); const prevProjectPathRef = useRef(null); - const isRestoringLayoutRef = useRef(false); + const restoringProjectPathRef = useRef(null); const [newSessionIds, setNewSessionIds] = useState>(new Set()); const [serverSessionInfo, setServerSessionInfo] = useState<{ current: number; max: number } | null>(null); const hasShownHighRamWarningRef = useRef(false); @@ -438,11 +438,14 @@ export function TerminalView() { const currentPath = currentProject?.path || null; const prevPath = prevProjectPathRef.current; - // Skip if no change or if we're restoring layout - if (currentPath === prevPath || isRestoringLayoutRef.current) { + // Skip if no change + if (currentPath === prevPath) { return; } + // If we're restoring a different project, that restore will be stale - let it finish but ignore results + // The path check in restoreLayout will handle this + // Save layout for previous project (if there was one and has terminals) if (prevPath && terminalState.tabs.length > 0) { saveTerminalLayout(prevPath); @@ -462,13 +465,20 @@ export function TerminalView() { if (savedLayout && savedLayout.tabs.length > 0) { // Restore the saved layout - try to reconnect to existing sessions - isRestoringLayoutRef.current = true; + // Track which project we're restoring to detect stale restores + restoringProjectPathRef.current = currentPath; // Clear existing terminals first (only client state, sessions stay on server) clearTerminalState(); // Create terminals and build layout - try to reconnect or create new const restoreLayout = async () => { + // Check if we're still restoring the same project (user may have switched) + if (restoringProjectPathRef.current !== currentPath) { + console.log("[Terminal] Restore cancelled - project changed"); + return; + } + let failedSessions = 0; let totalSessions = 0; let reconnectedSessions = 0; @@ -578,6 +588,12 @@ export function TerminalView() { // For each saved tab, rebuild the layout for (let tabIndex = 0; tabIndex < savedLayout.tabs.length; tabIndex++) { + // Check if project changed during restore - bail out early + if (restoringProjectPathRef.current !== currentPath) { + console.log("[Terminal] Restore cancelled mid-loop - project changed"); + return; + } + const savedTab = savedLayout.tabs[tabIndex]; // Create the tab first @@ -619,7 +635,10 @@ export function TerminalView() { duration: 5000, }); } finally { - isRestoringLayoutRef.current = false; + // Only clear if we're still the active restore + if (restoringProjectPathRef.current === currentPath) { + restoringProjectPathRef.current = null; + } } }; @@ -631,7 +650,8 @@ export function TerminalView() { // Also save when tabs become empty so closed terminals stay closed on refresh const saveLayoutTimeoutRef = useRef(null); useEffect(() => { - if (currentProject?.path && !isRestoringLayoutRef.current) { + // Don't save while restoring this project's layout + if (currentProject?.path && restoringProjectPathRef.current !== currentProject.path) { // Debounce saves to prevent excessive localStorage writes during rapid changes if (saveLayoutTimeoutRef.current) { clearTimeout(saveLayoutTimeoutRef.current); From 8f5e782583b928c0273229622799c5daa55c7854 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 20 Dec 2025 23:20:31 -0500 Subject: [PATCH 12/59] refactor: update token generation method and improve maxSessions validation - Changed the token generation method to use slice instead of substr for better readability. - Enhanced maxSessions validation in the settings update handler to check for undefined values and ensure the input is a number before processing. --- apps/server/src/routes/terminal/common.ts | 2 +- apps/server/src/routes/terminal/routes/settings.ts | 10 +++++++++- apps/server/src/services/terminal-service.ts | 2 +- apps/ui/src/components/views/terminal-view.tsx | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/server/src/routes/terminal/common.ts b/apps/server/src/routes/terminal/common.ts index 80b3a496..85039c39 100644 --- a/apps/server/src/routes/terminal/common.ts +++ b/apps/server/src/routes/terminal/common.ts @@ -54,7 +54,7 @@ export function getTokenData( export function generateToken(): string { return `term-${Date.now()}-${Math.random() .toString(36) - .substr(2, 15)}${Math.random().toString(36).substr(2, 15)}`; + .slice(2, 17)}${Math.random().toString(36).slice(2, 17)}`; } /** diff --git a/apps/server/src/routes/terminal/routes/settings.ts b/apps/server/src/routes/terminal/routes/settings.ts index 21151ee6..08e670ae 100644 --- a/apps/server/src/routes/terminal/routes/settings.ts +++ b/apps/server/src/routes/terminal/routes/settings.ts @@ -25,7 +25,15 @@ export function createSettingsUpdateHandler() { const terminalService = getTerminalService(); const { maxSessions } = req.body; - if (typeof maxSessions === "number") { + // Validate maxSessions if provided + if (maxSessions !== undefined) { + if (typeof maxSessions !== "number") { + res.status(400).json({ + success: false, + error: "maxSessions must be a number", + }); + return; + } if (!Number.isInteger(maxSessions)) { res.status(400).json({ success: false, diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index b569ec28..c11af057 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -233,7 +233,7 @@ export class TerminalService extends EventEmitter { return null; } - const id = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; const { shell: detectedShell, args: shellArgs } = this.detectShell(); const shell = options.shell || detectedShell; diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 3559d6b7..5f2b1a42 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -579,7 +579,7 @@ export function TerminalView() { return { type: "split", - id: persisted.id || `split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + id: persisted.id || `split-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`, direction: persisted.direction, panels: childPanels, size: persisted.size, From 7416c8b428bd05c154854d82d28d8fdd27718df6 Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:23:56 +0400 Subject: [PATCH 13/59] style: removed tiny clock --- apps/ui/src/components/claude-usage-popover.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx index 23182744..fcff9320 100644 --- a/apps/ui/src/components/claude-usage-popover.tsx +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -158,7 +158,7 @@ export function ClaudeUsagePopover() { {resetText && (

- + {title === "Session Usage" && } {resetText}

From 6533a15653cabb6528452e3c077e8537a833bb8f Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:26:18 +0400 Subject: [PATCH 14/59] feat: add Windows support using node-pty while keeping expect for macOS Platform-specific implementations: - macOS: Uses 'expect' command (unchanged, working) - Windows: Uses node-pty for PTY support Also fixes 'which' vs 'where' for checking Claude CLI availability. --- .../src/services/claude-usage-service.ts | 114 ++++++++++++++++-- 1 file changed, 103 insertions(+), 11 deletions(-) diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index a164a46d..7b745bae 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -1,4 +1,6 @@ import { spawn } from "child_process"; +import * as os from "os"; +import * as pty from "node-pty"; import { ClaudeUsage } from "../routes/claude/types.js"; /** @@ -8,18 +10,22 @@ import { ClaudeUsage } from "../routes/claude/types.js"; * This approach doesn't require any API keys - it relies on the user * having already authenticated via `claude login`. * - * Based on ClaudeBar's implementation approach. + * Platform-specific implementations: + * - macOS: Uses 'expect' command for PTY + * - Windows: Uses node-pty for PTY */ export class ClaudeUsageService { private claudeBinary = "claude"; private timeout = 30000; // 30 second timeout + private isWindows = os.platform() === "win32"; /** * Check if Claude CLI is available on the system */ async isAvailable(): Promise { return new Promise((resolve) => { - const proc = spawn("which", [this.claudeBinary]); + const checkCmd = this.isWindows ? "where" : "which"; + const proc = spawn(checkCmd, [this.claudeBinary]); proc.on("close", (code) => { resolve(code === 0); }); @@ -39,9 +45,19 @@ export class ClaudeUsageService { /** * Execute the claude /usage command and return the output - * Uses 'expect' to provide a pseudo-TTY since claude requires one + * Uses platform-specific PTY implementation */ private executeClaudeUsageCommand(): Promise { + if (this.isWindows) { + return this.executeClaudeUsageCommandWindows(); + } + return this.executeClaudeUsageCommandMac(); + } + + /** + * macOS implementation using 'expect' command + */ + private executeClaudeUsageCommandMac(): Promise { return new Promise((resolve, reject) => { let stdout = ""; let stderr = ""; @@ -126,6 +142,82 @@ export class ClaudeUsageService { }); } + /** + * Windows implementation using node-pty + */ + private executeClaudeUsageCommandWindows(): Promise { + return new Promise((resolve, reject) => { + let output = ""; + let settled = false; + let hasSeenUsageData = false; + + const workingDirectory = process.env.USERPROFILE || os.homedir() || "C:\\"; + + const ptyProcess = pty.spawn("cmd.exe", ["/c", "claude", "/usage"], { + name: "xterm-256color", + cols: 120, + rows: 30, + cwd: workingDirectory, + env: { + ...process.env, + TERM: "xterm-256color", + } as Record, + }); + + const timeoutId = setTimeout(() => { + if (!settled) { + settled = true; + ptyProcess.kill(); + reject(new Error("Command timed out")); + } + }, this.timeout); + + ptyProcess.onData((data) => { + output += data; + + // Check if we've seen the usage data (look for "Current session") + if (!hasSeenUsageData && output.includes("Current session")) { + hasSeenUsageData = true; + // Wait for full output, then send escape to exit + setTimeout(() => { + if (!settled) { + ptyProcess.write("\x1b"); // Send escape key + } + }, 2000); + } + + // Fallback: if we see "Esc to cancel" but haven't seen usage data yet + if (!hasSeenUsageData && output.includes("Esc to cancel")) { + setTimeout(() => { + if (!settled) { + ptyProcess.write("\x1b"); // Send escape key + } + }, 3000); + } + }); + + ptyProcess.onExit(({ exitCode }) => { + clearTimeout(timeoutId); + if (settled) return; + settled = true; + + // Check for authentication errors in output + if (output.includes("token_expired") || output.includes("authentication_error")) { + reject(new Error("Authentication required - please run 'claude login'")); + return; + } + + if (output.trim()) { + resolve(output); + } else if (exitCode !== 0) { + reject(new Error(`Command exited with code ${exitCode}`)); + } else { + reject(new Error("No output from claude command")); + } + }); + }); + } + /** * Strip ANSI escape codes from text */ @@ -165,12 +257,12 @@ export class ClaudeUsageService { const weeklyData = this.parseSection(lines, "Current week (all models)", "weekly"); // Parse Sonnet/Opus usage - try different labels - let opusData = this.parseSection(lines, "Current week (Sonnet only)", "opus"); - if (opusData.percentage === 0) { - opusData = this.parseSection(lines, "Current week (Sonnet)", "opus"); + let sonnetData = this.parseSection(lines, "Current week (Sonnet only)", "sonnet"); + if (sonnetData.percentage === 0) { + sonnetData = this.parseSection(lines, "Current week (Sonnet)", "sonnet"); } - if (opusData.percentage === 0) { - opusData = this.parseSection(lines, "Current week (Opus)", "opus"); + if (sonnetData.percentage === 0) { + sonnetData = this.parseSection(lines, "Current week (Opus)", "sonnet"); } return { @@ -186,9 +278,9 @@ export class ClaudeUsageService { weeklyResetTime: weeklyData.resetTime, weeklyResetText: weeklyData.resetText, - opusWeeklyTokensUsed: 0, // Not available from CLI - opusWeeklyPercentage: opusData.percentage, - opusResetText: opusData.resetText, + sonnetWeeklyTokensUsed: 0, // Not available from CLI + sonnetWeeklyPercentage: sonnetData.percentage, + sonnetResetText: sonnetData.resetText, costUsed: null, // Not available from CLI costLimit: null, From 820f43078b6870fb8ab9a28e2aec4bf09e5cb2f5 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 20 Dec 2025 23:26:28 -0500 Subject: [PATCH 15/59] feat: enhance terminal input validation and update keyboard shortcuts - Added validation for terminal input to ensure it is a string and limited to 1MB to prevent memory issues. - Implemented checks for terminal resize dimensions to ensure they are positive integers within specified bounds. - Updated keyboard shortcuts for terminal actions to use Alt key combinations instead of Ctrl+Shift for better accessibility. --- apps/server/src/index.ts | 23 +++++++++++++++++++ apps/server/src/routes/terminal/common.ts | 7 +++--- .../views/terminal-view/terminal-panel.tsx | 22 +++++++++--------- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index a4b32872..40c69377 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -297,11 +297,34 @@ terminalWss.on( switch (msg.type) { case "input": + // Validate input data type and length + if (typeof msg.data !== "string") { + ws.send(JSON.stringify({ type: "error", message: "Invalid input type" })); + break; + } + // Limit input size to 1MB to prevent memory issues + if (msg.data.length > 1024 * 1024) { + ws.send(JSON.stringify({ type: "error", message: "Input too large" })); + break; + } // Write user input to terminal terminalService.write(sessionId, msg.data); break; case "resize": + // Validate resize dimensions are positive integers within reasonable bounds + if ( + typeof msg.cols !== "number" || + typeof msg.rows !== "number" || + !Number.isInteger(msg.cols) || + !Number.isInteger(msg.rows) || + msg.cols < 1 || + msg.cols > 1000 || + msg.rows < 1 || + msg.rows > 500 + ) { + break; // Silently ignore invalid resize requests + } // Resize terminal with deduplication and rate limiting if (msg.cols && msg.rows) { const now = Date.now(); diff --git a/apps/server/src/routes/terminal/common.ts b/apps/server/src/routes/terminal/common.ts index 85039c39..7ce223d6 100644 --- a/apps/server/src/routes/terminal/common.ts +++ b/apps/server/src/routes/terminal/common.ts @@ -2,6 +2,7 @@ * Common utilities and state for terminal routes */ +import { randomBytes } from "crypto"; import { createLogger } from "../../lib/logger.js"; import type { Request, Response, NextFunction } from "express"; import { getTerminalService } from "../../services/terminal-service.js"; @@ -49,12 +50,10 @@ export function getTokenData( } /** - * Generate a secure random token + * Generate a cryptographically secure random token */ export function generateToken(): string { - return `term-${Date.now()}-${Math.random() - .toString(36) - .slice(2, 17)}${Math.random().toString(36).slice(2, 17)}`; + return `term-${randomBytes(32).toString("base64url")}`; } /** diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index 2ee46236..4a10cc11 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -668,8 +668,8 @@ export function TerminalPanel({ // Use event.code for keyboard-layout-independent key detection const code = event.code; - // Ctrl+Shift+D - Split right (uses Ctrl+Shift to avoid Alt+D readline conflict) - if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && code === 'KeyD') { + // Alt+D - Split right + if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyD') { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -678,8 +678,8 @@ export function TerminalPanel({ return false; } - // Ctrl+Shift+S - Split down (uses Ctrl+Shift to avoid readline conflicts) - if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && code === 'KeyS') { + // Alt+S - Split down + if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyS') { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -688,8 +688,8 @@ export function TerminalPanel({ return false; } - // Ctrl+Shift+W - Close terminal (uses Ctrl+Shift to avoid readline conflicts) - if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && code === 'KeyW') { + // Alt+W - Close terminal + if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyW') { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -698,8 +698,8 @@ export function TerminalPanel({ return false; } - // Ctrl+Shift+T - New terminal tab (uses Ctrl+Shift for consistency) - if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && code === 'KeyT') { + // Alt+T - New terminal tab + if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyT') { event.preventDefault(); if (canTrigger && onNewTabRef.current) { lastShortcutTimeRef.current = now; @@ -1757,7 +1757,7 @@ export function TerminalPanel({ e.stopPropagation(); onSplitHorizontal(); }} - title="Split Right (Cmd+D)" + title="Split Right (Alt+D)" > @@ -1769,7 +1769,7 @@ export function TerminalPanel({ e.stopPropagation(); onSplitVertical(); }} - title="Split Down (Cmd+Shift+D)" + title="Split Down (Alt+S)" > @@ -1799,7 +1799,7 @@ export function TerminalPanel({ e.stopPropagation(); onClose(); }} - title="Close Terminal (Cmd+W)" + title="Close Terminal (Alt+W)" > From f2582c44539ecccbcaaf54739659bf38d4200413 Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:32:30 +0400 Subject: [PATCH 16/59] fix: handle NaN percentage values and rename opus to sonnet - Show 'N/A' and dim card when percentage is NaN/invalid - Use gray progress bar for invalid values - Rename opusWeekly* properties to sonnetWeekly* to match server types --- .../src/components/claude-usage-popover.tsx | 42 +++++++++++-------- apps/ui/src/lib/electron.ts | 6 +-- apps/ui/src/store/app-store.ts | 6 +-- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx index fcff9320..84131e8d 100644 --- a/apps/ui/src/components/claude-usage-popover.tsx +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -123,7 +123,11 @@ export function ClaudeUsagePopover() { isPrimary?: boolean; stale?: boolean; }) => { - const status = getStatusInfo(percentage); + // Check if percentage is valid (not NaN, not undefined, is a finite number) + const isValidPercentage = typeof percentage === "number" && !isNaN(percentage) && isFinite(percentage); + const safePercentage = isValidPercentage ? percentage : 0; + + const status = getStatusInfo(safePercentage); const StatusIcon = status.icon; return ( @@ -131,7 +135,7 @@ export function ClaudeUsagePopover() { className={cn( "rounded-xl border bg-card/50 p-4 transition-opacity", isPrimary ? "border-border/60 shadow-sm" : "border-border/40", - stale && "opacity-60" + (stale || !isValidPercentage) && "opacity-50" )} >
@@ -141,20 +145,24 @@ export function ClaudeUsagePopover() {

{subtitle}

-
- - - {Math.round(percentage)}% - -
+ {isValidPercentage ? ( +
+ + + {Math.round(safePercentage)}% + +
+ ) : ( + N/A + )}
- + {resetText && (

@@ -267,8 +275,8 @@ export function ClaudeUsagePopover() {

diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 6baa67f6..5e01b492 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -898,9 +898,9 @@ const getMockElectronAPI = (): ElectronAPI => { weeklyPercentage: 5, weeklyResetTime: new Date(Date.now() + 86400000 * 2).toISOString(), weeklyResetText: "Resets Dec 23", - opusWeeklyTokensUsed: 0, - opusWeeklyPercentage: 1, - opusResetText: "Resets Dec 27", + sonnetWeeklyTokensUsed: 0, + sonnetWeeklyPercentage: 1, + sonnetResetText: "Resets Dec 27", costUsed: null, costLimit: null, costCurrency: null, diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 1601b1e8..365962c9 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -528,9 +528,9 @@ export interface ClaudeUsage { weeklyResetTime: string; weeklyResetText: string; - opusWeeklyTokensUsed: number; - opusWeeklyPercentage: number; - opusResetText: string; + sonnetWeeklyTokensUsed: number; + sonnetWeeklyPercentage: number; + sonnetResetText: string; costUsed: number | null; costLimit: number | null; From f504a00ce6ff1ff9e1728a02c7fcc3dc5e1f221c Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 20 Dec 2025 23:35:03 -0500 Subject: [PATCH 17/59] feat: improve error handling in terminal settings retrieval and enhance path normalization - Wrapped the terminal settings retrieval in a try-catch block to handle potential errors and respond with a 500 status and error details. - Updated path normalization logic to skip resolution for WSL UNC paths, preventing potential issues with path handling in Windows Subsystem for Linux. - Enhanced unit tests for session termination to include timer-based assertions for graceful session killing. --- .../src/routes/terminal/routes/settings.ts | 25 +++++++++++++------ apps/server/src/services/terminal-service.ts | 6 +++-- .../unit/services/terminal-service.test.ts | 10 +++++++- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/apps/server/src/routes/terminal/routes/settings.ts b/apps/server/src/routes/terminal/routes/settings.ts index 08e670ae..3af142a8 100644 --- a/apps/server/src/routes/terminal/routes/settings.ts +++ b/apps/server/src/routes/terminal/routes/settings.ts @@ -8,14 +8,23 @@ import { getErrorMessage, logError } from "../common.js"; export function createSettingsGetHandler() { return (_req: Request, res: Response): void => { - const terminalService = getTerminalService(); - res.json({ - success: true, - data: { - maxSessions: terminalService.getMaxSessions(), - currentSessions: terminalService.getSessionCount(), - }, - }); + try { + const terminalService = getTerminalService(); + res.json({ + success: true, + data: { + maxSessions: terminalService.getMaxSessions(), + currentSessions: terminalService.getSessionCount(), + }, + }); + } catch (error) { + logError(error, "Get terminal settings failed"); + res.status(500).json({ + success: false, + error: "Failed to get terminal settings", + details: getErrorMessage(error), + }); + } }; } diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index c11af057..8eb0d76b 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -181,8 +181,10 @@ export class TerminalService extends EventEmitter { } // Normalize the path to resolve . and .. segments - // This converts relative paths to absolute and cleans up the path - cwd = path.resolve(cwd); + // Skip normalization for WSL UNC paths as path.resolve would break them + if (!cwd.startsWith("//wsl")) { + cwd = path.resolve(cwd); + } // Check if path exists and is a directory try { diff --git a/apps/server/tests/unit/services/terminal-service.test.ts b/apps/server/tests/unit/services/terminal-service.test.ts index d273061a..b20e8047 100644 --- a/apps/server/tests/unit/services/terminal-service.test.ts +++ b/apps/server/tests/unit/services/terminal-service.test.ts @@ -408,6 +408,7 @@ describe("terminal-service.ts", () => { describe("killSession", () => { it("should kill existing session", () => { + vi.useFakeTimers(); vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); @@ -416,8 +417,15 @@ describe("terminal-service.ts", () => { const result = service.killSession(session.id); expect(result).toBe(true); - expect(mockPtyProcess.kill).toHaveBeenCalled(); + expect(mockPtyProcess.kill).toHaveBeenCalledWith("SIGTERM"); + + // Session is removed after SIGKILL timeout (1 second) + vi.advanceTimersByTime(1000); + + expect(mockPtyProcess.kill).toHaveBeenCalledWith("SIGKILL"); expect(service.getSession(session.id)).toBeUndefined(); + + vi.useRealTimers(); }); it("should return false for non-existent session", () => { From ab0487664aff21177e456da7b26f694fb48369e2 Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:46:11 +0400 Subject: [PATCH 18/59] feat: integrate ClaudeUsageService and update API routes for usage tracking --- apps/server/src/index.ts | 4 +++- apps/server/src/routes/claude/index.ts | 3 +-- .../src/components/claude-usage-popover.tsx | 20 +++++++------------ apps/ui/src/lib/electron.ts | 3 ++- apps/ui/src/lib/http-api-client.ts | 4 ++-- apps/ui/src/store/app-store.ts | 12 +++++++++-- 6 files changed, 25 insertions(+), 21 deletions(-) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 51e92e80..852c5ddf 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -45,6 +45,7 @@ import { getTerminalService } from "./services/terminal-service.js"; import { SettingsService } from "./services/settings-service.js"; import { createSpecRegenerationRoutes } from "./routes/app-spec/index.js"; import { createClaudeRoutes } from "./routes/claude/index.js"; +import { ClaudeUsageService } from "./services/claude-usage-service.js"; // Load environment variables dotenv.config(); @@ -112,6 +113,7 @@ const agentService = new AgentService(DATA_DIR, events); const featureLoader = new FeatureLoader(); const autoModeService = new AutoModeService(events); const settingsService = new SettingsService(DATA_DIR); +const claudeUsageService = new ClaudeUsageService(); // Initialize services (async () => { @@ -142,7 +144,7 @@ app.use("/api/workspace", createWorkspaceRoutes()); app.use("/api/templates", createTemplatesRoutes()); app.use("/api/terminal", createTerminalRoutes()); app.use("/api/settings", createSettingsRoutes(settingsService)); -app.use("/api/claude", createClaudeRoutes()); +app.use("/api/claude", createClaudeRoutes(claudeUsageService)); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/routes/claude/index.ts b/apps/server/src/routes/claude/index.ts index 8f359d5f..f951aa34 100644 --- a/apps/server/src/routes/claude/index.ts +++ b/apps/server/src/routes/claude/index.ts @@ -1,9 +1,8 @@ import { Router, Request, Response } from "express"; import { ClaudeUsageService } from "../../services/claude-usage-service.js"; -export function createClaudeRoutes(): Router { +export function createClaudeRoutes(service: ClaudeUsageService): Router { const router = Router(); - const service = new ClaudeUsageService(); // Get current usage (fetches from Claude CLI) router.get("/usage", async (req: Request, res: Response) => { diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx index 84131e8d..3288e5f1 100644 --- a/apps/ui/src/components/claude-usage-popover.tsx +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useCallback } from "react"; import { Popover, PopoverContent, @@ -29,7 +29,7 @@ export function ClaudeUsagePopover() { return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; }, [claudeUsageLastUpdated]); - const fetchUsage = async (isAutoRefresh = false) => { + const fetchUsage = useCallback(async (isAutoRefresh = false) => { if (!isAutoRefresh) setLoading(true); setError(null); try { @@ -38,7 +38,7 @@ export function ClaudeUsagePopover() { throw new Error("Claude API not available"); } const data = await api.claude.getUsage(); - if (data.error) { + if ("error" in data) { throw new Error(data.message || data.error); } setClaudeUsage(data); @@ -47,26 +47,20 @@ export function ClaudeUsagePopover() { } finally { if (!isAutoRefresh) setLoading(false); } - }; + }, [setClaudeUsage]); // Auto-fetch on mount if data is stale useEffect(() => { if (isStale) { fetchUsage(true); } - }, []); + }, [isStale, fetchUsage]); useEffect(() => { // Initial fetch when opened if (open) { - if (!claudeUsage) { + if (!claudeUsage || isStale) { fetchUsage(); - } else { - const now = Date.now(); - const stale = !claudeUsageLastUpdated || now - claudeUsageLastUpdated > 2 * 60 * 1000; - if (stale) { - fetchUsage(false); - } } } @@ -81,7 +75,7 @@ export function ClaudeUsagePopover() { return () => { if (intervalId) clearInterval(intervalId); }; - }, [open]); + }, [open, claudeUsage, isStale, claudeRefreshInterval, fetchUsage]); // Derived status color/icon helper const getStatusInfo = (percentage: number) => { diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 5e01b492..bdb09748 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1,5 +1,6 @@ // Type definitions for Electron IPC API import type { SessionListItem, Message } from "@/types/electron"; +import type { ClaudeUsageResponse } from "@/store/app-store"; import { getJSON, setJSON, removeItem } from "./storage"; export interface FileEntry { @@ -483,7 +484,7 @@ export interface ElectronAPI { ) => Promise<{ success: boolean; error?: string }>; }; claude?: { - getUsage: () => Promise; + getUsage: () => Promise; }; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index b78e8596..b713472a 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -24,7 +24,7 @@ import type { SuggestionType, } from "./electron"; import type { Message, SessionListItem } from "@/types/electron"; -import type { Feature } from "@/store/app-store"; +import type { Feature, ClaudeUsageResponse } from "@/store/app-store"; import type { WorktreeAPI, GitAPI, @@ -1019,7 +1019,7 @@ export class HttpApiClient implements ElectronAPI { // Claude API claude = { - getUsage: (): Promise => this.get("/api/claude/usage"), + getUsage: (): Promise => this.get("/api/claude/usage"), }; } diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 365962c9..4afafc2c 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -515,7 +515,7 @@ export interface AppState { } // Claude Usage interface matching the server response -export interface ClaudeUsage { +export type ClaudeUsage = { sessionTokensUsed: number; sessionLimit: number; sessionPercentage: number; @@ -535,7 +535,15 @@ export interface ClaudeUsage { costUsed: number | null; costLimit: number | null; costCurrency: string | null; -} + + lastUpdated: string; + userTimezone: string; +}; + +// Response type for Claude usage API (can be success or error) +export type ClaudeUsageResponse = + | ClaudeUsage + | { error: string; message?: string }; /** * Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit) From 012d1c452b0178b3c88c985d33edb29e96c05f46 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 20 Dec 2025 23:46:24 -0500 Subject: [PATCH 19/59] refactor: optimize button animations and interval checks for performance This commit introduces several performance improvements across the UI components: - Updated the Button component to enhance hover animations by grouping styles for better GPU efficiency. - Adjusted the interval timing in the BoardView and WorktreePanel components from 1 second to 3 and 5 seconds respectively, reducing CPU/GPU usage. - Replaced the continuous gradient rotation animation with a subtle pulse effect in global CSS to further optimize rendering performance. These changes aim to improve the overall responsiveness and efficiency of the UI components. --- apps/ui/src/components/ui/button.tsx | 6 ++-- apps/ui/src/components/views/board-view.tsx | 2 +- .../worktree-panel/worktree-panel.tsx | 5 ++-- apps/ui/src/styles/global.css | 29 ++++++++++--------- 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/apps/ui/src/components/ui/button.tsx b/apps/ui/src/components/ui/button.tsx index b5ec3738..75ec9abd 100644 --- a/apps/ui/src/components/ui/button.tsx +++ b/apps/ui/src/components/ui/button.tsx @@ -72,15 +72,15 @@ function Button({ + {error && ( + + )}
{/* Content */} @@ -337,7 +341,7 @@ export function ClaudeUsagePopover() { Claude Status -
{/* Could add quick settings link here */}
+ Updates every minute
diff --git a/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx index 2ca061da..cfde650d 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx +++ b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx @@ -1,18 +1,6 @@ -import { Clock } from "lucide-react"; import { cn } from "@/lib/utils"; -import { useState, useEffect } from "react"; -import { Slider } from "@/components/ui/slider"; -import { useAppStore } from "@/store/app-store"; export function ClaudeUsageSection() { - const { claudeRefreshInterval, setClaudeRefreshInterval } = useAppStore(); - const [localInterval, setLocalInterval] = useState(claudeRefreshInterval); - - // Sync local state with store when store changes (e.g. initial load) - useEffect(() => { - setLocalInterval(claudeRefreshInterval); - }, [claudeRefreshInterval]); - return (
  • Install Claude Code CLI if not already installed
  • Run claude login to authenticate
  • -
  • Usage data will be fetched automatically
  • +
  • Usage data will be fetched automatically every ~minute
  • - - {/* Refresh Interval Section */} -
    -
    -

    - - Refresh Interval -

    -

    - How often to check for usage updates. -

    -
    - -
    - setLocalInterval(vals[0])} - onValueCommit={(vals) => setClaudeRefreshInterval(vals[0])} - min={30} - max={120} - step={5} - className="flex-1" - /> - {Math.max(30, Math.min(120, localInterval || 30))}s -
    -
    ); From b80773b90db8a73e32416a9900369d573e7fa5af Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 11:11:33 +0400 Subject: [PATCH 22/59] fix: Enhance usage tracking visibility logic in BoardHeader and SettingsView components --- apps/ui/src/components/views/board-view/board-header.tsx | 4 +++- apps/ui/src/components/views/settings-view.tsx | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index a7c74b3c..f7be59cf 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -35,7 +35,9 @@ export function BoardHeader({ const apiKeys = useAppStore((state) => state.apiKeys); // Hide usage tracking when using API key (only show for Claude Code CLI users) - const showUsageTracking = !apiKeys.anthropic; + // Also hide on Windows for now (CLI usage command not supported) + const isWindows = typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win'); + const showUsageTracking = !apiKeys.anthropic && !isWindows; return (
    diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 5100e7e6..ad132409 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -51,7 +51,9 @@ export function SettingsView() { } = useAppStore(); // Hide usage tracking when using API key (only show for Claude Code CLI users) - const showUsageTracking = !apiKeys.anthropic; + // Also hide on Windows for now (CLI usage command not supported) + const isWindows = typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win'); + const showUsageTracking = !apiKeys.anthropic && !isWindows; // Convert electron Project to settings-view Project type const convertProject = ( From 6365cc137c329c0dae5980b46161cabbfcfe00ca Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 19:38:26 +0100 Subject: [PATCH 23/59] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20implemen?= =?UTF-8?q?t=20Phase=201=20folder-pattern=20compliance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename App.tsx to app.tsx (kebab-case naming convention) - Add barrel exports (index.ts) for src/hooks/ - Add barrel exports (index.ts) for src/components/dialogs/ - Add barrel exports (index.ts) for src/components/layout/ - Update renderer.tsx import to use lowercase app.tsx This is Phase 1 of folder-pattern.md compliance: establishing proper file naming conventions and barrel export patterns. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/ui/src/{App.tsx => app.tsx} | 20 ++++++++++---------- apps/ui/src/components/dialogs/index.ts | 2 ++ apps/ui/src/components/layout/index.ts | 1 + apps/ui/src/hooks/index.ts | 9 +++++++++ apps/ui/src/renderer.tsx | 8 ++++---- 5 files changed, 26 insertions(+), 14 deletions(-) rename apps/ui/src/{App.tsx => app.tsx} (52%) create mode 100644 apps/ui/src/components/dialogs/index.ts create mode 100644 apps/ui/src/components/layout/index.ts create mode 100644 apps/ui/src/hooks/index.ts diff --git a/apps/ui/src/App.tsx b/apps/ui/src/app.tsx similarity index 52% rename from apps/ui/src/App.tsx rename to apps/ui/src/app.tsx index a38de6b2..50380095 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/app.tsx @@ -1,15 +1,15 @@ -import { useState, useCallback } from "react"; -import { RouterProvider } from "@tanstack/react-router"; -import { router } from "./utils/router"; -import { SplashScreen } from "./components/splash-screen"; -import { useSettingsMigration } from "./hooks/use-settings-migration"; -import "./styles/global.css"; -import "./styles/theme-imports"; +import { useState, useCallback } from 'react'; +import { RouterProvider } from '@tanstack/react-router'; +import { router } from './utils/router'; +import { SplashScreen } from './components/splash-screen'; +import { useSettingsMigration } from './hooks/use-settings-migration'; +import './styles/global.css'; +import './styles/theme-imports'; export default function App() { const [showSplash, setShowSplash] = useState(() => { // Only show splash once per session - if (sessionStorage.getItem("automaker-splash-shown")) { + if (sessionStorage.getItem('automaker-splash-shown')) { return false; } return true; @@ -18,11 +18,11 @@ export default function App() { // Run settings migration on startup (localStorage -> file storage) const migrationState = useSettingsMigration(); if (migrationState.migrated) { - console.log("[App] Settings migrated to file storage"); + console.log('[App] Settings migrated to file storage'); } const handleSplashComplete = useCallback(() => { - sessionStorage.setItem("automaker-splash-shown", "true"); + sessionStorage.setItem('automaker-splash-shown', 'true'); setShowSplash(false); }, []); diff --git a/apps/ui/src/components/dialogs/index.ts b/apps/ui/src/components/dialogs/index.ts new file mode 100644 index 00000000..904c7a21 --- /dev/null +++ b/apps/ui/src/components/dialogs/index.ts @@ -0,0 +1,2 @@ +export { BoardBackgroundModal } from './board-background-modal'; +export { FileBrowserDialog } from './file-browser-dialog'; diff --git a/apps/ui/src/components/layout/index.ts b/apps/ui/src/components/layout/index.ts new file mode 100644 index 00000000..bfed6246 --- /dev/null +++ b/apps/ui/src/components/layout/index.ts @@ -0,0 +1 @@ +export { Sidebar } from './sidebar'; diff --git a/apps/ui/src/hooks/index.ts b/apps/ui/src/hooks/index.ts new file mode 100644 index 00000000..b18a85e6 --- /dev/null +++ b/apps/ui/src/hooks/index.ts @@ -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'; diff --git a/apps/ui/src/renderer.tsx b/apps/ui/src/renderer.tsx index 9a58d97d..86054d5a 100644 --- a/apps/ui/src/renderer.tsx +++ b/apps/ui/src/renderer.tsx @@ -1,8 +1,8 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import App from "./App"; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './app'; -createRoot(document.getElementById("app")!).render( +createRoot(document.getElementById('app')!).render( From e47b34288b733b493de15c66d774c0bb1a906e61 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 19:43:17 +0100 Subject: [PATCH 24/59] =?UTF-8?q?=F0=9F=97=82=EF=B8=8F=20refactor:=20imple?= =?UTF-8?q?ment=20Phase=202=20folder-pattern=20compliance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move dialogs to src/components/dialogs/ folder: - delete-session-dialog.tsx - delete-all-archived-sessions-dialog.tsx - new-project-modal.tsx - workspace-picker-modal.tsx - Update all imports to reference new dialog locations - Create barrel export (index.ts) for board-view/components/kanban-card/ - Create barrel exports (index.ts) for all 11 settings-view subfolders: - api-keys/, api-keys/hooks/, appearance/, audio/, cli-status/ - components/, config/, danger-zone/, feature-defaults/ - keyboard-shortcuts/, shared/ This is Phase 2 of folder-pattern.md compliance: organizing dialogs and establishing consistent barrel export patterns across all view subfolders. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../delete-all-archived-sessions-dialog.tsx | 10 +- .../{ => dialogs}/delete-session-dialog.tsx | 14 +- apps/ui/src/components/dialogs/index.ts | 4 + .../{ => dialogs}/new-project-modal.tsx | 191 ++++++-------- .../{ => dialogs}/workspace-picker-modal.tsx | 32 +-- apps/ui/src/components/layout/sidebar.tsx | 2 +- apps/ui/src/components/session-manager.tsx | 240 ++++++++---------- .../components/kanban-card/index.ts | 7 + .../settings-view/api-keys/hooks/index.ts | 1 + .../views/settings-view/api-keys/index.ts | 4 + .../views/settings-view/appearance/index.ts | 1 + .../views/settings-view/audio/index.ts | 1 + .../views/settings-view/cli-status/index.ts | 1 + .../views/settings-view/components/index.ts | 4 + .../views/settings-view/config/index.ts | 2 + .../views/settings-view/danger-zone/index.ts | 1 + .../settings-view/feature-defaults/index.ts | 1 + .../settings-view/keyboard-shortcuts/index.ts | 1 + .../views/settings-view/shared/index.ts | 2 + apps/ui/src/components/views/welcome-view.tsx | 191 ++++++-------- 20 files changed, 304 insertions(+), 406 deletions(-) rename apps/ui/src/components/{ => dialogs}/delete-all-archived-sessions-dialog.tsx (89%) rename apps/ui/src/components/{ => dialogs}/delete-session-dialog.tsx (78%) rename apps/ui/src/components/{ => dialogs}/new-project-modal.tsx (70%) rename apps/ui/src/components/{ => dialogs}/workspace-picker-modal.tsx (85%) create mode 100644 apps/ui/src/components/views/board-view/components/kanban-card/index.ts create mode 100644 apps/ui/src/components/views/settings-view/api-keys/hooks/index.ts create mode 100644 apps/ui/src/components/views/settings-view/api-keys/index.ts create mode 100644 apps/ui/src/components/views/settings-view/appearance/index.ts create mode 100644 apps/ui/src/components/views/settings-view/audio/index.ts create mode 100644 apps/ui/src/components/views/settings-view/cli-status/index.ts create mode 100644 apps/ui/src/components/views/settings-view/components/index.ts create mode 100644 apps/ui/src/components/views/settings-view/config/index.ts create mode 100644 apps/ui/src/components/views/settings-view/danger-zone/index.ts create mode 100644 apps/ui/src/components/views/settings-view/feature-defaults/index.ts create mode 100644 apps/ui/src/components/views/settings-view/keyboard-shortcuts/index.ts create mode 100644 apps/ui/src/components/views/settings-view/shared/index.ts diff --git a/apps/ui/src/components/delete-all-archived-sessions-dialog.tsx b/apps/ui/src/components/dialogs/delete-all-archived-sessions-dialog.tsx similarity index 89% rename from apps/ui/src/components/delete-all-archived-sessions-dialog.tsx rename to apps/ui/src/components/dialogs/delete-all-archived-sessions-dialog.tsx index 66b0bae6..358b99da 100644 --- a/apps/ui/src/components/delete-all-archived-sessions-dialog.tsx +++ b/apps/ui/src/components/dialogs/delete-all-archived-sessions-dialog.tsx @@ -1,4 +1,3 @@ - import { Dialog, DialogContent, @@ -6,9 +5,9 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Trash2 } from "lucide-react"; +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Trash2 } from 'lucide-react'; interface DeleteAllArchivedSessionsDialogProps { open: boolean; @@ -29,8 +28,7 @@ export function DeleteAllArchivedSessionsDialog({ Delete All Archived Sessions - Are you sure you want to delete all archived sessions? This action - cannot be undone. + Are you sure you want to delete all archived sessions? This action cannot be undone. {archivedCount > 0 && ( {archivedCount} session(s) will be deleted. diff --git a/apps/ui/src/components/delete-session-dialog.tsx b/apps/ui/src/components/dialogs/delete-session-dialog.tsx similarity index 78% rename from apps/ui/src/components/delete-session-dialog.tsx rename to apps/ui/src/components/dialogs/delete-session-dialog.tsx index e40cbed8..10862012 100644 --- a/apps/ui/src/components/delete-session-dialog.tsx +++ b/apps/ui/src/components/dialogs/delete-session-dialog.tsx @@ -1,6 +1,6 @@ -import { MessageSquare } from "lucide-react"; -import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; -import type { SessionListItem } from "@/types/electron"; +import { MessageSquare } from 'lucide-react'; +import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog'; +import type { SessionListItem } from '@/types/electron'; interface DeleteSessionDialogProps { open: boolean; @@ -38,12 +38,8 @@ export function DeleteSessionDialog({
    -

    - {session.name} -

    -

    - {session.messageCount} messages -

    +

    {session.name}

    +

    {session.messageCount} messages

    )} diff --git a/apps/ui/src/components/dialogs/index.ts b/apps/ui/src/components/dialogs/index.ts index 904c7a21..4cadb26d 100644 --- a/apps/ui/src/components/dialogs/index.ts +++ b/apps/ui/src/components/dialogs/index.ts @@ -1,2 +1,6 @@ export { BoardBackgroundModal } from './board-background-modal'; +export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions-dialog'; +export { DeleteSessionDialog } from './delete-session-dialog'; export { FileBrowserDialog } from './file-browser-dialog'; +export { NewProjectModal } from './new-project-modal'; +export { WorkspacePickerModal } from './workspace-picker-modal'; diff --git a/apps/ui/src/components/new-project-modal.tsx b/apps/ui/src/components/dialogs/new-project-modal.tsx similarity index 70% rename from apps/ui/src/components/new-project-modal.tsx rename to apps/ui/src/components/dialogs/new-project-modal.tsx index 93eef763..042b2ad7 100644 --- a/apps/ui/src/components/new-project-modal.tsx +++ b/apps/ui/src/components/dialogs/new-project-modal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect } from 'react'; import { Dialog, DialogContent, @@ -6,13 +6,13 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { HotkeyButton } from "@/components/ui/hotkey-button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Badge } from "@/components/ui/badge"; +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { HotkeyButton } from '@/components/ui/hotkey-button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; import { FolderPlus, FolderOpen, @@ -22,15 +22,12 @@ import { Loader2, Link, Folder, -} from "lucide-react"; -import { starterTemplates, type StarterTemplate } from "@/lib/templates"; -import { getElectronAPI } from "@/lib/electron"; -import { cn } from "@/lib/utils"; -import { useFileBrowser } from "@/contexts/file-browser-context"; -import { - getDefaultWorkspaceDirectory, - saveLastProjectDirectory, -} from "@/lib/workspace-config"; +} from 'lucide-react'; +import { starterTemplates, type StarterTemplate } from '@/lib/templates'; +import { getElectronAPI } from '@/lib/electron'; +import { cn } from '@/lib/utils'; +import { useFileBrowser } from '@/contexts/file-browser-context'; +import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config'; interface ValidationErrors { projectName?: boolean; @@ -42,20 +39,13 @@ interface ValidationErrors { interface NewProjectModalProps { open: boolean; onOpenChange: (open: boolean) => void; - onCreateBlankProject: ( - projectName: string, - parentDir: string - ) => Promise; + onCreateBlankProject: (projectName: string, parentDir: string) => Promise; onCreateFromTemplate: ( template: StarterTemplate, projectName: string, parentDir: string ) => Promise; - onCreateFromCustomUrl: ( - repoUrl: string, - projectName: string, - parentDir: string - ) => Promise; + onCreateFromCustomUrl: (repoUrl: string, projectName: string, parentDir: string) => Promise; isCreating: boolean; } @@ -67,14 +57,13 @@ export function NewProjectModal({ onCreateFromCustomUrl, isCreating, }: NewProjectModalProps) { - const [activeTab, setActiveTab] = useState<"blank" | "template">("blank"); - const [projectName, setProjectName] = useState(""); - const [workspaceDir, setWorkspaceDir] = useState(""); + const [activeTab, setActiveTab] = useState<'blank' | 'template'>('blank'); + const [projectName, setProjectName] = useState(''); + const [workspaceDir, setWorkspaceDir] = useState(''); const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false); - const [selectedTemplate, setSelectedTemplate] = - useState(null); + const [selectedTemplate, setSelectedTemplate] = useState(null); const [useCustomUrl, setUseCustomUrl] = useState(false); - const [customUrl, setCustomUrl] = useState(""); + const [customUrl, setCustomUrl] = useState(''); const [errors, setErrors] = useState({}); const { openFileBrowser } = useFileBrowser(); @@ -89,7 +78,7 @@ export function NewProjectModal({ } }) .catch((error) => { - console.error("Failed to get default workspace directory:", error); + console.error('Failed to get default workspace directory:', error); }) .finally(() => { setIsLoadingWorkspace(false); @@ -100,11 +89,11 @@ export function NewProjectModal({ // Reset form when modal closes useEffect(() => { if (!open) { - setProjectName(""); + setProjectName(''); setSelectedTemplate(null); setUseCustomUrl(false); - setCustomUrl(""); - setActiveTab("blank"); + setCustomUrl(''); + setActiveTab('blank'); setErrors({}); } }, [open]); @@ -117,10 +106,7 @@ export function NewProjectModal({ }, [projectName, errors.projectName]); useEffect(() => { - if ( - (selectedTemplate || (useCustomUrl && customUrl)) && - errors.templateSelection - ) { + if ((selectedTemplate || (useCustomUrl && customUrl)) && errors.templateSelection) { setErrors((prev) => ({ ...prev, templateSelection: false })); } }, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]); @@ -145,7 +131,7 @@ export function NewProjectModal({ } // Check template selection (only for template tab) - if (activeTab === "template") { + if (activeTab === 'template') { if (useCustomUrl) { if (!customUrl.trim()) { newErrors.customUrl = true; @@ -164,7 +150,7 @@ export function NewProjectModal({ // Clear errors and proceed setErrors({}); - if (activeTab === "blank") { + if (activeTab === 'blank') { await onCreateBlankProject(projectName, workspaceDir); } else if (useCustomUrl && customUrl) { await onCreateFromCustomUrl(customUrl, projectName, workspaceDir); @@ -181,7 +167,7 @@ export function NewProjectModal({ const handleSelectTemplate = (template: StarterTemplate) => { setSelectedTemplate(template); setUseCustomUrl(false); - setCustomUrl(""); + setCustomUrl(''); }; const handleToggleCustomUrl = () => { @@ -193,9 +179,8 @@ export function NewProjectModal({ const handleBrowseDirectory = async () => { const selectedPath = await openFileBrowser({ - title: "Select Base Project Directory", - description: - "Choose the parent directory where your project will be created", + title: 'Select Base Project Directory', + description: 'Choose the parent directory where your project will be created', initialPath: workspaceDir || undefined, }); if (selectedPath) { @@ -211,15 +196,12 @@ export function NewProjectModal({ // Use platform-specific path separator const pathSep = - typeof window !== "undefined" && (window as any).electronAPI - ? navigator.platform.indexOf("Win") !== -1 - ? "\\" - : "/" - : "/"; - const projectPath = - workspaceDir && projectName - ? `${workspaceDir}${pathSep}${projectName}` - : ""; + typeof window !== 'undefined' && (window as any).electronAPI + ? navigator.platform.indexOf('Win') !== -1 + ? '\\' + : '/' + : '/'; + const projectPath = workspaceDir && projectName ? `${workspaceDir}${pathSep}${projectName}` : ''; return ( @@ -228,9 +210,7 @@ export function NewProjectModal({ data-testid="new-project-modal" > - - Create New Project - + Create New Project Start with a blank project or choose from a starter template. @@ -241,13 +221,9 @@ export function NewProjectModal({
    setProjectName(e.target.value)} className={cn( - "bg-input text-foreground placeholder:text-muted-foreground", + 'bg-input text-foreground placeholder:text-muted-foreground', errors.projectName - ? "border-red-500 focus:border-red-500 focus:ring-red-500/20" - : "border-border" + ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' + : 'border-border' )} data-testid="project-name-input" autoFocus /> - {errors.projectName && ( -

    Project name is required

    - )} + {errors.projectName &&

    Project name is required

    }
    {/* Workspace Directory Display */}
    {isLoadingWorkspace ? ( - "Loading workspace..." + 'Loading workspace...' ) : workspaceDir ? ( <> - Will be created at:{" "} + Will be created at:{' '} {projectPath || workspaceDir} @@ -305,7 +279,7 @@ export function NewProjectModal({ setActiveTab(v as "blank" | "template")} + onValueChange={(v) => setActiveTab(v as 'blank' | 'template')} className="flex-1 flex flex-col overflow-hidden" > @@ -323,9 +297,8 @@ export function NewProjectModal({

    - Create an empty project with the standard .automaker directory - structure. Perfect for starting from scratch or importing an - existing codebase. + Create an empty project with the standard .automaker directory structure. Perfect + for starting from scratch or importing an existing codebase.

    @@ -342,18 +315,18 @@ export function NewProjectModal({ {/* Preset Templates */}
    {starterTemplates.map((template) => (
    handleSelectTemplate(template)} data-testid={`template-${template.id}`} @@ -361,13 +334,10 @@ export function NewProjectModal({
    -

    - {template.name} -

    - {selectedTemplate?.id === template.id && - !useCustomUrl && ( - - )} +

    {template.name}

    + {selectedTemplate?.id === template.id && !useCustomUrl && ( + + )}

    {template.description} @@ -376,11 +346,7 @@ export function NewProjectModal({ {/* Tech Stack */}

    {template.techStack.slice(0, 6).map((tech) => ( - + {tech} ))} @@ -394,7 +360,7 @@ export function NewProjectModal({ {/* Key Features */}
    Features: - {template.features.slice(0, 3).join(" · ")} + {template.features.slice(0, 3).join(' · ')} {template.features.length > 3 && ` · +${template.features.length - 3} more`}
    @@ -419,47 +385,38 @@ export function NewProjectModal({ {/* Custom URL Option */}
    -

    - Custom GitHub URL -

    - {useCustomUrl && ( - - )} +

    Custom GitHub URL

    + {useCustomUrl && }

    Clone any public GitHub repository as a starting point.

    {useCustomUrl && ( -
    e.stopPropagation()} - className="space-y-1" - > +
    e.stopPropagation()} className="space-y-1"> setCustomUrl(e.target.value)} className={cn( - "bg-input text-foreground placeholder:text-muted-foreground", + 'bg-input text-foreground placeholder:text-muted-foreground', errors.customUrl - ? "border-red-500 focus:border-red-500 focus:ring-red-500/20" - : "border-border" + ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' + : 'border-border' )} data-testid="custom-url-input" /> {errors.customUrl && ( -

    - GitHub URL is required -

    +

    GitHub URL is required

    )}
    )} @@ -482,14 +439,14 @@ export function NewProjectModal({ onClick={validateAndCreate} disabled={isCreating} className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0" - hotkey={{ key: "Enter", cmdCtrl: true }} + hotkey={{ key: 'Enter', cmdCtrl: true }} hotkeyActive={open} data-testid="confirm-create-project" > {isCreating ? ( <> - {activeTab === "template" ? "Cloning..." : "Creating..."} + {activeTab === 'template' ? 'Cloning...' : 'Creating...'} ) : ( <>Create Project diff --git a/apps/ui/src/components/workspace-picker-modal.tsx b/apps/ui/src/components/dialogs/workspace-picker-modal.tsx similarity index 85% rename from apps/ui/src/components/workspace-picker-modal.tsx rename to apps/ui/src/components/dialogs/workspace-picker-modal.tsx index 2f3303a2..4f287465 100644 --- a/apps/ui/src/components/workspace-picker-modal.tsx +++ b/apps/ui/src/components/dialogs/workspace-picker-modal.tsx @@ -1,5 +1,4 @@ - -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback } from 'react'; import { Dialog, DialogContent, @@ -7,10 +6,10 @@ import { 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"; +} 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; @@ -23,11 +22,7 @@ interface WorkspacePickerModalProps { onSelect: (path: string, name: string) => void; } -export function WorkspacePickerModal({ - open, - onOpenChange, - onSelect, -}: WorkspacePickerModalProps) { +export function WorkspacePickerModal({ open, onOpenChange, onSelect }: WorkspacePickerModalProps) { const [isLoading, setIsLoading] = useState(false); const [directories, setDirectories] = useState([]); const [error, setError] = useState(null); @@ -43,10 +38,10 @@ export function WorkspacePickerModal({ if (result.success && result.directories) { setDirectories(result.directories); } else { - setError(result.error || "Failed to load directories"); + setError(result.error || 'Failed to load directories'); } } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load directories"); + setError(err instanceof Error ? err.message : 'Failed to load directories'); } finally { setIsLoading(false); } @@ -90,12 +85,7 @@ export function WorkspacePickerModal({

    {error}

    -
    @@ -128,9 +118,7 @@ export function WorkspacePickerModal({

    {dir.name}

    -

    - {dir.path} -

    +

    {dir.path}

    ))} diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index cca6aa22..3cafe020 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -72,7 +72,7 @@ import { toast } from 'sonner'; import { themeOptions } from '@/config/theme-options'; import type { SpecRegenerationEvent } from '@/types/electron'; import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog'; -import { NewProjectModal } from '@/components/new-project-modal'; +import { NewProjectModal } from '@/components/dialogs/new-project-modal'; import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; import type { FeatureCount } from '@/components/views/spec-view/types'; import { diff --git a/apps/ui/src/components/session-manager.tsx b/apps/ui/src/components/session-manager.tsx index c255c27f..f8452aa1 100644 --- a/apps/ui/src/components/session-manager.tsx +++ b/apps/ui/src/components/session-manager.tsx @@ -1,10 +1,9 @@ - -import { useState, useEffect } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { HotkeyButton } from "@/components/ui/hotkey-button"; -import { Input } from "@/components/ui/input"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { HotkeyButton } from '@/components/ui/hotkey-button'; +import { Input } from '@/components/ui/input'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Plus, MessageSquare, @@ -15,66 +14,66 @@ import { X, ArchiveRestore, Loader2, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import type { SessionListItem } from "@/types/electron"; -import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts"; -import { getElectronAPI } from "@/lib/electron"; -import { DeleteSessionDialog } from "@/components/delete-session-dialog"; -import { DeleteAllArchivedSessionsDialog } from "@/components/delete-all-archived-sessions-dialog"; +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { SessionListItem } from '@/types/electron'; +import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; +import { getElectronAPI } from '@/lib/electron'; +import { DeleteSessionDialog } from '@/components/dialogs/delete-session-dialog'; +import { DeleteAllArchivedSessionsDialog } from '@/components/dialogs/delete-all-archived-sessions-dialog'; // Random session name generator const adjectives = [ - "Swift", - "Bright", - "Clever", - "Dynamic", - "Eager", - "Focused", - "Gentle", - "Happy", - "Inventive", - "Jolly", - "Keen", - "Lively", - "Mighty", - "Noble", - "Optimal", - "Peaceful", - "Quick", - "Radiant", - "Smart", - "Tranquil", - "Unique", - "Vibrant", - "Wise", - "Zealous", + 'Swift', + 'Bright', + 'Clever', + 'Dynamic', + 'Eager', + 'Focused', + 'Gentle', + 'Happy', + 'Inventive', + 'Jolly', + 'Keen', + 'Lively', + 'Mighty', + 'Noble', + 'Optimal', + 'Peaceful', + 'Quick', + 'Radiant', + 'Smart', + 'Tranquil', + 'Unique', + 'Vibrant', + 'Wise', + 'Zealous', ]; const nouns = [ - "Agent", - "Builder", - "Coder", - "Developer", - "Explorer", - "Forge", - "Garden", - "Helper", - "Innovator", - "Journey", - "Kernel", - "Lighthouse", - "Mission", - "Navigator", - "Oracle", - "Project", - "Quest", - "Runner", - "Spark", - "Task", - "Unicorn", - "Voyage", - "Workshop", + 'Agent', + 'Builder', + 'Coder', + 'Developer', + 'Explorer', + 'Forge', + 'Garden', + 'Helper', + 'Innovator', + 'Journey', + 'Kernel', + 'Lighthouse', + 'Mission', + 'Navigator', + 'Oracle', + 'Project', + 'Quest', + 'Runner', + 'Spark', + 'Task', + 'Unicorn', + 'Voyage', + 'Workshop', ]; function generateRandomSessionName(): string { @@ -101,19 +100,15 @@ export function SessionManager({ }: SessionManagerProps) { const shortcuts = useKeyboardShortcutsConfig(); const [sessions, setSessions] = useState([]); - const [activeTab, setActiveTab] = useState<"active" | "archived">("active"); + const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active'); const [editingSessionId, setEditingSessionId] = useState(null); - const [editingName, setEditingName] = useState(""); + const [editingName, setEditingName] = useState(''); const [isCreating, setIsCreating] = useState(false); - const [newSessionName, setNewSessionName] = useState(""); - const [runningSessions, setRunningSessions] = useState>( - new Set() - ); + const [newSessionName, setNewSessionName] = useState(''); + const [runningSessions, setRunningSessions] = useState>(new Set()); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [sessionToDelete, setSessionToDelete] = - useState(null); - const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = - useState(false); + const [sessionToDelete, setSessionToDelete] = useState(null); + const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false); // Check running state for all sessions const checkRunningSessions = async (sessionList: SessionListItem[]) => { @@ -131,10 +126,7 @@ export function SessionManager({ } } catch (err) { // Ignore errors for individual session checks - console.warn( - `[SessionManager] Failed to check running state for ${session.id}:`, - err - ); + console.warn(`[SessionManager] Failed to check running state for ${session.id}:`, err); } } @@ -180,14 +172,10 @@ export function SessionManager({ const sessionName = newSessionName.trim() || generateRandomSessionName(); - const result = await api.sessions.create( - sessionName, - projectPath, - projectPath - ); + const result = await api.sessions.create(sessionName, projectPath, projectPath); if (result.success && result.session?.id) { - setNewSessionName(""); + setNewSessionName(''); setIsCreating(false); await loadSessions(); onSelectSession(result.session.id); @@ -201,11 +189,7 @@ export function SessionManager({ const sessionName = generateRandomSessionName(); - const result = await api.sessions.create( - sessionName, - projectPath, - projectPath - ); + const result = await api.sessions.create(sessionName, projectPath, projectPath); if (result.success && result.session?.id) { await loadSessions(); @@ -234,7 +218,7 @@ export function SessionManager({ if (result.success) { setEditingSessionId(null); - setEditingName(""); + setEditingName(''); await loadSessions(); } }; @@ -243,7 +227,7 @@ export function SessionManager({ const handleArchiveSession = async (sessionId: string) => { const api = getElectronAPI(); if (!api?.sessions) { - console.error("[SessionManager] Sessions API not available"); + console.error('[SessionManager] Sessions API not available'); return; } @@ -256,10 +240,10 @@ export function SessionManager({ } await loadSessions(); } else { - console.error("[SessionManager] Archive failed:", result.error); + console.error('[SessionManager] Archive failed:', result.error); } } catch (error) { - console.error("[SessionManager] Archive error:", error); + console.error('[SessionManager] Archive error:', error); } }; @@ -267,7 +251,7 @@ export function SessionManager({ const handleUnarchiveSession = async (sessionId: string) => { const api = getElectronAPI(); if (!api?.sessions) { - console.error("[SessionManager] Sessions API not available"); + console.error('[SessionManager] Sessions API not available'); return; } @@ -276,10 +260,10 @@ export function SessionManager({ if (result.success) { await loadSessions(); } else { - console.error("[SessionManager] Unarchive failed:", result.error); + console.error('[SessionManager] Unarchive failed:', result.error); } } catch (error) { - console.error("[SessionManager] Unarchive error:", error); + console.error('[SessionManager] Unarchive error:', error); } }; @@ -324,8 +308,7 @@ export function SessionManager({ const activeSessions = sessions.filter((s) => !s.isArchived); const archivedSessions = sessions.filter((s) => s.isArchived); - const displayedSessions = - activeTab === "active" ? activeSessions : archivedSessions; + const displayedSessions = activeTab === 'active' ? activeSessions : archivedSessions; return ( @@ -337,8 +320,8 @@ export function SessionManager({ size="sm" onClick={() => { // Switch to active tab if on archived tab - if (activeTab === "archived") { - setActiveTab("active"); + if (activeTab === 'archived') { + setActiveTab('active'); } handleQuickCreateSession(); }} @@ -354,9 +337,7 @@ export function SessionManager({ - setActiveTab(value as "active" | "archived") - } + onValueChange={(value) => setActiveTab(value as 'active' | 'archived')} className="w-full" > @@ -372,10 +353,7 @@ export function SessionManager({ - + {/* Create new session */} {isCreating && (
    @@ -385,10 +363,10 @@ export function SessionManager({ value={newSessionName} onChange={(e) => setNewSessionName(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter") handleCreateSession(); - if (e.key === "Escape") { + if (e.key === 'Enter') handleCreateSession(); + if (e.key === 'Escape') { setIsCreating(false); - setNewSessionName(""); + setNewSessionName(''); } }} autoFocus @@ -401,7 +379,7 @@ export function SessionManager({ variant="ghost" onClick={() => { setIsCreating(false); - setNewSessionName(""); + setNewSessionName(''); }} > @@ -411,7 +389,7 @@ export function SessionManager({ )} {/* Delete All Archived button - shown at the top of archived sessions */} - {activeTab === "archived" && archivedSessions.length > 0 && ( + {activeTab === 'archived' && archivedSessions.length > 0 && (
    )} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/index.ts b/apps/ui/src/components/views/board-view/components/kanban-card/index.ts new file mode 100644 index 00000000..a8b7a36a --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/kanban-card/index.ts @@ -0,0 +1,7 @@ +export { AgentInfoPanel } from './agent-info-panel'; +export { CardActions } from './card-actions'; +export { CardBadges, PriorityBadges } from './card-badges'; +export { CardContentSections } from './card-content-sections'; +export { CardHeaderSection } from './card-header'; +export { KanbanCard } from './kanban-card'; +export { SummaryDialog } from './summary-dialog'; diff --git a/apps/ui/src/components/views/settings-view/api-keys/hooks/index.ts b/apps/ui/src/components/views/settings-view/api-keys/hooks/index.ts new file mode 100644 index 00000000..7b953096 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/api-keys/hooks/index.ts @@ -0,0 +1 @@ +export { useApiKeyManagement } from './use-api-key-management'; diff --git a/apps/ui/src/components/views/settings-view/api-keys/index.ts b/apps/ui/src/components/views/settings-view/api-keys/index.ts new file mode 100644 index 00000000..0a51c82f --- /dev/null +++ b/apps/ui/src/components/views/settings-view/api-keys/index.ts @@ -0,0 +1,4 @@ +export { ApiKeyField } from './api-key-field'; +export { ApiKeysSection } from './api-keys-section'; +export { AuthenticationStatusDisplay } from './authentication-status-display'; +export { SecurityNotice } from './security-notice'; diff --git a/apps/ui/src/components/views/settings-view/appearance/index.ts b/apps/ui/src/components/views/settings-view/appearance/index.ts new file mode 100644 index 00000000..9273561e --- /dev/null +++ b/apps/ui/src/components/views/settings-view/appearance/index.ts @@ -0,0 +1 @@ +export { AppearanceSection } from './appearance-section'; diff --git a/apps/ui/src/components/views/settings-view/audio/index.ts b/apps/ui/src/components/views/settings-view/audio/index.ts new file mode 100644 index 00000000..a6d19cf6 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/audio/index.ts @@ -0,0 +1 @@ +export { AudioSection } from './audio-section'; diff --git a/apps/ui/src/components/views/settings-view/cli-status/index.ts b/apps/ui/src/components/views/settings-view/cli-status/index.ts new file mode 100644 index 00000000..a6d7cf87 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/cli-status/index.ts @@ -0,0 +1 @@ +export { ClaudeCliStatus } from './claude-cli-status'; diff --git a/apps/ui/src/components/views/settings-view/components/index.ts b/apps/ui/src/components/views/settings-view/components/index.ts new file mode 100644 index 00000000..de388fad --- /dev/null +++ b/apps/ui/src/components/views/settings-view/components/index.ts @@ -0,0 +1,4 @@ +export { DeleteProjectDialog } from './delete-project-dialog'; +export { KeyboardMapDialog } from './keyboard-map-dialog'; +export { SettingsHeader } from './settings-header'; +export { SettingsNavigation } from './settings-navigation'; diff --git a/apps/ui/src/components/views/settings-view/config/index.ts b/apps/ui/src/components/views/settings-view/config/index.ts new file mode 100644 index 00000000..9591a88d --- /dev/null +++ b/apps/ui/src/components/views/settings-view/config/index.ts @@ -0,0 +1,2 @@ +export { NAV_ITEMS } from './navigation'; +export type { NavigationItem } from './navigation'; diff --git a/apps/ui/src/components/views/settings-view/danger-zone/index.ts b/apps/ui/src/components/views/settings-view/danger-zone/index.ts new file mode 100644 index 00000000..f4426f7e --- /dev/null +++ b/apps/ui/src/components/views/settings-view/danger-zone/index.ts @@ -0,0 +1 @@ +export { DangerZoneSection } from './danger-zone-section'; diff --git a/apps/ui/src/components/views/settings-view/feature-defaults/index.ts b/apps/ui/src/components/views/settings-view/feature-defaults/index.ts new file mode 100644 index 00000000..95d00123 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/feature-defaults/index.ts @@ -0,0 +1 @@ +export { FeatureDefaultsSection } from './feature-defaults-section'; diff --git a/apps/ui/src/components/views/settings-view/keyboard-shortcuts/index.ts b/apps/ui/src/components/views/settings-view/keyboard-shortcuts/index.ts new file mode 100644 index 00000000..5db1d7b5 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/keyboard-shortcuts/index.ts @@ -0,0 +1 @@ +export { KeyboardShortcutsSection } from './keyboard-shortcuts-section'; diff --git a/apps/ui/src/components/views/settings-view/shared/index.ts b/apps/ui/src/components/views/settings-view/shared/index.ts new file mode 100644 index 00000000..9f0b5505 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/shared/index.ts @@ -0,0 +1,2 @@ +export type { Theme } from '@/config/theme-options'; +export type { CliStatus, KanbanDetailLevel, Project, ApiKeys } from './types'; diff --git a/apps/ui/src/components/views/welcome-view.tsx b/apps/ui/src/components/views/welcome-view.tsx index e9948f29..00c3899a 100644 --- a/apps/ui/src/components/views/welcome-view.tsx +++ b/apps/ui/src/components/views/welcome-view.tsx @@ -1,6 +1,5 @@ - -import { useState, useCallback } from "react"; -import { Button } from "@/components/ui/button"; +import { useState, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, @@ -8,10 +7,10 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; -import { useAppStore, type ThemeMode } from "@/store/app-store"; -import { getElectronAPI, type Project } from "@/lib/electron"; -import { initializeProject } from "@/lib/project-init"; +} from '@/components/ui/dialog'; +import { useAppStore, type ThemeMode } from '@/store/app-store'; +import { getElectronAPI, type Project } from '@/lib/electron'; +import { initializeProject } from '@/lib/project-init'; import { FolderOpen, Plus, @@ -21,19 +20,19 @@ import { MessageSquare, ChevronDown, Loader2, -} from "lucide-react"; +} from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { toast } from "sonner"; -import { WorkspacePickerModal } from "@/components/workspace-picker-modal"; -import { NewProjectModal } from "@/components/new-project-modal"; -import { getHttpApiClient } from "@/lib/http-api-client"; -import type { StarterTemplate } from "@/lib/templates"; -import { useNavigate } from "@tanstack/react-router"; +} from '@/components/ui/dropdown-menu'; +import { toast } from 'sonner'; +import { WorkspacePickerModal } from '@/components/dialogs/workspace-picker-modal'; +import { NewProjectModal } from '@/components/dialogs/new-project-modal'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import type { StarterTemplate } from '@/lib/templates'; +import { useNavigate } from '@tanstack/react-router'; export function WelcomeView() { const { @@ -66,24 +65,24 @@ export function WelcomeView() { const api = getElectronAPI(); if (!api.autoMode?.analyzeProject) { - console.log("[Welcome] Auto mode API not available, skipping analysis"); + console.log('[Welcome] Auto mode API not available, skipping analysis'); return; } setIsAnalyzing(true); try { - console.log("[Welcome] Starting project analysis for:", projectPath); + console.log('[Welcome] Starting project analysis for:', projectPath); const result = await api.autoMode.analyzeProject(projectPath); if (result.success) { - toast.success("Project analyzed", { - description: "AI agent has analyzed your project structure", + toast.success('Project analyzed', { + description: 'AI agent has analyzed your project structure', }); } else { - console.error("[Welcome] Project analysis failed:", result.error); + console.error('[Welcome] Project analysis failed:', result.error); } } catch (error) { - console.error("[Welcome] Failed to analyze project:", error); + console.error('[Welcome] Failed to analyze project:', error); } finally { setIsAnalyzing(false); } @@ -100,8 +99,8 @@ export function WelcomeView() { const initResult = await initializeProject(path); if (!initResult.success) { - toast.error("Failed to initialize project", { - description: initResult.error || "Unknown error occurred", + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', }); return; } @@ -126,26 +125,23 @@ export function WelcomeView() { setShowInitDialog(true); // Kick off agent to analyze the project and update app_spec.txt - console.log( - "[Welcome] Project initialized, created files:", - initResult.createdFiles - ); - console.log("[Welcome] Kicking off project analysis agent..."); + console.log('[Welcome] Project initialized, created files:', initResult.createdFiles); + console.log('[Welcome] Kicking off project analysis agent...'); // Start analysis in background (don't await, let it run async) analyzeProject(path); } else { - toast.success("Project opened", { + toast.success('Project opened', { description: `Opened ${name}`, }); } // Navigate to the board view - navigate({ to: "/board" }); + navigate({ to: '/board' }); } catch (error) { - console.error("[Welcome] Failed to open project:", error); - toast.error("Failed to open project", { - description: error instanceof Error ? error.message : "Unknown error", + console.error('[Welcome] Failed to open project:', error); + toast.error('Failed to open project', { + description: error instanceof Error ? error.message : 'Unknown error', }); } finally { setIsOpening(false); @@ -178,21 +174,19 @@ export function WelcomeView() { 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"; + const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project'; await initializeAndOpenProject(path, name); } } } catch (error) { - console.error("[Welcome] Failed to check workspace config:", 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"; + const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project'; await initializeAndOpenProject(path, name); } } @@ -224,16 +218,13 @@ export function WelcomeView() { }; const handleInteractiveMode = () => { - navigate({ to: "/interview" }); + navigate({ to: '/interview' }); }; /** * Create a blank project with just .automaker directory structure */ - const handleCreateBlankProject = async ( - projectName: string, - parentDir: string - ) => { + const handleCreateBlankProject = async (projectName: string, parentDir: string) => { setIsCreating(true); try { const api = getElectronAPI(); @@ -242,7 +233,7 @@ export function WelcomeView() { // Validate that parent directory exists const parentExists = await api.exists(parentDir); if (!parentExists) { - toast.error("Parent directory does not exist", { + toast.error('Parent directory does not exist', { description: `Cannot create project in non-existent directory: ${parentDir}`, }); return; @@ -251,7 +242,7 @@ export function WelcomeView() { // Verify parent is actually a directory const parentStat = await api.stat(parentDir); if (parentStat && !parentStat.isDirectory) { - toast.error("Parent path is not a directory", { + toast.error('Parent path is not a directory', { description: `${parentDir} is not a directory`, }); return; @@ -260,8 +251,8 @@ export function WelcomeView() { // Create project directory const mkdirResult = await api.mkdir(projectPath); if (!mkdirResult.success) { - toast.error("Failed to create project directory", { - description: mkdirResult.error || "Unknown error occurred", + toast.error('Failed to create project directory', { + description: mkdirResult.error || 'Unknown error occurred', }); return; } @@ -270,8 +261,8 @@ export function WelcomeView() { const initResult = await initializeProject(projectPath); if (!initResult.success) { - toast.error("Failed to initialize project", { - description: initResult.error || "Unknown error occurred", + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', }); return; } @@ -313,7 +304,7 @@ export function WelcomeView() { setCurrentProject(project); setShowNewProjectModal(false); - toast.success("Project created", { + toast.success('Project created', { description: `Created ${projectName} with .automaker directory`, }); @@ -326,9 +317,9 @@ export function WelcomeView() { }); setShowInitDialog(true); } catch (error) { - console.error("Failed to create project:", error); - toast.error("Failed to create project", { - description: error instanceof Error ? error.message : "Unknown error", + console.error('Failed to create project:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', }); } finally { setIsCreating(false); @@ -356,8 +347,8 @@ export function WelcomeView() { ); if (!cloneResult.success || !cloneResult.projectPath) { - toast.error("Failed to clone template", { - description: cloneResult.error || "Unknown error occurred", + toast.error('Failed to clone template', { + description: cloneResult.error || 'Unknown error occurred', }); return; } @@ -368,8 +359,8 @@ export function WelcomeView() { const initResult = await initializeProject(projectPath); if (!initResult.success) { - toast.error("Failed to initialize project", { - description: initResult.error || "Unknown error occurred", + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', }); return; } @@ -387,15 +378,11 @@ export function WelcomeView() { - ${template.techStack - .map((tech) => `${tech}`) - .join("\n ")} + ${template.techStack.map((tech) => `${tech}`).join('\n ')} - ${template.features - .map((feature) => `${feature}`) - .join("\n ")} + ${template.features.map((feature) => `${feature}`).join('\n ')} @@ -415,7 +402,7 @@ export function WelcomeView() { setCurrentProject(project); setShowNewProjectModal(false); - toast.success("Project created from template", { + toast.success('Project created from template', { description: `Created ${projectName} from ${template.name}`, }); @@ -431,9 +418,9 @@ export function WelcomeView() { // Kick off project analysis analyzeProject(projectPath); } catch (error) { - console.error("Failed to create project from template:", error); - toast.error("Failed to create project", { - description: error instanceof Error ? error.message : "Unknown error", + console.error('Failed to create project from template:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', }); } finally { setIsCreating(false); @@ -454,15 +441,11 @@ export function WelcomeView() { const api = getElectronAPI(); // Clone the repository - const cloneResult = await httpClient.templates.clone( - repoUrl, - projectName, - parentDir - ); + const cloneResult = await httpClient.templates.clone(repoUrl, projectName, parentDir); if (!cloneResult.success || !cloneResult.projectPath) { - toast.error("Failed to clone repository", { - description: cloneResult.error || "Unknown error occurred", + toast.error('Failed to clone repository', { + description: cloneResult.error || 'Unknown error occurred', }); return; } @@ -473,8 +456,8 @@ export function WelcomeView() { const initResult = await initializeProject(projectPath); if (!initResult.success) { - toast.error("Failed to initialize project", { - description: initResult.error || "Unknown error occurred", + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', }); return; } @@ -516,7 +499,7 @@ export function WelcomeView() { setCurrentProject(project); setShowNewProjectModal(false); - toast.success("Project created from repository", { + toast.success('Project created from repository', { description: `Created ${projectName} from ${repoUrl}`, }); @@ -532,9 +515,9 @@ export function WelcomeView() { // Kick off project analysis analyzeProject(projectPath); } catch (error) { - console.error("Failed to create project from custom URL:", error); - toast.error("Failed to create project", { - description: error instanceof Error ? error.message : "Unknown error", + console.error('Failed to create project from custom URL:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', }); } finally { setIsCreating(false); @@ -587,12 +570,9 @@ export function WelcomeView() {
    -

    - New Project -

    +

    New Project

    - Create a new project from scratch with AI-powered - development + Create a new project from scratch with AI-powered development

    @@ -608,10 +588,7 @@ export function WelcomeView() { - + Quick Setup @@ -640,9 +617,7 @@ export function WelcomeView() {
    -

    - Open Project -

    +

    Open Project

    Open an existing project folder to continue working

    @@ -667,9 +642,7 @@ export function WelcomeView() {
    -

    - Recent Projects -

    +

    Recent Projects

    {recentProjects.map((project, index) => ( @@ -695,9 +668,7 @@ export function WelcomeView() {

    {project.lastOpened && (

    - {new Date( - project.lastOpened - ).toLocaleDateString()} + {new Date(project.lastOpened).toLocaleDateString()}

    )}
    @@ -715,9 +686,7 @@ export function WelcomeView() {
    -

    - No projects yet -

    +

    No projects yet

    Get started by creating a new project or opening an existing one

    @@ -747,9 +716,7 @@ export function WelcomeView() {
    - {initStatus?.isNewProject - ? "Project Initialized" - : "Project Updated"} + {initStatus?.isNewProject ? 'Project Initialized' : 'Project Updated'} {initStatus?.isNewProject @@ -759,9 +726,7 @@ export function WelcomeView() {
    -

    - Created files: -

    +

    Created files:

      {initStatus?.createdFiles.map((file) => (
    • ) : (

      - Tip: Edit the{" "} + Tip: Edit the{' '} app_spec.txt - {" "} - file to describe your project. The AI agent will use this to - understand your project structure. + {' '} + file to describe your project. The AI agent will use this to understand your + project structure.

      )}
    @@ -826,9 +791,7 @@ export function WelcomeView() { >
    -

    - Initializing project... -

    +

    Initializing project...

    )} From 7e8995df243b5fd28c505a8f95b966e2cdd379bf Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 19:51:04 +0100 Subject: [PATCH 25/59] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20implemen?= =?UTF-8?q?t=20Phase=203=20sidebar=20refactoring=20(partial)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract inline components and organize sidebar structure: - Create sidebar/ subfolder structure (components/, hooks/, dialogs/) - Extract types.ts: NavSection, NavItem, component prop interfaces - Extract constants.ts: theme options, feature flags - Extract 3 inline components into separate files: - sortable-project-item.tsx (drag-and-drop project item) - theme-menu-item.tsx (memoized theme selector) - bug-report-button.tsx (reusable bug report button) - Update sidebar.tsx to import from extracted modules - Reduce sidebar.tsx from 2323 to 2187 lines (-136 lines) This is Phase 3 (partial) of folder-pattern.md compliance: breaking down the monolithic sidebar.tsx into maintainable, reusable components. Further refactoring (hooks extraction, dialog extraction) can be done incrementally to avoid disrupting functionality. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/ui/src/components/layout/sidebar.tsx | 167 ++---------------- .../sidebar/components/bug-report-button.tsx | 23 +++ .../layout/sidebar/components/index.ts | 3 + .../components/sortable-project-item.tsx | 54 ++++++ .../sidebar/components/theme-menu-item.tsx | 27 +++ .../components/layout/sidebar/constants.ts | 24 +++ .../ui/src/components/layout/sidebar/types.ts | 36 ++++ 7 files changed, 178 insertions(+), 156 deletions(-) create mode 100644 apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx create mode 100644 apps/ui/src/components/layout/sidebar/components/index.ts create mode 100644 apps/ui/src/components/layout/sidebar/components/sortable-project-item.tsx create mode 100644 apps/ui/src/components/layout/sidebar/components/theme-menu-item.tsx create mode 100644 apps/ui/src/components/layout/sidebar/constants.ts create mode 100644 apps/ui/src/components/layout/sidebar/types.ts diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 3cafe020..39ffef97 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -83,159 +83,18 @@ import { useSensors, closestCenter, } from '@dnd-kit/core'; -import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { getHttpApiClient } from '@/lib/http-api-client'; import type { StarterTemplate } from '@/lib/templates'; -interface NavSection { - label?: string; - items: NavItem[]; -} - -interface NavItem { - id: string; - label: string; - icon: any; - shortcut?: string; -} - -// Sortable Project Item Component -interface SortableProjectItemProps { - project: Project; - currentProjectId: string | undefined; - isHighlighted: boolean; - onSelect: (project: Project) => void; -} - -function SortableProjectItem({ - project, - currentProjectId, - isHighlighted, - onSelect, -}: SortableProjectItemProps) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: project.id, - }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - - return ( -
    - {/* Drag Handle */} - - - {/* Project content - clickable area */} -
    onSelect(project)}> - - {project.name} - {currentProjectId === project.id && } -
    -
    - ); -} - -// Theme options for project theme selector - derived from the shared config -import { darkThemes, lightThemes } from '@/config/theme-options'; - -const PROJECT_DARK_THEMES = darkThemes.map((opt) => ({ - value: opt.value, - label: opt.label, - icon: opt.Icon, - color: opt.color, -})); - -const PROJECT_LIGHT_THEMES = lightThemes.map((opt) => ({ - value: opt.value, - label: opt.label, - icon: opt.Icon, - color: opt.color, -})); - -// Memoized theme menu item to prevent re-renders during hover -interface ThemeMenuItemProps { - option: { - value: string; - label: string; - icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; - color: string; - }; - onPreviewEnter: (value: string) => void; - onPreviewLeave: (e: React.PointerEvent) => void; -} - -const ThemeMenuItem = memo(function ThemeMenuItem({ - option, - onPreviewEnter, - onPreviewLeave, -}: ThemeMenuItemProps) { - const Icon = option.icon; - return ( -
    onPreviewEnter(option.value)} - onPointerLeave={onPreviewLeave} - > - - - {option.label} - -
    - ); -}); - -// Reusable Bug Report Button Component -const BugReportButton = ({ - sidebarExpanded, - onClick, -}: { - sidebarExpanded: boolean; - onClick: () => void; -}) => { - return ( - - ); -}; +// Local imports from subfolder +import type { NavSection, NavItem } from './sidebar/types'; +import { SortableProjectItem, ThemeMenuItem, BugReportButton } from './sidebar/components'; +import { + PROJECT_DARK_THEMES, + PROJECT_LIGHT_THEMES, + SIDEBAR_FEATURE_FLAGS, +} from './sidebar/constants'; export function Sidebar() { const navigate = useNavigate(); @@ -267,12 +126,8 @@ export function Sidebar() { } = useAppStore(); // Environment variable flags for hiding sidebar items - const hideTerminal = import.meta.env.VITE_HIDE_TERMINAL === 'true'; - const hideWiki = import.meta.env.VITE_HIDE_WIKI === 'true'; - const hideRunningAgents = import.meta.env.VITE_HIDE_RUNNING_AGENTS === 'true'; - const hideContext = import.meta.env.VITE_HIDE_CONTEXT === 'true'; - const hideSpecEditor = import.meta.env.VITE_HIDE_SPEC_EDITOR === 'true'; - const hideAiProfiles = import.meta.env.VITE_HIDE_AI_PROFILES === 'true'; + const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor, hideAiProfiles } = + SIDEBAR_FEATURE_FLAGS; // Get customizable keyboard shortcuts const shortcuts = useKeyboardShortcutsConfig(); diff --git a/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx b/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx new file mode 100644 index 00000000..68a413c4 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx @@ -0,0 +1,23 @@ +import { Bug } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { BugReportButtonProps } from '../types'; + +export function BugReportButton({ sidebarExpanded, onClick }: BugReportButtonProps) { + return ( + + ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/index.ts b/apps/ui/src/components/layout/sidebar/components/index.ts new file mode 100644 index 00000000..ecc7861e --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/index.ts @@ -0,0 +1,3 @@ +export { SortableProjectItem } from './sortable-project-item'; +export { ThemeMenuItem } from './theme-menu-item'; +export { BugReportButton } from './bug-report-button'; diff --git a/apps/ui/src/components/layout/sidebar/components/sortable-project-item.tsx b/apps/ui/src/components/layout/sidebar/components/sortable-project-item.tsx new file mode 100644 index 00000000..9d1e567e --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/sortable-project-item.tsx @@ -0,0 +1,54 @@ +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Folder, Check, GripVertical } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { SortableProjectItemProps } from '../types'; + +export function SortableProjectItem({ + project, + currentProjectId, + isHighlighted, + onSelect, +}: SortableProjectItemProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: project.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
    + {/* Drag Handle */} + + + {/* Project content - clickable area */} +
    onSelect(project)}> + + {project.name} + {currentProjectId === project.id && } +
    +
    + ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/theme-menu-item.tsx b/apps/ui/src/components/layout/sidebar/components/theme-menu-item.tsx new file mode 100644 index 00000000..5d9749b2 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/theme-menu-item.tsx @@ -0,0 +1,27 @@ +import { memo } from 'react'; +import { DropdownMenuRadioItem } from '@/components/ui/dropdown-menu'; +import type { ThemeMenuItemProps } from '../types'; + +export const ThemeMenuItem = memo(function ThemeMenuItem({ + option, + onPreviewEnter, + onPreviewLeave, +}: ThemeMenuItemProps) { + const Icon = option.icon; + return ( +
    onPreviewEnter(option.value)} + onPointerLeave={onPreviewLeave} + > + + + {option.label} + +
    + ); +}); diff --git a/apps/ui/src/components/layout/sidebar/constants.ts b/apps/ui/src/components/layout/sidebar/constants.ts new file mode 100644 index 00000000..4beca953 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/constants.ts @@ -0,0 +1,24 @@ +import { darkThemes, lightThemes } from '@/config/theme-options'; + +export const PROJECT_DARK_THEMES = darkThemes.map((opt) => ({ + value: opt.value, + label: opt.label, + icon: opt.Icon, + color: opt.color, +})); + +export const PROJECT_LIGHT_THEMES = lightThemes.map((opt) => ({ + value: opt.value, + label: opt.label, + icon: opt.Icon, + color: opt.color, +})); + +export const SIDEBAR_FEATURE_FLAGS = { + hideTerminal: import.meta.env.VITE_HIDE_TERMINAL === 'true', + hideWiki: import.meta.env.VITE_HIDE_WIKI === 'true', + hideRunningAgents: import.meta.env.VITE_HIDE_RUNNING_AGENTS === 'true', + hideContext: import.meta.env.VITE_HIDE_CONTEXT === 'true', + hideSpecEditor: import.meta.env.VITE_HIDE_SPEC_EDITOR === 'true', + hideAiProfiles: import.meta.env.VITE_HIDE_AI_PROFILES === 'true', +} as const; diff --git a/apps/ui/src/components/layout/sidebar/types.ts b/apps/ui/src/components/layout/sidebar/types.ts new file mode 100644 index 00000000..e76e4917 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/types.ts @@ -0,0 +1,36 @@ +import type { Project } from '@/lib/electron'; + +export interface NavSection { + label?: string; + items: NavItem[]; +} + +export interface NavItem { + id: string; + label: string; + icon: React.ComponentType<{ className?: string }>; + shortcut?: string; +} + +export interface SortableProjectItemProps { + project: Project; + currentProjectId: string | undefined; + isHighlighted: boolean; + onSelect: (project: Project) => void; +} + +export interface ThemeMenuItemProps { + option: { + value: string; + label: string; + icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; + color: string; + }; + onPreviewEnter: (value: string) => void; + onPreviewLeave: (e: React.PointerEvent) => void; +} + +export interface BugReportButtonProps { + sidebarExpanded: boolean; + onClick: () => void; +} From 7fac115a361e87e931dc702413d7dd41a88c0bf5 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 20:01:26 +0100 Subject: [PATCH 26/59] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20extract?= =?UTF-8?q?=20Phase=201=20hooks=20from=20sidebar=20(2187=E2=86=922099=20li?= =?UTF-8?q?nes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract 3 simple hooks with no UI dependencies: - use-theme-preview.ts: Debounced theme preview on hover - use-sidebar-auto-collapse.ts: Auto-collapse on small screens - use-drag-and-drop.ts: Project reordering drag-and-drop Benefits: - Reduced sidebar.tsx by 88 lines (-4%) - Improved testability (hooks can be tested in isolation) - Removed unused imports (DragEndEvent, PointerSensor, useSensor, useSensors) - Created hooks/ barrel export pattern Next steps: Extract 10+ remaining hooks and 10+ UI sections to reach target of 200-300 lines (current: 2099 lines, need to reduce ~1800 more) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/ui/src/components/layout/sidebar.tsx | 96 ++----------------- .../components/layout/sidebar/hooks/index.ts | 3 + .../layout/sidebar/hooks/use-drag-and-drop.ts | 41 ++++++++ .../hooks/use-sidebar-auto-collapse.ts | 30 ++++++ .../layout/sidebar/hooks/use-theme-preview.ts | 53 ++++++++++ 5 files changed, 134 insertions(+), 89 deletions(-) create mode 100644 apps/ui/src/components/layout/sidebar/hooks/index.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-drag-and-drop.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-theme-preview.ts diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 39ffef97..adc68ac2 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -75,14 +75,7 @@ import { DeleteProjectDialog } from '@/components/views/settings-view/components import { NewProjectModal } from '@/components/dialogs/new-project-modal'; import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; import type { FeatureCount } from '@/components/views/spec-view/types'; -import { - DndContext, - DragEndEvent, - PointerSensor, - useSensor, - useSensors, - closestCenter, -} from '@dnd-kit/core'; +import { DndContext, closestCenter } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { getHttpApiClient } from '@/lib/http-api-client'; import type { StarterTemplate } from '@/lib/templates'; @@ -95,6 +88,7 @@ import { PROJECT_LIGHT_THEMES, SIDEBAR_FEATURE_FLAGS, } from './sidebar/constants'; +import { useThemePreview, useSidebarAutoCollapse, useDragAndDrop } from './sidebar/hooks'; export function Sidebar() { const navigate = useNavigate(); @@ -164,45 +158,8 @@ export function Sidebar() { const [featureCount, setFeatureCount] = useState(50); const [showSpecIndicator, setShowSpecIndicator] = useState(true); - // Debounced preview theme handlers to prevent excessive re-renders - const previewTimeoutRef = useRef | null>(null); - - const handlePreviewEnter = useCallback( - (value: string) => { - // Clear any pending timeout - if (previewTimeoutRef.current) { - clearTimeout(previewTimeoutRef.current); - } - // Small delay to debounce rapid hover changes - previewTimeoutRef.current = setTimeout(() => { - setPreviewTheme(value as ThemeMode); - }, 16); // ~1 frame delay - }, - [setPreviewTheme] - ); - - const handlePreviewLeave = useCallback( - (e: React.PointerEvent) => { - const relatedTarget = e.relatedTarget as HTMLElement; - if (!relatedTarget?.closest('[data-testid^="project-theme-"]')) { - // Clear any pending timeout - if (previewTimeoutRef.current) { - clearTimeout(previewTimeoutRef.current); - } - setPreviewTheme(null); - } - }, - [setPreviewTheme] - ); - - // Cleanup timeout on unmount - useEffect(() => { - return () => { - if (previewTimeoutRef.current) { - clearTimeout(previewTimeoutRef.current); - } - }; - }, []); + // Debounced preview theme handlers + const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); // Derive isCreatingSpec from store state const isCreatingSpec = specCreatingForProject !== null; @@ -212,23 +169,7 @@ export function Sidebar() { const projectSearchInputRef = useRef(null); // Auto-collapse sidebar on small screens - useEffect(() => { - const mediaQuery = window.matchMedia('(max-width: 1024px)'); // lg breakpoint - - const handleResize = () => { - if (mediaQuery.matches && sidebarOpen) { - // Auto-collapse on small screens - toggleSidebar(); - } - }; - - // Check on mount - handleResize(); - - // Listen for changes - mediaQuery.addEventListener('change', handleResize); - return () => mediaQuery.removeEventListener('change', handleResize); - }, [sidebarOpen, toggleSidebar]); + useSidebarAutoCollapse({ sidebarOpen, toggleSidebar }); // Filtered projects based on search query const filteredProjects = useMemo(() => { @@ -262,31 +203,8 @@ export function Sidebar() { } }, [isProjectPickerOpen]); - // Sensors for drag-and-drop - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 5, // Small distance to start drag - }, - }) - ); - - // Handle drag end for reordering projects - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - - if (over && active.id !== over.id) { - const oldIndex = projects.findIndex((p) => p.id === active.id); - const newIndex = projects.findIndex((p) => p.id === over.id); - - if (oldIndex !== -1 && newIndex !== -1) { - reorderProjects(oldIndex, newIndex); - } - } - }, - [projects, reorderProjects] - ); + // Drag-and-drop for project reordering + const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects }); // Subscribe to spec regeneration events useEffect(() => { diff --git a/apps/ui/src/components/layout/sidebar/hooks/index.ts b/apps/ui/src/components/layout/sidebar/hooks/index.ts new file mode 100644 index 00000000..0255a7e5 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/index.ts @@ -0,0 +1,3 @@ +export { useThemePreview } from './use-theme-preview'; +export { useSidebarAutoCollapse } from './use-sidebar-auto-collapse'; +export { useDragAndDrop } from './use-drag-and-drop'; diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-drag-and-drop.ts b/apps/ui/src/components/layout/sidebar/hooks/use-drag-and-drop.ts new file mode 100644 index 00000000..570264a4 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-drag-and-drop.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; +import { useSensors, useSensor, PointerSensor, type DragEndEvent } from '@dnd-kit/core'; +import type { Project } from '@/lib/electron'; + +interface UseDragAndDropProps { + projects: Project[]; + reorderProjects: (oldIndex: number, newIndex: number) => void; +} + +export function useDragAndDrop({ projects, reorderProjects }: UseDragAndDropProps) { + // Sensors for drag-and-drop + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 5, // Small distance to start drag + }, + }) + ); + + // Handle drag end for reordering projects + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + const oldIndex = projects.findIndex((p) => p.id === active.id); + const newIndex = projects.findIndex((p) => p.id === over.id); + + if (oldIndex !== -1 && newIndex !== -1) { + reorderProjects(oldIndex, newIndex); + } + } + }, + [projects, reorderProjects] + ); + + return { + sensors, + handleDragEnd, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts b/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts new file mode 100644 index 00000000..994da088 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts @@ -0,0 +1,30 @@ +import { useEffect } from 'react'; + +interface UseSidebarAutoCollapseProps { + sidebarOpen: boolean; + toggleSidebar: () => void; +} + +export function useSidebarAutoCollapse({ + sidebarOpen, + toggleSidebar, +}: UseSidebarAutoCollapseProps) { + // Auto-collapse sidebar on small screens + useEffect(() => { + const mediaQuery = window.matchMedia('(max-width: 1024px)'); // lg breakpoint + + const handleResize = () => { + if (mediaQuery.matches && sidebarOpen) { + // Auto-collapse on small screens + toggleSidebar(); + } + }; + + // Check on mount + handleResize(); + + // Listen for changes + mediaQuery.addEventListener('change', handleResize); + return () => mediaQuery.removeEventListener('change', handleResize); + }, [sidebarOpen, toggleSidebar]); +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-theme-preview.ts b/apps/ui/src/components/layout/sidebar/hooks/use-theme-preview.ts new file mode 100644 index 00000000..46c25e93 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-theme-preview.ts @@ -0,0 +1,53 @@ +import { useRef, useCallback, useEffect } from 'react'; +import type { ThemeMode } from '@/store/app-store'; + +interface UseThemePreviewProps { + setPreviewTheme: (theme: ThemeMode | null) => void; +} + +export function useThemePreview({ setPreviewTheme }: UseThemePreviewProps) { + // Debounced preview theme handlers to prevent excessive re-renders + const previewTimeoutRef = useRef | null>(null); + + const handlePreviewEnter = useCallback( + (value: string) => { + // Clear any pending timeout + if (previewTimeoutRef.current) { + clearTimeout(previewTimeoutRef.current); + } + // Small delay to debounce rapid hover changes + previewTimeoutRef.current = setTimeout(() => { + setPreviewTheme(value as ThemeMode); + }, 16); // ~1 frame delay + }, + [setPreviewTheme] + ); + + const handlePreviewLeave = useCallback( + (e: React.PointerEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement; + if (!relatedTarget?.closest('[data-testid^="project-theme-"]')) { + // Clear any pending timeout + if (previewTimeoutRef.current) { + clearTimeout(previewTimeoutRef.current); + } + setPreviewTheme(null); + } + }, + [setPreviewTheme] + ); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (previewTimeoutRef.current) { + clearTimeout(previewTimeoutRef.current); + } + }; + }, []); + + return { + handlePreviewEnter, + handlePreviewLeave, + }; +} From b641884c37b9510ef6608bb0c8dfe01fe28b85a6 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 20:20:50 +0100 Subject: [PATCH 27/59] refactor: enhance sidebar functionality with new hooks and components - Introduced new hooks: useRunningAgents, useTrashOperations, useProjectPicker, useSpecRegeneration, and useNavigation for improved state management and functionality. - Created CollapseToggleButton component for sidebar collapse functionality, enhancing UI responsiveness. - Refactored sidebar.tsx to utilize the new hooks and components, improving code organization and maintainability. - Updated sidebar structure to streamline project selection and navigation processes. This refactor aims to enhance user experience and maintainability by modularizing functionality and improving the sidebar's responsiveness. --- apps/ui/src/components/layout/sidebar.tsx | 505 +++--------------- .../components/collapse-toggle-button.tsx | 60 +++ .../layout/sidebar/components/index.ts | 1 + .../components/layout/sidebar/hooks/index.ts | 5 + .../layout/sidebar/hooks/use-navigation.ts | 211 ++++++++ .../sidebar/hooks/use-project-picker.ts | 105 ++++ .../sidebar/hooks/use-running-agents.ts | 53 ++ .../sidebar/hooks/use-spec-regeneration.ts | 78 +++ .../sidebar/hooks/use-trash-operations.ts | 92 ++++ 9 files changed, 679 insertions(+), 431 deletions(-) create mode 100644 apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-project-picker.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-spec-regeneration.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-trash-operations.ts diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index adc68ac2..0fa7ce54 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect, useCallback, useRef, memo } from 'react'; +import { useState, useCallback } from 'react'; import { useNavigate, useLocation } from '@tanstack/react-router'; import { cn } from '@/lib/utils'; import { useAppStore, formatShortcut, type ThemeMode } from '@/store/app-store'; @@ -6,9 +6,6 @@ import { FolderOpen, Plus, Settings, - FileText, - LayoutGrid, - Bot, Folder, X, PanelLeft, @@ -16,12 +13,10 @@ import { ChevronDown, Redo2, Check, - BookOpen, GripVertical, RotateCcw, Trash2, Undo2, - UserCircle, MoreVertical, Palette, Monitor, @@ -31,7 +26,6 @@ import { Recycle, Sparkles, Loader2, - Terminal, Rocket, Zap, CheckCircle2, @@ -61,16 +55,11 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { - useKeyboardShortcuts, - useKeyboardShortcutsConfig, - KeyboardShortcut, -} from '@/hooks/use-keyboard-shortcuts'; +import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; import { getElectronAPI, Project, TrashedProject, RunningAgent } from '@/lib/electron'; import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; import { toast } from 'sonner'; import { themeOptions } from '@/config/theme-options'; -import type { SpecRegenerationEvent } from '@/types/electron'; import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog'; import { NewProjectModal } from '@/components/dialogs/new-project-modal'; import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; @@ -81,14 +70,27 @@ import { getHttpApiClient } from '@/lib/http-api-client'; import type { StarterTemplate } from '@/lib/templates'; // Local imports from subfolder -import type { NavSection, NavItem } from './sidebar/types'; -import { SortableProjectItem, ThemeMenuItem, BugReportButton } from './sidebar/components'; +import { + SortableProjectItem, + ThemeMenuItem, + BugReportButton, + CollapseToggleButton, +} from './sidebar/components'; import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES, SIDEBAR_FEATURE_FLAGS, } from './sidebar/constants'; -import { useThemePreview, useSidebarAutoCollapse, useDragAndDrop } from './sidebar/hooks'; +import { + useThemePreview, + useSidebarAutoCollapse, + useDragAndDrop, + useRunningAgents, + useTrashOperations, + useProjectPicker, + useSpecRegeneration, + useNavigation, +} from './sidebar/hooks'; export function Sidebar() { const navigate = useNavigate(); @@ -128,18 +130,11 @@ export function Sidebar() { // State for project picker dropdown const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false); - const [projectSearchQuery, setProjectSearchQuery] = useState(''); - const [selectedProjectIndex, setSelectedProjectIndex] = useState(0); const [showTrashDialog, setShowTrashDialog] = useState(false); - const [activeTrashId, setActiveTrashId] = useState(null); - const [isEmptyingTrash, setIsEmptyingTrash] = useState(false); // State for delete project confirmation dialog const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); - // State for running agents count - const [runningAgentsCount, setRunningAgentsCount] = useState(0); - // State for new project modal const [showNewProjectModal, setShowNewProjectModal] = useState(false); const [isCreatingProject, setIsCreatingProject] = useState(false); @@ -165,132 +160,56 @@ export function Sidebar() { const isCreatingSpec = specCreatingForProject !== null; const creatingSpecProjectPath = specCreatingForProject; - // Ref for project search input - const projectSearchInputRef = useRef(null); - // Auto-collapse sidebar on small screens useSidebarAutoCollapse({ sidebarOpen, toggleSidebar }); - // Filtered projects based on search query - const filteredProjects = useMemo(() => { - if (!projectSearchQuery.trim()) { - return projects; - } - const query = projectSearchQuery.toLowerCase(); - return projects.filter((project) => project.name.toLowerCase().includes(query)); - }, [projects, projectSearchQuery]); - - // Reset selection when filtered results change - useEffect(() => { - setSelectedProjectIndex(0); - }, [filteredProjects.length, projectSearchQuery]); - - // Reset search query when dropdown closes - useEffect(() => { - if (!isProjectPickerOpen) { - setProjectSearchQuery(''); - setSelectedProjectIndex(0); - } - }, [isProjectPickerOpen]); - - // Focus the search input when dropdown opens - useEffect(() => { - if (isProjectPickerOpen) { - // Small delay to ensure the dropdown is rendered - setTimeout(() => { - projectSearchInputRef.current?.focus(); - }, 0); - } - }, [isProjectPickerOpen]); + // Project picker with search and keyboard navigation + const { + projectSearchQuery, + setProjectSearchQuery, + selectedProjectIndex, + setSelectedProjectIndex, + projectSearchInputRef, + filteredProjects, + selectHighlightedProject, + } = useProjectPicker({ + projects, + isProjectPickerOpen, + setIsProjectPickerOpen, + setCurrentProject, + }); // Drag-and-drop for project reordering const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects }); - // Subscribe to spec regeneration events - useEffect(() => { - const api = getElectronAPI(); - if (!api.specRegeneration) return; + // Running agents count + const { runningAgentsCount } = useRunningAgents(); - const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => { - console.log( - '[Sidebar] Spec regeneration event:', - event.type, - 'for project:', - event.projectPath - ); + // Trash operations + const { + activeTrashId, + isEmptyingTrash, + handleRestoreProject, + handleDeleteProjectFromDisk, + handleEmptyTrash, + } = useTrashOperations({ + restoreTrashedProject, + deleteTrashedProject, + emptyTrash, + trashedProjects, + }); - // Only handle events for the project we're currently setting up - if (event.projectPath !== creatingSpecProjectPath && event.projectPath !== setupProjectPath) { - console.log('[Sidebar] Ignoring event - not for project being set up'); - return; - } - - if (event.type === 'spec_regeneration_complete') { - setSpecCreatingForProject(null); - setShowSetupDialog(false); - setProjectOverview(''); - setSetupProjectPath(''); - // Clear onboarding state if we came from onboarding - setNewProjectName(''); - setNewProjectPath(''); - toast.success('App specification created', { - description: 'Your project is now set up and ready to go!', - }); - } else if (event.type === 'spec_regeneration_error') { - setSpecCreatingForProject(null); - toast.error('Failed to create specification', { - description: event.error, - }); - } - }); - - return () => { - unsubscribe(); - }; - }, [creatingSpecProjectPath, setupProjectPath, setSpecCreatingForProject]); - - // Fetch running agents count function - used for initial load and event-driven updates - const fetchRunningAgentsCount = useCallback(async () => { - try { - const api = getElectronAPI(); - if (api.runningAgents) { - const result = await api.runningAgents.getAll(); - if (result.success && result.runningAgents) { - setRunningAgentsCount(result.runningAgents.length); - } - } - } catch (error) { - console.error('[Sidebar] Error fetching running agents count:', error); - } - }, []); - - // Subscribe to auto-mode events to update running agents count in real-time - useEffect(() => { - const api = getElectronAPI(); - if (!api.autoMode) { - // If autoMode is not available, still fetch initial count - fetchRunningAgentsCount(); - return; - } - - // Initial fetch on mount - fetchRunningAgentsCount(); - - const unsubscribe = api.autoMode.onEvent((event) => { - // When a feature starts, completes, or errors, refresh the count - if ( - event.type === 'auto_mode_feature_complete' || - event.type === 'auto_mode_error' || - event.type === 'auto_mode_feature_start' - ) { - fetchRunningAgentsCount(); - } - }); - - return () => { - unsubscribe(); - }; - }, [fetchRunningAgentsCount]); + // Spec regeneration events + useSpecRegeneration({ + creatingSpecProjectPath, + setupProjectPath, + setSpecCreatingForProject, + setShowSetupDialog, + setProjectOverview, + setSetupProjectPath, + setNewProjectName, + setNewProjectPath, + }); // Handle creating initial spec for new project const handleCreateInitialSpec = useCallback(async () => { @@ -711,261 +630,23 @@ export function Sidebar() { } }, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme]); - const handleRestoreProject = useCallback( - (projectId: string) => { - restoreTrashedProject(projectId); - toast.success('Project restored', { - description: 'Added back to your project list.', - }); - setShowTrashDialog(false); - }, - [restoreTrashedProject] - ); - - const handleDeleteProjectFromDisk = useCallback( - async (trashedProject: TrashedProject) => { - const confirmed = window.confirm( - `Delete "${trashedProject.name}" from disk?\nThis sends the folder to your system Trash.` - ); - if (!confirmed) return; - - setActiveTrashId(trashedProject.id); - try { - const api = getElectronAPI(); - if (!api.trashItem) { - throw new Error('System Trash is not available in this build.'); - } - - const result = await api.trashItem(trashedProject.path); - if (!result.success) { - throw new Error(result.error || 'Failed to delete project folder'); - } - - deleteTrashedProject(trashedProject.id); - toast.success('Project folder sent to system Trash', { - description: trashedProject.path, - }); - } catch (error) { - console.error('[Sidebar] Failed to delete project from disk:', error); - toast.error('Failed to delete project folder', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } finally { - setActiveTrashId(null); - } - }, - [deleteTrashedProject] - ); - - const handleEmptyTrash = useCallback(() => { - if (trashedProjects.length === 0) { - setShowTrashDialog(false); - return; - } - - const confirmed = window.confirm( - 'Clear all projects from recycle bin? This does not delete folders from disk.' - ); - if (!confirmed) return; - - setIsEmptyingTrash(true); - try { - emptyTrash(); - toast.success('Recycle bin cleared'); - setShowTrashDialog(false); - } finally { - setIsEmptyingTrash(false); - } - }, [emptyTrash, trashedProjects.length]); - - const navSections: NavSection[] = useMemo(() => { - const allToolsItems: NavItem[] = [ - { - id: 'spec', - label: 'Spec Editor', - icon: FileText, - shortcut: shortcuts.spec, - }, - { - id: 'context', - label: 'Context', - icon: BookOpen, - shortcut: shortcuts.context, - }, - { - id: 'profiles', - label: 'AI Profiles', - icon: UserCircle, - shortcut: shortcuts.profiles, - }, - ]; - - // Filter out hidden items - const visibleToolsItems = allToolsItems.filter((item) => { - if (item.id === 'spec' && hideSpecEditor) { - return false; - } - if (item.id === 'context' && hideContext) { - return false; - } - if (item.id === 'profiles' && hideAiProfiles) { - return false; - } - return true; - }); - - // Build project items - Terminal is conditionally included - const projectItems: NavItem[] = [ - { - id: 'board', - label: 'Kanban Board', - icon: LayoutGrid, - shortcut: shortcuts.board, - }, - { - id: 'agent', - label: 'Agent Runner', - icon: Bot, - shortcut: shortcuts.agent, - }, - ]; - - // Add Terminal to Project section if not hidden - if (!hideTerminal) { - projectItems.push({ - id: 'terminal', - label: 'Terminal', - icon: Terminal, - shortcut: shortcuts.terminal, - }); - } - - return [ - { - label: 'Project', - items: projectItems, - }, - { - label: 'Tools', - items: visibleToolsItems, - }, - ]; - }, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles]); - - // Handle selecting the currently highlighted project - const selectHighlightedProject = useCallback(() => { - if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) { - setCurrentProject(filteredProjects[selectedProjectIndex]); - setIsProjectPickerOpen(false); - } - }, [filteredProjects, selectedProjectIndex, setCurrentProject]); - - // Handle keyboard events when project picker is open - useEffect(() => { - if (!isProjectPickerOpen) return; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setIsProjectPickerOpen(false); - } else if (event.key === 'Enter') { - event.preventDefault(); - selectHighlightedProject(); - } else if (event.key === 'ArrowDown') { - event.preventDefault(); - setSelectedProjectIndex((prev) => (prev < filteredProjects.length - 1 ? prev + 1 : prev)); - } else if (event.key === 'ArrowUp') { - event.preventDefault(); - setSelectedProjectIndex((prev) => (prev > 0 ? prev - 1 : prev)); - } else if (event.key.toLowerCase() === 'p' && !event.metaKey && !event.ctrlKey) { - // Toggle off when P is pressed (not with modifiers) while dropdown is open - // Only if not typing in the search input - if (document.activeElement !== projectSearchInputRef.current) { - event.preventDefault(); - setIsProjectPickerOpen(false); - } - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isProjectPickerOpen, selectHighlightedProject, filteredProjects.length]); - - // Build keyboard shortcuts for navigation - const navigationShortcuts: KeyboardShortcut[] = useMemo(() => { - const shortcutsList: KeyboardShortcut[] = []; - - // Sidebar toggle shortcut - always available - shortcutsList.push({ - key: shortcuts.toggleSidebar, - action: () => toggleSidebar(), - description: 'Toggle sidebar', - }); - - // Open project shortcut - opens the folder selection dialog directly - shortcutsList.push({ - key: shortcuts.openProject, - action: () => handleOpenFolder(), - description: 'Open folder selection dialog', - }); - - // Project picker shortcut - only when we have projects - if (projects.length > 0) { - shortcutsList.push({ - key: shortcuts.projectPicker, - action: () => setIsProjectPickerOpen((prev) => !prev), - description: 'Toggle project picker', - }); - } - - // Project cycling shortcuts - only when we have project history - if (projectHistory.length > 1) { - shortcutsList.push({ - key: shortcuts.cyclePrevProject, - action: () => cyclePrevProject(), - description: 'Cycle to previous project (MRU)', - }); - shortcutsList.push({ - key: shortcuts.cycleNextProject, - action: () => cycleNextProject(), - description: 'Cycle to next project (LRU)', - }); - } - - // Only enable nav shortcuts if there's a current project - if (currentProject) { - navSections.forEach((section) => { - section.items.forEach((item) => { - if (item.shortcut) { - shortcutsList.push({ - key: item.shortcut, - action: () => navigate({ to: `/${item.id}` as const }), - description: `Navigate to ${item.label}`, - }); - } - }); - }); - - // Add settings shortcut - shortcutsList.push({ - key: shortcuts.settings, - action: () => navigate({ to: '/settings' }), - description: 'Navigate to Settings', - }); - } - - return shortcutsList; - }, [ + // Navigation sections and keyboard shortcuts (defined after handlers) + const { navSections, navigationShortcuts } = useNavigation({ shortcuts, + hideSpecEditor, + hideContext, + hideTerminal, + hideAiProfiles, currentProject, + projects, + projectHistory, navigate, toggleSidebar, - projects.length, handleOpenFolder, - projectHistory.length, + setIsProjectPickerOpen, cyclePrevProject, cycleNextProject, - navSections, - ]); + }); // Register keyboard shortcuts useKeyboardShortcuts(navigationShortcuts); @@ -990,49 +671,11 @@ export function Sidebar() { )} data-testid="sidebar" > - {/* Floating Collapse Toggle Button - Desktop only - At border intersection */} - +
    {/* Logo */} diff --git a/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx b/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx new file mode 100644 index 00000000..4c09056b --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx @@ -0,0 +1,60 @@ +import { PanelLeft, PanelLeftClose } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatShortcut } from '@/store/app-store'; + +interface CollapseToggleButtonProps { + sidebarOpen: boolean; + toggleSidebar: () => void; + shortcut: string; +} + +export function CollapseToggleButton({ + sidebarOpen, + toggleSidebar, + shortcut, +}: CollapseToggleButtonProps) { + return ( + + ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/index.ts b/apps/ui/src/components/layout/sidebar/components/index.ts index ecc7861e..0e320be9 100644 --- a/apps/ui/src/components/layout/sidebar/components/index.ts +++ b/apps/ui/src/components/layout/sidebar/components/index.ts @@ -1,3 +1,4 @@ export { SortableProjectItem } from './sortable-project-item'; export { ThemeMenuItem } from './theme-menu-item'; export { BugReportButton } from './bug-report-button'; +export { CollapseToggleButton } from './collapse-toggle-button'; diff --git a/apps/ui/src/components/layout/sidebar/hooks/index.ts b/apps/ui/src/components/layout/sidebar/hooks/index.ts index 0255a7e5..c5cca3b8 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/index.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/index.ts @@ -1,3 +1,8 @@ export { useThemePreview } from './use-theme-preview'; export { useSidebarAutoCollapse } from './use-sidebar-auto-collapse'; export { useDragAndDrop } from './use-drag-and-drop'; +export { useRunningAgents } from './use-running-agents'; +export { useTrashOperations } from './use-trash-operations'; +export { useProjectPicker } from './use-project-picker'; +export { useSpecRegeneration } from './use-spec-regeneration'; +export { useNavigation } from './use-navigation'; diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts new file mode 100644 index 00000000..3148ede0 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts @@ -0,0 +1,211 @@ +import { useMemo } from 'react'; +import type { NavigateOptions } from '@tanstack/react-router'; +import { FileText, LayoutGrid, Bot, BookOpen, UserCircle, Terminal } from 'lucide-react'; +import type { NavSection, NavItem } from '../types'; +import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; +import type { Project } from '@/lib/electron'; + +interface UseNavigationProps { + shortcuts: { + toggleSidebar: string; + openProject: string; + projectPicker: string; + cyclePrevProject: string; + cycleNextProject: string; + spec: string; + context: string; + profiles: string; + board: string; + agent: string; + terminal: string; + settings: string; + }; + hideSpecEditor: boolean; + hideContext: boolean; + hideTerminal: boolean; + hideAiProfiles: boolean; + currentProject: Project | null; + projects: Project[]; + projectHistory: string[]; + navigate: (opts: NavigateOptions) => void; + toggleSidebar: () => void; + handleOpenFolder: () => void; + setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void; + cyclePrevProject: () => void; + cycleNextProject: () => void; +} + +export function useNavigation({ + shortcuts, + hideSpecEditor, + hideContext, + hideTerminal, + hideAiProfiles, + currentProject, + projects, + projectHistory, + navigate, + toggleSidebar, + handleOpenFolder, + setIsProjectPickerOpen, + cyclePrevProject, + cycleNextProject, +}: UseNavigationProps) { + // Build navigation sections + const navSections: NavSection[] = useMemo(() => { + const allToolsItems: NavItem[] = [ + { + id: 'spec', + label: 'Spec Editor', + icon: FileText, + shortcut: shortcuts.spec, + }, + { + id: 'context', + label: 'Context', + icon: BookOpen, + shortcut: shortcuts.context, + }, + { + id: 'profiles', + label: 'AI Profiles', + icon: UserCircle, + shortcut: shortcuts.profiles, + }, + ]; + + // Filter out hidden items + const visibleToolsItems = allToolsItems.filter((item) => { + if (item.id === 'spec' && hideSpecEditor) { + return false; + } + if (item.id === 'context' && hideContext) { + return false; + } + if (item.id === 'profiles' && hideAiProfiles) { + return false; + } + return true; + }); + + // Build project items - Terminal is conditionally included + const projectItems: NavItem[] = [ + { + id: 'board', + label: 'Kanban Board', + icon: LayoutGrid, + shortcut: shortcuts.board, + }, + { + id: 'agent', + label: 'Agent Runner', + icon: Bot, + shortcut: shortcuts.agent, + }, + ]; + + // Add Terminal to Project section if not hidden + if (!hideTerminal) { + projectItems.push({ + id: 'terminal', + label: 'Terminal', + icon: Terminal, + shortcut: shortcuts.terminal, + }); + } + + return [ + { + label: 'Project', + items: projectItems, + }, + { + label: 'Tools', + items: visibleToolsItems, + }, + ]; + }, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles]); + + // Build keyboard shortcuts for navigation + const navigationShortcuts: KeyboardShortcut[] = useMemo(() => { + const shortcutsList: KeyboardShortcut[] = []; + + // Sidebar toggle shortcut - always available + shortcutsList.push({ + key: shortcuts.toggleSidebar, + action: () => toggleSidebar(), + description: 'Toggle sidebar', + }); + + // Open project shortcut - opens the folder selection dialog directly + shortcutsList.push({ + key: shortcuts.openProject, + action: () => handleOpenFolder(), + description: 'Open folder selection dialog', + }); + + // Project picker shortcut - only when we have projects + if (projects.length > 0) { + shortcutsList.push({ + key: shortcuts.projectPicker, + action: () => setIsProjectPickerOpen((prev) => !prev), + description: 'Toggle project picker', + }); + } + + // Project cycling shortcuts - only when we have project history + if (projectHistory.length > 1) { + shortcutsList.push({ + key: shortcuts.cyclePrevProject, + action: () => cyclePrevProject(), + description: 'Cycle to previous project (MRU)', + }); + shortcutsList.push({ + key: shortcuts.cycleNextProject, + action: () => cycleNextProject(), + description: 'Cycle to next project (LRU)', + }); + } + + // Only enable nav shortcuts if there's a current project + if (currentProject) { + navSections.forEach((section) => { + section.items.forEach((item) => { + if (item.shortcut) { + shortcutsList.push({ + key: item.shortcut, + action: () => navigate({ to: `/${item.id}` as const }), + description: `Navigate to ${item.label}`, + }); + } + }); + }); + + // Add settings shortcut + shortcutsList.push({ + key: shortcuts.settings, + action: () => navigate({ to: '/settings' }), + description: 'Navigate to Settings', + }); + } + + return shortcutsList; + }, [ + shortcuts, + currentProject, + navigate, + toggleSidebar, + projects.length, + handleOpenFolder, + projectHistory.length, + cyclePrevProject, + cycleNextProject, + navSections, + setIsProjectPickerOpen, + ]); + + return { + navSections, + navigationShortcuts, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-project-picker.ts b/apps/ui/src/components/layout/sidebar/hooks/use-project-picker.ts new file mode 100644 index 00000000..7a8566dc --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-project-picker.ts @@ -0,0 +1,105 @@ +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import type { Project } from '@/lib/electron'; + +interface UseProjectPickerProps { + projects: Project[]; + isProjectPickerOpen: boolean; + setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void; + setCurrentProject: (project: Project) => void; +} + +export function useProjectPicker({ + projects, + isProjectPickerOpen, + setIsProjectPickerOpen, + setCurrentProject, +}: UseProjectPickerProps) { + const [projectSearchQuery, setProjectSearchQuery] = useState(''); + const [selectedProjectIndex, setSelectedProjectIndex] = useState(0); + const projectSearchInputRef = useRef(null); + + // Filtered projects based on search query + const filteredProjects = useMemo(() => { + if (!projectSearchQuery.trim()) { + return projects; + } + const query = projectSearchQuery.toLowerCase(); + return projects.filter((project) => project.name.toLowerCase().includes(query)); + }, [projects, projectSearchQuery]); + + // Reset selection when filtered results change + useEffect(() => { + setSelectedProjectIndex(0); + }, [filteredProjects.length, projectSearchQuery]); + + // Reset search query when dropdown closes + useEffect(() => { + if (!isProjectPickerOpen) { + setProjectSearchQuery(''); + setSelectedProjectIndex(0); + } + }, [isProjectPickerOpen]); + + // Focus the search input when dropdown opens + useEffect(() => { + if (isProjectPickerOpen) { + // Small delay to ensure the dropdown is rendered + setTimeout(() => { + projectSearchInputRef.current?.focus(); + }, 0); + } + }, [isProjectPickerOpen]); + + // Handle selecting the currently highlighted project + const selectHighlightedProject = useCallback(() => { + if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) { + setCurrentProject(filteredProjects[selectedProjectIndex]); + setIsProjectPickerOpen(false); + } + }, [filteredProjects, selectedProjectIndex, setCurrentProject, setIsProjectPickerOpen]); + + // Handle keyboard events when project picker is open + useEffect(() => { + if (!isProjectPickerOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsProjectPickerOpen(false); + } else if (event.key === 'Enter') { + event.preventDefault(); + selectHighlightedProject(); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + setSelectedProjectIndex((prev) => (prev < filteredProjects.length - 1 ? prev + 1 : prev)); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setSelectedProjectIndex((prev) => (prev > 0 ? prev - 1 : prev)); + } else if (event.key.toLowerCase() === 'p' && !event.metaKey && !event.ctrlKey) { + // Toggle off when P is pressed (not with modifiers) while dropdown is open + // Only if not typing in the search input + if (document.activeElement !== projectSearchInputRef.current) { + event.preventDefault(); + setIsProjectPickerOpen(false); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ + isProjectPickerOpen, + selectHighlightedProject, + filteredProjects.length, + setIsProjectPickerOpen, + ]); + + return { + projectSearchQuery, + setProjectSearchQuery, + selectedProjectIndex, + setSelectedProjectIndex, + projectSearchInputRef, + filteredProjects, + selectHighlightedProject, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts b/apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts new file mode 100644 index 00000000..7431e934 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts @@ -0,0 +1,53 @@ +import { useState, useEffect, useCallback } from 'react'; +import { getElectronAPI } from '@/lib/electron'; + +export function useRunningAgents() { + const [runningAgentsCount, setRunningAgentsCount] = useState(0); + + // Fetch running agents count function - used for initial load and event-driven updates + const fetchRunningAgentsCount = useCallback(async () => { + try { + const api = getElectronAPI(); + if (api.runningAgents) { + const result = await api.runningAgents.getAll(); + if (result.success && result.runningAgents) { + setRunningAgentsCount(result.runningAgents.length); + } + } + } catch (error) { + console.error('[Sidebar] Error fetching running agents count:', error); + } + }, []); + + // Subscribe to auto-mode events to update running agents count in real-time + useEffect(() => { + const api = getElectronAPI(); + if (!api.autoMode) { + // If autoMode is not available, still fetch initial count + fetchRunningAgentsCount(); + return; + } + + // Initial fetch on mount + fetchRunningAgentsCount(); + + const unsubscribe = api.autoMode.onEvent((event) => { + // When a feature starts, completes, or errors, refresh the count + if ( + event.type === 'auto_mode_feature_complete' || + event.type === 'auto_mode_error' || + event.type === 'auto_mode_feature_start' + ) { + fetchRunningAgentsCount(); + } + }); + + return () => { + unsubscribe(); + }; + }, [fetchRunningAgentsCount]); + + return { + runningAgentsCount, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-spec-regeneration.ts b/apps/ui/src/components/layout/sidebar/hooks/use-spec-regeneration.ts new file mode 100644 index 00000000..5337a603 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-spec-regeneration.ts @@ -0,0 +1,78 @@ +import { useEffect } from 'react'; +import { toast } from 'sonner'; +import { getElectronAPI } from '@/lib/electron'; +import type { SpecRegenerationEvent } from '@/types/electron'; + +interface UseSpecRegenerationProps { + creatingSpecProjectPath: string | null; + setupProjectPath: string; + setSpecCreatingForProject: (path: string | null) => void; + setShowSetupDialog: (show: boolean) => void; + setProjectOverview: (overview: string) => void; + setSetupProjectPath: (path: string) => void; + setNewProjectName: (name: string) => void; + setNewProjectPath: (path: string) => void; +} + +export function useSpecRegeneration({ + creatingSpecProjectPath, + setupProjectPath, + setSpecCreatingForProject, + setShowSetupDialog, + setProjectOverview, + setSetupProjectPath, + setNewProjectName, + setNewProjectPath, +}: UseSpecRegenerationProps) { + // Subscribe to spec regeneration events + useEffect(() => { + const api = getElectronAPI(); + if (!api.specRegeneration) return; + + const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => { + console.log( + '[Sidebar] Spec regeneration event:', + event.type, + 'for project:', + event.projectPath + ); + + // Only handle events for the project we're currently setting up + if (event.projectPath !== creatingSpecProjectPath && event.projectPath !== setupProjectPath) { + console.log('[Sidebar] Ignoring event - not for project being set up'); + return; + } + + if (event.type === 'spec_regeneration_complete') { + setSpecCreatingForProject(null); + setShowSetupDialog(false); + setProjectOverview(''); + setSetupProjectPath(''); + // Clear onboarding state if we came from onboarding + setNewProjectName(''); + setNewProjectPath(''); + toast.success('App specification created', { + description: 'Your project is now set up and ready to go!', + }); + } else if (event.type === 'spec_regeneration_error') { + setSpecCreatingForProject(null); + toast.error('Failed to create specification', { + description: event.error, + }); + } + }); + + return () => { + unsubscribe(); + }; + }, [ + creatingSpecProjectPath, + setupProjectPath, + setSpecCreatingForProject, + setShowSetupDialog, + setProjectOverview, + setSetupProjectPath, + setNewProjectName, + setNewProjectPath, + ]); +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-trash-operations.ts b/apps/ui/src/components/layout/sidebar/hooks/use-trash-operations.ts new file mode 100644 index 00000000..bb0dc571 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-trash-operations.ts @@ -0,0 +1,92 @@ +import { useState, useCallback } from 'react'; +import { toast } from 'sonner'; +import { getElectronAPI, type TrashedProject } from '@/lib/electron'; + +interface UseTrashOperationsProps { + restoreTrashedProject: (projectId: string) => void; + deleteTrashedProject: (projectId: string) => void; + emptyTrash: () => void; + trashedProjects: TrashedProject[]; +} + +export function useTrashOperations({ + restoreTrashedProject, + deleteTrashedProject, + emptyTrash, + trashedProjects, +}: UseTrashOperationsProps) { + const [activeTrashId, setActiveTrashId] = useState(null); + const [isEmptyingTrash, setIsEmptyingTrash] = useState(false); + + const handleRestoreProject = useCallback( + (projectId: string) => { + restoreTrashedProject(projectId); + toast.success('Project restored', { + description: 'Added back to your project list.', + }); + }, + [restoreTrashedProject] + ); + + const handleDeleteProjectFromDisk = useCallback( + async (trashedProject: TrashedProject) => { + const confirmed = window.confirm( + `Delete "${trashedProject.name}" from disk?\nThis sends the folder to your system Trash.` + ); + if (!confirmed) return; + + setActiveTrashId(trashedProject.id); + try { + const api = getElectronAPI(); + if (!api.trashItem) { + throw new Error('System Trash is not available in this build.'); + } + + const result = await api.trashItem(trashedProject.path); + if (!result.success) { + throw new Error(result.error || 'Failed to delete project folder'); + } + + deleteTrashedProject(trashedProject.id); + toast.success('Project folder sent to system Trash', { + description: trashedProject.path, + }); + } catch (error) { + console.error('[Sidebar] Failed to delete project from disk:', error); + toast.error('Failed to delete project folder', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setActiveTrashId(null); + } + }, + [deleteTrashedProject] + ); + + const handleEmptyTrash = useCallback(() => { + if (trashedProjects.length === 0) { + return; + } + + const confirmed = window.confirm( + 'Clear all projects from recycle bin? This does not delete folders from disk.' + ); + if (!confirmed) return; + + setIsEmptyingTrash(true); + try { + emptyTrash(); + toast.success('Recycle bin cleared'); + } finally { + setIsEmptyingTrash(false); + } + }, [emptyTrash, trashedProjects.length]); + + return { + activeTrashId, + isEmptyingTrash, + handleRestoreProject, + handleDeleteProjectFromDisk, + handleEmptyTrash, + }; +} From aafd0b39913f83c098f7ffa8a93ab2266cb9825d Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 20:29:16 +0100 Subject: [PATCH 28/59] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20extract?= =?UTF-8?q?=20UI=20components=20from=20sidebar=20for=20better=20maintainab?= =?UTF-8?q?ility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract logo, header, actions, and navigation into separate components: - AutomakerLogo: SVG logo with collapsed/expanded states - SidebarHeader: Logo section with bug report button - ProjectActions: New/Open/Trash action buttons - SidebarNavigation: Navigation items with active states Reduces sidebar.tsx from 1551 to 1442 lines (-109 lines) Improves code organization and component reusability 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/ui/src/components/layout/sidebar.tsx | 340 ++---------------- .../sidebar/components/automaker-logo.tsx | 117 ++++++ .../layout/sidebar/components/index.ts | 4 + .../sidebar/components/project-actions.tsx | 91 +++++ .../sidebar/components/sidebar-header.tsx | 40 +++ .../sidebar/components/sidebar-navigation.tsx | 140 ++++++++ 6 files changed, 414 insertions(+), 318 deletions(-) create mode 100644 apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx create mode 100644 apps/ui/src/components/layout/sidebar/components/project-actions.tsx create mode 100644 apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx create mode 100644 apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 0fa7ce54..2b8c1e8a 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -3,13 +3,9 @@ import { useNavigate, useLocation } from '@tanstack/react-router'; import { cn } from '@/lib/utils'; import { useAppStore, formatShortcut, type ThemeMode } from '@/store/app-store'; import { - FolderOpen, - Plus, Settings, Folder, X, - PanelLeft, - PanelLeftClose, ChevronDown, Redo2, Check, @@ -21,9 +17,7 @@ import { Palette, Monitor, Search, - Bug, Activity, - Recycle, Sparkles, Loader2, Rocket, @@ -75,6 +69,9 @@ import { ThemeMenuItem, BugReportButton, CollapseToggleButton, + SidebarHeader, + ProjectActions, + SidebarNavigation, } from './sidebar/components'; import { PROJECT_DARK_THEMES, @@ -678,204 +675,21 @@ export function Sidebar() { />
    - {/* Logo */} -
    -
    navigate({ to: '/' })} - data-testid="logo-button" - > - {!sidebarOpen ? ( -
    - - - - - - - - - - - - - - - - - -
    - ) : ( -
    - - - - - - - - - - - - - - - - - - - automaker. - -
    - )} -
    - {/* Bug Report Button - Inside logo container when expanded */} - {sidebarOpen && } -
    - - {/* Bug Report Button - Collapsed sidebar version */} - {!sidebarOpen && ( -
    - -
    - )} + {/* Project Actions - Moved above project selector */} {sidebarOpen && ( -
    - - - -
    + )} {/* Project Selector with Cycle Buttons */} @@ -1163,123 +977,13 @@ export function Sidebar() {
    )} - {/* Nav Items - Scrollable */} - +
    {/* Bottom Section - Running Agents / Bug Report / Settings */} diff --git a/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx b/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx new file mode 100644 index 00000000..66345b92 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx @@ -0,0 +1,117 @@ +import type { NavigateOptions } from '@tanstack/react-router'; +import { cn } from '@/lib/utils'; + +interface AutomakerLogoProps { + sidebarOpen: boolean; + navigate: (opts: NavigateOptions) => void; +} + +export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) { + return ( +
    navigate({ to: '/' })} + data-testid="logo-button" + > + {!sidebarOpen ? ( +
    + + + + + + + + + + + + + + + + + +
    + ) : ( +
    + + + + + + + + + + + + + + + + + + + automaker. + +
    + )} +
    + ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/index.ts b/apps/ui/src/components/layout/sidebar/components/index.ts index 0e320be9..31ecd852 100644 --- a/apps/ui/src/components/layout/sidebar/components/index.ts +++ b/apps/ui/src/components/layout/sidebar/components/index.ts @@ -2,3 +2,7 @@ export { SortableProjectItem } from './sortable-project-item'; export { ThemeMenuItem } from './theme-menu-item'; export { BugReportButton } from './bug-report-button'; export { CollapseToggleButton } from './collapse-toggle-button'; +export { AutomakerLogo } from './automaker-logo'; +export { SidebarHeader } from './sidebar-header'; +export { ProjectActions } from './project-actions'; +export { SidebarNavigation } from './sidebar-navigation'; diff --git a/apps/ui/src/components/layout/sidebar/components/project-actions.tsx b/apps/ui/src/components/layout/sidebar/components/project-actions.tsx new file mode 100644 index 00000000..3730afe7 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/project-actions.tsx @@ -0,0 +1,91 @@ +import { Plus, FolderOpen, Recycle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatShortcut } from '@/store/app-store'; +import type { TrashedProject } from '@/lib/electron'; + +interface ProjectActionsProps { + setShowNewProjectModal: (show: boolean) => void; + handleOpenFolder: () => void; + setShowTrashDialog: (show: boolean) => void; + trashedProjects: TrashedProject[]; + shortcuts: { + openProject: string; + }; +} + +export function ProjectActions({ + setShowNewProjectModal, + handleOpenFolder, + setShowTrashDialog, + trashedProjects, + shortcuts, +}: ProjectActionsProps) { + return ( +
    + + + +
    + ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx new file mode 100644 index 00000000..89352f15 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx @@ -0,0 +1,40 @@ +import type { NavigateOptions } from '@tanstack/react-router'; +import { cn } from '@/lib/utils'; +import { AutomakerLogo } from './automaker-logo'; +import { BugReportButton } from './bug-report-button'; + +interface SidebarHeaderProps { + sidebarOpen: boolean; + navigate: (opts: NavigateOptions) => void; + handleBugReportClick: () => void; +} + +export function SidebarHeader({ sidebarOpen, navigate, handleBugReportClick }: SidebarHeaderProps) { + return ( + <> + {/* Logo */} +
    + + {/* Bug Report Button - Inside logo container when expanded */} + {sidebarOpen && } +
    + + {/* Bug Report Button - Collapsed sidebar version */} + {!sidebarOpen && ( +
    + +
    + )} + + ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx new file mode 100644 index 00000000..4e0f7cf1 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx @@ -0,0 +1,140 @@ +import type { NavigateOptions } from '@tanstack/react-router'; +import { cn } from '@/lib/utils'; +import { formatShortcut } from '@/store/app-store'; +import type { NavSection } from '../types'; +import type { Project } from '@/lib/electron'; + +interface SidebarNavigationProps { + currentProject: Project | null; + sidebarOpen: boolean; + navSections: NavSection[]; + isActiveRoute: (id: string) => boolean; + navigate: (opts: NavigateOptions) => void; +} + +export function SidebarNavigation({ + currentProject, + sidebarOpen, + navSections, + isActiveRoute, + navigate, +}: SidebarNavigationProps) { + return ( + + ); +} From a40bb6df24e04e986e0b9544c51a9ac62a2ef871 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 21:23:04 +0100 Subject: [PATCH 29/59] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20streamli?= =?UTF-8?q?ne=20sidebar=20component=20structure=20and=20enhance=20function?= =?UTF-8?q?ality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extracted new components: ProjectSelectorWithOptions, SidebarFooter, TrashDialog, and OnboardingDialog to improve code organization and reusability. - Introduced new hooks: useProjectCreation, useSetupDialog, and useTrashDialog for better state management and modularity. - Updated sidebar.tsx to utilize the new components and hooks, reducing complexity and improving maintainability. - Enhanced project creation and setup processes with dedicated dialogs and streamlined user interactions. This refactor aims to enhance the user experience and maintainability of the sidebar by modularizing functionality and improving the overall structure. --- apps/ui/src/components/layout/sidebar.tsx | 1255 ++--------------- .../layout/sidebar/components/index.ts | 2 + .../project-selector-with-options.tsx | 374 +++++ .../sidebar/components/sidebar-footer.tsx | 269 ++++ .../layout/sidebar/dialogs/index.ts | 2 + .../sidebar/dialogs/onboarding-dialog.tsx | 122 ++ .../layout/sidebar/dialogs/trash-dialog.tsx | 116 ++ .../components/layout/sidebar/hooks/index.ts | 4 + .../sidebar/hooks/use-project-creation.ts | 175 +++ .../layout/sidebar/hooks/use-project-theme.ts | 25 + .../layout/sidebar/hooks/use-setup-dialog.ts | 147 ++ .../layout/sidebar/hooks/use-trash-dialog.ts | 40 + 12 files changed, 1371 insertions(+), 1160 deletions(-) create mode 100644 apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx create mode 100644 apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx create mode 100644 apps/ui/src/components/layout/sidebar/dialogs/index.ts create mode 100644 apps/ui/src/components/layout/sidebar/dialogs/onboarding-dialog.tsx create mode 100644 apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-project-theme.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-trash-dialog.ts diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 2b8c1e8a..e59c6744 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -1,92 +1,35 @@ import { useState, useCallback } from 'react'; import { useNavigate, useLocation } from '@tanstack/react-router'; import { cn } from '@/lib/utils'; -import { useAppStore, formatShortcut, type ThemeMode } from '@/store/app-store'; -import { - Settings, - Folder, - X, - ChevronDown, - Redo2, - Check, - GripVertical, - RotateCcw, - Trash2, - Undo2, - MoreVertical, - Palette, - Monitor, - Search, - Activity, - Sparkles, - Loader2, - Rocket, - Zap, - CheckCircle2, - ArrowRight, - Moon, - Sun, -} from 'lucide-react'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuLabel, -} from '@/components/ui/dropdown-menu'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; +import { useAppStore, type ThemeMode } from '@/store/app-store'; import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; -import { getElectronAPI, Project, TrashedProject, RunningAgent } from '@/lib/electron'; +import { getElectronAPI } from '@/lib/electron'; import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; import { toast } from 'sonner'; -import { themeOptions } from '@/config/theme-options'; import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog'; import { NewProjectModal } from '@/components/dialogs/new-project-modal'; import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; -import type { FeatureCount } from '@/components/views/spec-view/types'; -import { DndContext, closestCenter } from '@dnd-kit/core'; -import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { getHttpApiClient } from '@/lib/http-api-client'; -import type { StarterTemplate } from '@/lib/templates'; // Local imports from subfolder import { - SortableProjectItem, - ThemeMenuItem, - BugReportButton, CollapseToggleButton, SidebarHeader, ProjectActions, SidebarNavigation, + ProjectSelectorWithOptions, + SidebarFooter, } from './sidebar/components'; +import { TrashDialog, OnboardingDialog } from './sidebar/dialogs'; +import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants'; import { - PROJECT_DARK_THEMES, - PROJECT_LIGHT_THEMES, - SIDEBAR_FEATURE_FLAGS, -} from './sidebar/constants'; -import { - useThemePreview, useSidebarAutoCollapse, - useDragAndDrop, useRunningAgents, - useTrashOperations, - useProjectPicker, useSpecRegeneration, useNavigation, + useProjectCreation, + useSetupDialog, + useTrashDialog, + useProjectTheme, } from './sidebar/hooks'; export function Sidebar() { @@ -100,19 +43,12 @@ export function Sidebar() { sidebarOpen, projectHistory, upsertAndSetCurrentProject, - setCurrentProject, toggleSidebar, restoreTrashedProject, deleteTrashedProject, emptyTrash, - reorderProjects, cyclePrevProject, cycleNextProject, - clearProjectHistory, - setProjectTheme, - setTheme, - setPreviewTheme, - theme: globalTheme, moveProjectToTrash, specCreatingForProject, setSpecCreatingForProject, @@ -125,33 +61,61 @@ export function Sidebar() { // Get customizable keyboard shortcuts const shortcuts = useKeyboardShortcutsConfig(); - // State for project picker dropdown + // State for project picker (needed for keyboard shortcuts) const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false); - const [showTrashDialog, setShowTrashDialog] = useState(false); // State for delete project confirmation dialog const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); - // State for new project modal - const [showNewProjectModal, setShowNewProjectModal] = useState(false); - const [isCreatingProject, setIsCreatingProject] = useState(false); + // Project theme management (must come before useProjectCreation which uses globalTheme) + const { globalTheme } = useProjectTheme(); - // State for new project onboarding dialog - const [showOnboardingDialog, setShowOnboardingDialog] = useState(false); - const [newProjectName, setNewProjectName] = useState(''); - const [newProjectPath, setNewProjectPath] = useState(''); + // Project creation state and handlers + const { + showNewProjectModal, + setShowNewProjectModal, + isCreatingProject, + showOnboardingDialog, + setShowOnboardingDialog, + newProjectName, + setNewProjectName, + newProjectPath, + setNewProjectPath, + handleCreateBlankProject, + handleCreateFromTemplate, + handleCreateFromCustomUrl, + } = useProjectCreation({ + trashedProjects, + currentProject, + globalTheme, + upsertAndSetCurrentProject, + }); - // State for new project setup dialog - const [showSetupDialog, setShowSetupDialog] = useState(false); - const [setupProjectPath, setSetupProjectPath] = useState(''); - const [projectOverview, setProjectOverview] = useState(''); - const [generateFeatures, setGenerateFeatures] = useState(true); - const [analyzeProject, setAnalyzeProject] = useState(true); - const [featureCount, setFeatureCount] = useState(50); - const [showSpecIndicator, setShowSpecIndicator] = useState(true); - - // Debounced preview theme handlers - const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); + // Setup dialog state and handlers + const { + showSetupDialog, + setShowSetupDialog, + setupProjectPath, + setSetupProjectPath, + projectOverview, + setProjectOverview, + generateFeatures, + setGenerateFeatures, + analyzeProject, + setAnalyzeProject, + featureCount, + setFeatureCount, + handleCreateInitialSpec, + handleSkipSetup, + handleOnboardingGenerateSpec, + handleOnboardingSkip, + } = useSetupDialog({ + setSpecCreatingForProject, + newProjectPath, + setNewProjectName, + setNewProjectPath, + setShowOnboardingDialog, + }); // Derive isCreatingSpec from store state const isCreatingSpec = specCreatingForProject !== null; @@ -160,36 +124,19 @@ export function Sidebar() { // Auto-collapse sidebar on small screens useSidebarAutoCollapse({ sidebarOpen, toggleSidebar }); - // Project picker with search and keyboard navigation - const { - projectSearchQuery, - setProjectSearchQuery, - selectedProjectIndex, - setSelectedProjectIndex, - projectSearchInputRef, - filteredProjects, - selectHighlightedProject, - } = useProjectPicker({ - projects, - isProjectPickerOpen, - setIsProjectPickerOpen, - setCurrentProject, - }); - - // Drag-and-drop for project reordering - const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects }); - // Running agents count const { runningAgentsCount } = useRunningAgents(); - // Trash operations + // Trash dialog and operations const { + showTrashDialog, + setShowTrashDialog, activeTrashId, isEmptyingTrash, handleRestoreProject, handleDeleteProjectFromDisk, handleEmptyTrash, - } = useTrashOperations({ + } = useTrashDialog({ restoreTrashedProject, deleteTrashedProject, emptyTrash, @@ -208,355 +155,6 @@ export function Sidebar() { setNewProjectPath, }); - // Handle creating initial spec for new project - const handleCreateInitialSpec = useCallback(async () => { - if (!setupProjectPath || !projectOverview.trim()) return; - - // Set store state immediately so the loader shows up right away - setSpecCreatingForProject(setupProjectPath); - setShowSpecIndicator(true); - setShowSetupDialog(false); - - try { - const api = getElectronAPI(); - if (!api.specRegeneration) { - toast.error('Spec regeneration not available'); - setSpecCreatingForProject(null); - return; - } - const result = await api.specRegeneration.create( - setupProjectPath, - projectOverview.trim(), - generateFeatures, - analyzeProject, - generateFeatures ? featureCount : undefined // only pass maxFeatures if generating features - ); - - if (!result.success) { - console.error('[Sidebar] Failed to start spec creation:', result.error); - setSpecCreatingForProject(null); - toast.error('Failed to create specification', { - description: result.error, - }); - } else { - // Show processing toast to inform user - toast.info('Generating app specification...', { - description: "This may take a minute. You'll be notified when complete.", - }); - } - // If successful, we'll wait for the events to update the state - } catch (error) { - console.error('[Sidebar] Failed to create spec:', error); - setSpecCreatingForProject(null); - toast.error('Failed to create specification', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } - }, [ - setupProjectPath, - projectOverview, - generateFeatures, - analyzeProject, - featureCount, - setSpecCreatingForProject, - ]); - - // Handle skipping setup - const handleSkipSetup = useCallback(() => { - setShowSetupDialog(false); - setProjectOverview(''); - setSetupProjectPath(''); - // Clear onboarding state if we came from onboarding - if (newProjectPath) { - setNewProjectName(''); - setNewProjectPath(''); - } - toast.info('Setup skipped', { - description: 'You can set up your app_spec.txt later from the Spec view.', - }); - }, [newProjectPath]); - - // Handle onboarding dialog - generate spec - const handleOnboardingGenerateSpec = useCallback(() => { - setShowOnboardingDialog(false); - // Navigate to the setup dialog flow - setSetupProjectPath(newProjectPath); - setProjectOverview(''); - setShowSetupDialog(true); - }, [newProjectPath]); - - // Handle onboarding dialog - skip - const handleOnboardingSkip = useCallback(() => { - setShowOnboardingDialog(false); - setNewProjectName(''); - setNewProjectPath(''); - toast.info('You can generate your app_spec.txt anytime from the Spec view', { - description: 'Your project is ready to use!', - }); - }, []); - - /** - * Create a blank project with just .automaker directory structure - */ - const handleCreateBlankProject = useCallback( - async (projectName: string, parentDir: string) => { - setIsCreatingProject(true); - try { - const api = getElectronAPI(); - const projectPath = `${parentDir}/${projectName}`; - - // Create project directory - const mkdirResult = await api.mkdir(projectPath); - if (!mkdirResult.success) { - toast.error('Failed to create project directory', { - description: mkdirResult.error || 'Unknown error occurred', - }); - return; - } - - // Initialize .automaker directory with all necessary files - const initResult = await initializeProject(projectPath); - - if (!initResult.success) { - toast.error('Failed to initialize project', { - description: initResult.error || 'Unknown error occurred', - }); - return; - } - - // Update the app_spec.txt with the project name - // Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts - await api.writeFile( - `${projectPath}/.automaker/app_spec.txt`, - ` - ${projectName} - - - Describe your project here. This file will be analyzed by an AI agent - to understand your project structure and tech stack. - - - - - - - - - - - - - -` - ); - - const trashedProject = trashedProjects.find((p) => p.path === projectPath); - const effectiveTheme = - (trashedProject?.theme as ThemeMode | undefined) || - (currentProject?.theme as ThemeMode | undefined) || - globalTheme; - const project = upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); - - setShowNewProjectModal(false); - - // Show onboarding dialog for new project - setNewProjectName(projectName); - setNewProjectPath(projectPath); - setShowOnboardingDialog(true); - - toast.success('Project created', { - description: `Created ${projectName} with .automaker directory`, - }); - } catch (error) { - console.error('[Sidebar] Failed to create project:', error); - toast.error('Failed to create project', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } finally { - setIsCreatingProject(false); - } - }, - [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] - ); - - /** - * Create a project from a GitHub starter template - */ - const handleCreateFromTemplate = useCallback( - async (template: StarterTemplate, projectName: string, parentDir: string) => { - setIsCreatingProject(true); - try { - const httpClient = getHttpApiClient(); - const api = getElectronAPI(); - - // Clone the template repository - const cloneResult = await httpClient.templates.clone( - template.repoUrl, - projectName, - parentDir - ); - - if (!cloneResult.success || !cloneResult.projectPath) { - toast.error('Failed to clone template', { - description: cloneResult.error || 'Unknown error occurred', - }); - return; - } - - const projectPath = cloneResult.projectPath; - - // Initialize .automaker directory with all necessary files - const initResult = await initializeProject(projectPath); - - if (!initResult.success) { - toast.error('Failed to initialize project', { - description: initResult.error || 'Unknown error occurred', - }); - return; - } - - // Update the app_spec.txt with template-specific info - // Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts - await api.writeFile( - `${projectPath}/.automaker/app_spec.txt`, - ` - ${projectName} - - - This project was created from the "${template.name}" starter template. - ${template.description} - - - - ${template.techStack.map((tech) => `${tech}`).join('\n ')} - - - - ${template.features.map((feature) => `${feature}`).join('\n ')} - - - - - -` - ); - - const trashedProject = trashedProjects.find((p) => p.path === projectPath); - const effectiveTheme = - (trashedProject?.theme as ThemeMode | undefined) || - (currentProject?.theme as ThemeMode | undefined) || - globalTheme; - const project = upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); - - setShowNewProjectModal(false); - - // Show onboarding dialog for new project - setNewProjectName(projectName); - setNewProjectPath(projectPath); - setShowOnboardingDialog(true); - - toast.success('Project created from template', { - description: `Created ${projectName} from ${template.name}`, - }); - } catch (error) { - console.error('[Sidebar] Failed to create project from template:', error); - toast.error('Failed to create project', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } finally { - setIsCreatingProject(false); - } - }, - [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] - ); - - /** - * Create a project from a custom GitHub URL - */ - const handleCreateFromCustomUrl = useCallback( - async (repoUrl: string, projectName: string, parentDir: string) => { - setIsCreatingProject(true); - try { - const httpClient = getHttpApiClient(); - const api = getElectronAPI(); - - // Clone the repository - const cloneResult = await httpClient.templates.clone(repoUrl, projectName, parentDir); - - if (!cloneResult.success || !cloneResult.projectPath) { - toast.error('Failed to clone repository', { - description: cloneResult.error || 'Unknown error occurred', - }); - return; - } - - const projectPath = cloneResult.projectPath; - - // Initialize .automaker directory with all necessary files - const initResult = await initializeProject(projectPath); - - if (!initResult.success) { - toast.error('Failed to initialize project', { - description: initResult.error || 'Unknown error occurred', - }); - return; - } - - // Update the app_spec.txt with basic info - // Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts - await api.writeFile( - `${projectPath}/.automaker/app_spec.txt`, - ` - ${projectName} - - - This project was cloned from ${repoUrl}. - The AI agent will analyze the project structure. - - - - - - - - - - - - - -` - ); - - const trashedProject = trashedProjects.find((p) => p.path === projectPath); - const effectiveTheme = - (trashedProject?.theme as ThemeMode | undefined) || - (currentProject?.theme as ThemeMode | undefined) || - globalTheme; - const project = upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); - - setShowNewProjectModal(false); - - // Show onboarding dialog for new project - setNewProjectName(projectName); - setNewProjectPath(projectPath); - setShowOnboardingDialog(true); - - toast.success('Project created from repository', { - description: `Created ${projectName} from ${repoUrl}`, - }); - } catch (error) { - console.error('[Sidebar] Failed to create project from URL:', error); - toast.error('Failed to create project', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } finally { - setIsCreatingProject(false); - } - }, - [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] - ); - // Handle bug report button click const handleBugReportClick = useCallback(() => { const api = getElectronAPI(); @@ -597,7 +195,7 @@ export function Sidebar() { (trashedProject?.theme as ThemeMode | undefined) || (currentProject?.theme as ThemeMode | undefined) || globalTheme; - const project = upsertAndSetCurrentProject(path, name, effectiveTheme); + upsertAndSetCurrentProject(path, name, effectiveTheme); // Check if app_spec.txt exists const specExists = await hasAppSpec(path); @@ -692,290 +290,12 @@ export function Sidebar() { /> )} - {/* Project Selector with Cycle Buttons */} - {sidebarOpen && projects.length > 0 && ( -
    - - - - - - {/* Search input for type-ahead filtering */} -
    -
    - - setProjectSearchQuery(e.target.value)} - className={cn( - 'w-full h-9 pl-8 pr-3 text-sm rounded-lg', - 'border border-border bg-background/50', - 'text-foreground placeholder:text-muted-foreground', - 'focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/50', - 'transition-all duration-200' - )} - data-testid="project-search-input" - /> -
    -
    - - {filteredProjects.length === 0 ? ( -
    - No projects found -
    - ) : ( - - p.id)} - strategy={verticalListSortingStrategy} - > -
    - {filteredProjects.map((project, index) => ( - { - setCurrentProject(p); - setIsProjectPickerOpen(false); - }} - /> - ))} -
    -
    -
    - )} - - {/* Keyboard hint */} -
    -

    - arrow navigate{' '} - |{' '} - enter select{' '} - |{' '} - esc close -

    -
    -
    -
    - - {/* Project Options Menu - theme and history */} - {currentProject && ( - { - // Clear preview theme when the menu closes - if (!open) { - setPreviewTheme(null); - } - }} - > - - - - - {/* Project Theme Submenu */} - - - - Project Theme - {currentProject.theme && ( - - {currentProject.theme} - - )} - - { - // Clear preview theme when leaving the dropdown - setPreviewTheme(null); - }} - > - {/* Use Global Option */} - { - if (currentProject) { - setPreviewTheme(null); - if (value !== '') { - setTheme(value as any); - } else { - setTheme(globalTheme); - } - setProjectTheme( - currentProject.id, - value === '' ? null : (value as any) - ); - } - }} - > -
    handlePreviewEnter(globalTheme)} - onPointerLeave={() => setPreviewTheme(null)} - > - - - Use Global - - ({globalTheme}) - - -
    - - {/* Two Column Layout */} -
    - {/* Dark Themes Column */} -
    -
    - - Dark -
    -
    - {PROJECT_DARK_THEMES.map((option) => ( - - ))} -
    -
    - {/* Light Themes Column */} -
    -
    - - Light -
    -
    - {PROJECT_LIGHT_THEMES.map((option) => ( - - ))} -
    -
    -
    -
    -
    -
    - - {/* Project History Section - only show when there's history */} - {projectHistory.length > 1 && ( - <> - - - Project History - - - - Previous - - {formatShortcut(shortcuts.cyclePrevProject, true)} - - - - - Next - - {formatShortcut(shortcuts.cycleNextProject, true)} - - - - - Clear history - - - )} - - {/* Move to Trash Section */} - - setShowDeleteProjectDialog(true)} - className="text-destructive focus:text-destructive focus:bg-destructive/10" - data-testid="move-project-to-trash" - > - - Move to Trash - -
    -
    - )} -
    - )} +
    - {/* Bottom Section - Running Agents / Bug Report / Settings */} -
    - {/* Wiki Link */} - {!hideWiki && ( -
    - -
    - )} - {/* Running Agents Link */} - {!hideRunningAgents && ( -
    - -
    - )} - {/* Settings Link */} -
    - -
    -
    - - - - Recycle Bin - - Restore projects to the sidebar or delete their folders using your system Trash. - - - - {trashedProjects.length === 0 ? ( -

    Recycle bin is empty.

    - ) : ( -
    - {trashedProjects.map((project) => ( -
    -
    -

    {project.name}

    -

    {project.path}

    -

    - Trashed {new Date(project.trashedAt).toLocaleString()} -

    -
    -
    - - - -
    -
    - ))} -
    - )} - - - - {trashedProjects.length > 0 && ( - - )} - -
    -
    + + {/* New Project Setup Dialog */} - {/* New Project Onboarding Dialog */} - { - if (!open) { - handleOnboardingSkip(); - } - }} - > - - -
    -
    - -
    -
    - Welcome to {newProjectName}! - - Your new project is ready. Let's get you started. - -
    -
    -
    - -
    - {/* Main explanation */} -
    -

    - Would you like to auto-generate your app_spec.txt? This file helps - describe your project and is used to pre-populate your backlog with features to work - on. -

    -
    - - {/* Benefits list */} -
    -
    - -
    -

    Pre-populate your backlog

    -

    - Automatically generate features based on your project specification -

    -
    -
    -
    - -
    -

    Better AI assistance

    -

    - Help AI agents understand your project structure and tech stack -

    -
    -
    -
    - -
    -

    Project documentation

    -

    - Keep a clear record of your project's capabilities and features -

    -
    -
    -
    - - {/* Info box */} -
    -

    - Tip: You can always generate or edit - your app_spec.txt later from the Spec Editor in the sidebar. -

    -
    -
    - - - - - -
    -
    + onOpenChange={setShowOnboardingDialog} + newProjectName={newProjectName} + onSkip={handleOnboardingSkip} + onGenerateSpec={handleOnboardingGenerateSpec} + /> {/* Delete Project Confirmation Dialog */} void; + setShowDeleteProjectDialog: (show: boolean) => void; +} + +export function ProjectSelectorWithOptions({ + sidebarOpen, + isProjectPickerOpen, + setIsProjectPickerOpen, + setShowDeleteProjectDialog, +}: ProjectSelectorWithOptionsProps) { + // Get data from store + const { + projects, + currentProject, + projectHistory, + setCurrentProject, + reorderProjects, + cyclePrevProject, + cycleNextProject, + clearProjectHistory, + } = useAppStore(); + + // Get keyboard shortcuts + const shortcuts = useKeyboardShortcutsConfig(); + const { + projectSearchQuery, + setProjectSearchQuery, + selectedProjectIndex, + projectSearchInputRef, + filteredProjects, + } = useProjectPicker({ + projects, + isProjectPickerOpen, + setIsProjectPickerOpen, + setCurrentProject, + }); + + // Drag-and-drop handlers + const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects }); + + // Theme management + const { + globalTheme, + setTheme, + setProjectTheme, + setPreviewTheme, + handlePreviewEnter, + handlePreviewLeave, + } = useProjectTheme(); + + if (!sidebarOpen || projects.length === 0) { + return null; + } + + return ( +
    + + + + + + {/* Search input for type-ahead filtering */} +
    +
    + + setProjectSearchQuery(e.target.value)} + className={cn( + 'w-full h-9 pl-8 pr-3 text-sm rounded-lg', + 'border border-border bg-background/50', + 'text-foreground placeholder:text-muted-foreground', + 'focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/50', + 'transition-all duration-200' + )} + data-testid="project-search-input" + /> +
    +
    + + {filteredProjects.length === 0 ? ( +
    + No projects found +
    + ) : ( + + p.id)} + strategy={verticalListSortingStrategy} + > +
    + {filteredProjects.map((project, index) => ( + { + setCurrentProject(p); + setIsProjectPickerOpen(false); + }} + /> + ))} +
    +
    +
    + )} + + {/* Keyboard hint */} +
    +

    + arrow navigate{' '} + |{' '} + enter select{' '} + |{' '} + esc close +

    +
    +
    +
    + + {/* Project Options Menu - theme and history */} + {currentProject && ( + { + // Clear preview theme when the menu closes + if (!open) { + setPreviewTheme(null); + } + }} + > + + + + + {/* Project Theme Submenu */} + + + + Project Theme + {currentProject.theme && ( + + {currentProject.theme} + + )} + + { + // Clear preview theme when leaving the dropdown + setPreviewTheme(null); + }} + > + {/* Use Global Option */} + { + if (currentProject) { + setPreviewTheme(null); + if (value !== '') { + setTheme(value as ThemeMode); + } else { + setTheme(globalTheme); + } + setProjectTheme( + currentProject.id, + value === '' ? null : (value as ThemeMode) + ); + } + }} + > +
    handlePreviewEnter(globalTheme)} + onPointerLeave={() => setPreviewTheme(null)} + > + + + Use Global + + ({globalTheme}) + + +
    + + {/* Two Column Layout */} +
    + {/* Dark Themes Column */} +
    +
    + + Dark +
    +
    + {PROJECT_DARK_THEMES.map((option) => ( + + ))} +
    +
    + {/* Light Themes Column */} +
    +
    + + Light +
    +
    + {PROJECT_LIGHT_THEMES.map((option) => ( + + ))} +
    +
    +
    +
    +
    +
    + + {/* Project History Section - only show when there's history */} + {projectHistory.length > 1 && ( + <> + + + Project History + + + + Previous + + {formatShortcut(shortcuts.cyclePrevProject, true)} + + + + + Next + + {formatShortcut(shortcuts.cycleNextProject, true)} + + + + + Clear history + + + )} + + {/* Move to Trash Section */} + + setShowDeleteProjectDialog(true)} + className="text-destructive focus:text-destructive focus:bg-destructive/10" + data-testid="move-project-to-trash" + > + + Move to Trash + +
    +
    + )} +
    + ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx new file mode 100644 index 00000000..664797b6 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx @@ -0,0 +1,269 @@ +import type { NavigateOptions } from '@tanstack/react-router'; +import { cn } from '@/lib/utils'; +import { formatShortcut } from '@/store/app-store'; +import { BookOpen, Activity, Settings } from 'lucide-react'; + +interface SidebarFooterProps { + sidebarOpen: boolean; + isActiveRoute: (id: string) => boolean; + navigate: (opts: NavigateOptions) => void; + hideWiki: boolean; + hideRunningAgents: boolean; + runningAgentsCount: number; + shortcuts: { + settings: string; + }; +} + +export function SidebarFooter({ + sidebarOpen, + isActiveRoute, + navigate, + hideWiki, + hideRunningAgents, + runningAgentsCount, + shortcuts, +}: SidebarFooterProps) { + return ( +
    + {/* Wiki Link */} + {!hideWiki && ( +
    + +
    + )} + {/* Running Agents Link */} + {!hideRunningAgents && ( +
    + +
    + )} + {/* Settings Link */} +
    + +
    +
    + ); +} diff --git a/apps/ui/src/components/layout/sidebar/dialogs/index.ts b/apps/ui/src/components/layout/sidebar/dialogs/index.ts new file mode 100644 index 00000000..9b9235df --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/dialogs/index.ts @@ -0,0 +1,2 @@ +export { TrashDialog } from './trash-dialog'; +export { OnboardingDialog } from './onboarding-dialog'; diff --git a/apps/ui/src/components/layout/sidebar/dialogs/onboarding-dialog.tsx b/apps/ui/src/components/layout/sidebar/dialogs/onboarding-dialog.tsx new file mode 100644 index 00000000..4a9e3558 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/dialogs/onboarding-dialog.tsx @@ -0,0 +1,122 @@ +import { Rocket, CheckCircle2, Zap, FileText, Sparkles, ArrowRight } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +interface OnboardingDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + newProjectName: string; + onSkip: () => void; + onGenerateSpec: () => void; +} + +export function OnboardingDialog({ + open, + onOpenChange, + newProjectName, + onSkip, + onGenerateSpec, +}: OnboardingDialogProps) { + return ( + { + if (!isOpen) { + onSkip(); + } + onOpenChange(isOpen); + }} + > + + +
    +
    + +
    +
    + Welcome to {newProjectName}! + + Your new project is ready. Let's get you started. + +
    +
    +
    + +
    + {/* Main explanation */} +
    +

    + Would you like to auto-generate your app_spec.txt? This file helps + describe your project and is used to pre-populate your backlog with features to work + on. +

    +
    + + {/* Benefits list */} +
    +
    + +
    +

    Pre-populate your backlog

    +

    + Automatically generate features based on your project specification +

    +
    +
    +
    + +
    +

    Better AI assistance

    +

    + Help AI agents understand your project structure and tech stack +

    +
    +
    +
    + +
    +

    Project documentation

    +

    + Keep a clear record of your project's capabilities and features +

    +
    +
    +
    + + {/* Info box */} +
    +

    + Tip: You can always generate or edit your + app_spec.txt later from the Spec Editor in the sidebar. +

    +
    +
    + + + + + +
    +
    + ); +} diff --git a/apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx b/apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx new file mode 100644 index 00000000..bb231436 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx @@ -0,0 +1,116 @@ +import { X, Trash2, Undo2 } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import type { TrashedProject } from '@/lib/electron'; + +interface TrashDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + trashedProjects: TrashedProject[]; + activeTrashId: string | null; + handleRestoreProject: (id: string) => void; + handleDeleteProjectFromDisk: (project: TrashedProject) => void; + deleteTrashedProject: (id: string) => void; + handleEmptyTrash: () => void; + isEmptyingTrash: boolean; +} + +export function TrashDialog({ + open, + onOpenChange, + trashedProjects, + activeTrashId, + handleRestoreProject, + handleDeleteProjectFromDisk, + deleteTrashedProject, + handleEmptyTrash, + isEmptyingTrash, +}: TrashDialogProps) { + return ( + + + + Recycle Bin + + Restore projects to the sidebar or delete their folders using your system Trash. + + + + {trashedProjects.length === 0 ? ( +

    Recycle bin is empty.

    + ) : ( +
    + {trashedProjects.map((project) => ( +
    +
    +

    {project.name}

    +

    {project.path}

    +

    + Trashed {new Date(project.trashedAt).toLocaleString()} +

    +
    +
    + + + +
    +
    + ))} +
    + )} + + + + {trashedProjects.length > 0 && ( + + )} + +
    +
    + ); +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/index.ts b/apps/ui/src/components/layout/sidebar/hooks/index.ts index c5cca3b8..7a047f8a 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/index.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/index.ts @@ -6,3 +6,7 @@ export { useTrashOperations } from './use-trash-operations'; export { useProjectPicker } from './use-project-picker'; export { useSpecRegeneration } from './use-spec-regeneration'; export { useNavigation } from './use-navigation'; +export { useProjectCreation } from './use-project-creation'; +export { useSetupDialog } from './use-setup-dialog'; +export { useTrashDialog } from './use-trash-dialog'; +export { useProjectTheme } from './use-project-theme'; diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts new file mode 100644 index 00000000..3d75fabb --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts @@ -0,0 +1,175 @@ +import { useState, useCallback } from 'react'; +import { getElectronAPI } from '@/lib/electron'; +import { initializeProject } from '@/lib/project-init'; +import { toast } from 'sonner'; +import type { StarterTemplate } from '@/lib/templates'; +import type { ThemeMode } from '@/store/app-store'; +import type { TrashedProject, Project } from '@/lib/electron'; + +interface UseProjectCreationProps { + trashedProjects: TrashedProject[]; + currentProject: Project | null; + globalTheme: ThemeMode; + upsertAndSetCurrentProject: (path: string, name: string, theme: ThemeMode) => Project; +} + +export function useProjectCreation({ + trashedProjects, + currentProject, + globalTheme, + upsertAndSetCurrentProject, +}: UseProjectCreationProps) { + // Modal state + const [showNewProjectModal, setShowNewProjectModal] = useState(false); + const [isCreatingProject, setIsCreatingProject] = useState(false); + + // Onboarding state + const [showOnboardingDialog, setShowOnboardingDialog] = useState(false); + const [newProjectName, setNewProjectName] = useState(''); + const [newProjectPath, setNewProjectPath] = useState(''); + + /** + * Common logic for all project creation flows + */ + const finalizeProjectCreation = useCallback( + async (projectPath: string, projectName: string) => { + try { + // Initialize .automaker directory structure + await initializeProject(projectPath); + + // Write initial app_spec.txt with basic XML structure + const api = getElectronAPI(); + await api.fs.writeFile( + `${projectPath}/app_spec.txt`, + `\n\n ${projectName}\n Add your project description here\n` + ); + + // Determine theme: try trashed project theme, then current project theme, then global + const trashedProject = trashedProjects.find((p) => p.path === projectPath); + const effectiveTheme = + (trashedProject?.theme as ThemeMode | undefined) || + (currentProject?.theme as ThemeMode | undefined) || + globalTheme; + + upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); + + setShowNewProjectModal(false); + + // Show onboarding dialog for new project + setNewProjectName(projectName); + setNewProjectPath(projectPath); + setShowOnboardingDialog(true); + + toast.success('Project created successfully'); + } catch (error) { + console.error('[ProjectCreation] Failed to finalize project:', error); + toast.error('Failed to initialize project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + throw error; + } + }, + [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] + ); + + /** + * Create a blank project with .automaker structure + */ + const handleCreateBlankProject = useCallback( + async (projectName: string, parentDir: string) => { + setIsCreatingProject(true); + try { + const api = getElectronAPI(); + const projectPath = `${parentDir}/${projectName}`; + + // Create project directory + await api.fs.createFolder(projectPath); + + // Finalize project setup + await finalizeProjectCreation(projectPath, projectName); + } catch (error) { + console.error('[ProjectCreation] Failed to create blank project:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCreatingProject(false); + } + }, + [finalizeProjectCreation] + ); + + /** + * Create project from a starter template + */ + const handleCreateFromTemplate = useCallback( + async (template: StarterTemplate, projectName: string, parentDir: string) => { + setIsCreatingProject(true); + try { + const api = getElectronAPI(); + const projectPath = `${parentDir}/${projectName}`; + + // Clone template repository + await api.git.clone(template.githubUrl, projectPath); + + // Finalize project setup + await finalizeProjectCreation(projectPath, projectName); + } catch (error) { + console.error('[ProjectCreation] Failed to create from template:', error); + toast.error('Failed to create project from template', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCreatingProject(false); + } + }, + [finalizeProjectCreation] + ); + + /** + * Create project from a custom GitHub URL + */ + const handleCreateFromCustomUrl = useCallback( + async (repoUrl: string, projectName: string, parentDir: string) => { + setIsCreatingProject(true); + try { + const api = getElectronAPI(); + const projectPath = `${parentDir}/${projectName}`; + + // Clone custom repository + await api.git.clone(repoUrl, projectPath); + + // Finalize project setup + await finalizeProjectCreation(projectPath, projectName); + } catch (error) { + console.error('[ProjectCreation] Failed to create from custom URL:', error); + toast.error('Failed to create project from URL', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCreatingProject(false); + } + }, + [finalizeProjectCreation] + ); + + return { + // Modal state + showNewProjectModal, + setShowNewProjectModal, + isCreatingProject, + + // Onboarding state + showOnboardingDialog, + setShowOnboardingDialog, + newProjectName, + setNewProjectName, + newProjectPath, + setNewProjectPath, + + // Handlers + handleCreateBlankProject, + handleCreateFromTemplate, + handleCreateFromCustomUrl, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-project-theme.ts b/apps/ui/src/components/layout/sidebar/hooks/use-project-theme.ts new file mode 100644 index 00000000..b80e605d --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-project-theme.ts @@ -0,0 +1,25 @@ +import { useAppStore } from '@/store/app-store'; +import { useThemePreview } from './use-theme-preview'; + +/** + * Hook that manages project theme state and preview handlers + */ +export function useProjectTheme() { + // Get theme-related values from store + const { theme: globalTheme, setTheme, setProjectTheme, setPreviewTheme } = useAppStore(); + + // Get debounced preview handlers + const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); + + return { + // Theme state + globalTheme, + setTheme, + setProjectTheme, + setPreviewTheme, + + // Preview handlers + handlePreviewEnter, + handlePreviewLeave, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts b/apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts new file mode 100644 index 00000000..8a94fd18 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts @@ -0,0 +1,147 @@ +import { useState, useCallback } from 'react'; +import { getElectronAPI } from '@/lib/electron'; +import { toast } from 'sonner'; +import type { FeatureCount } from '@/components/views/spec-view/types'; + +interface UseSetupDialogProps { + setSpecCreatingForProject: (path: string | null) => void; + newProjectPath: string; + setNewProjectName: (name: string) => void; + setNewProjectPath: (path: string) => void; + setShowOnboardingDialog: (show: boolean) => void; +} + +export function useSetupDialog({ + setSpecCreatingForProject, + newProjectPath, + setNewProjectName, + setNewProjectPath, + setShowOnboardingDialog, +}: UseSetupDialogProps) { + // Setup dialog state + const [showSetupDialog, setShowSetupDialog] = useState(false); + const [setupProjectPath, setSetupProjectPath] = useState(''); + const [projectOverview, setProjectOverview] = useState(''); + const [generateFeatures, setGenerateFeatures] = useState(true); + const [analyzeProject, setAnalyzeProject] = useState(true); + const [featureCount, setFeatureCount] = useState(50); + + /** + * Handle creating initial spec for new project + */ + const handleCreateInitialSpec = useCallback(async () => { + if (!setupProjectPath || !projectOverview.trim()) return; + + // Set store state immediately so the loader shows up right away + setSpecCreatingForProject(setupProjectPath); + setShowSetupDialog(false); + + try { + const api = getElectronAPI(); + if (!api.specRegeneration) { + toast.error('Spec regeneration not available'); + setSpecCreatingForProject(null); + return; + } + + const result = await api.specRegeneration.create( + setupProjectPath, + projectOverview.trim(), + generateFeatures, + analyzeProject, + generateFeatures ? featureCount : undefined // only pass maxFeatures if generating features + ); + + if (!result.success) { + console.error('[SetupDialog] Failed to start spec creation:', result.error); + setSpecCreatingForProject(null); + toast.error('Failed to create specification', { + description: result.error, + }); + } else { + // Show processing toast to inform user + toast.info('Generating app specification...', { + description: "This may take a minute. You'll be notified when complete.", + }); + } + // If successful, we'll wait for the events to update the state + } catch (error) { + console.error('[SetupDialog] Failed to create spec:', error); + setSpecCreatingForProject(null); + toast.error('Failed to create specification', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, [ + setupProjectPath, + projectOverview, + generateFeatures, + analyzeProject, + featureCount, + setSpecCreatingForProject, + ]); + + /** + * Handle skipping setup + */ + const handleSkipSetup = useCallback(() => { + setShowSetupDialog(false); + setProjectOverview(''); + setSetupProjectPath(''); + + // Clear onboarding state if we came from onboarding + if (newProjectPath) { + setNewProjectName(''); + setNewProjectPath(''); + } + + toast.info('Setup skipped', { + description: 'You can set up your app_spec.txt later from the Spec view.', + }); + }, [newProjectPath, setNewProjectName, setNewProjectPath]); + + /** + * Handle onboarding dialog - generate spec + */ + const handleOnboardingGenerateSpec = useCallback(() => { + setShowOnboardingDialog(false); + // Navigate to the setup dialog flow + setSetupProjectPath(newProjectPath); + setProjectOverview(''); + setShowSetupDialog(true); + }, [newProjectPath, setShowOnboardingDialog]); + + /** + * Handle onboarding dialog - skip + */ + const handleOnboardingSkip = useCallback(() => { + setShowOnboardingDialog(false); + setNewProjectName(''); + setNewProjectPath(''); + toast.info('You can generate your app_spec.txt anytime from the Spec view', { + description: 'Your project is ready to use!', + }); + }, [setShowOnboardingDialog, setNewProjectName, setNewProjectPath]); + + return { + // State + showSetupDialog, + setShowSetupDialog, + setupProjectPath, + setSetupProjectPath, + projectOverview, + setProjectOverview, + generateFeatures, + setGenerateFeatures, + analyzeProject, + setAnalyzeProject, + featureCount, + setFeatureCount, + + // Handlers + handleCreateInitialSpec, + handleSkipSetup, + handleOnboardingGenerateSpec, + handleOnboardingSkip, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-trash-dialog.ts b/apps/ui/src/components/layout/sidebar/hooks/use-trash-dialog.ts new file mode 100644 index 00000000..74c1ee9b --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-trash-dialog.ts @@ -0,0 +1,40 @@ +import { useState } from 'react'; +import { useTrashOperations } from './use-trash-operations'; +import type { TrashedProject } from '@/lib/electron'; + +interface UseTrashDialogProps { + restoreTrashedProject: (projectId: string) => void; + deleteTrashedProject: (projectId: string) => void; + emptyTrash: () => void; + trashedProjects: TrashedProject[]; +} + +/** + * Hook that combines trash operations with dialog state management + */ +export function useTrashDialog({ + restoreTrashedProject, + deleteTrashedProject, + emptyTrash, + trashedProjects, +}: UseTrashDialogProps) { + // Dialog state + const [showTrashDialog, setShowTrashDialog] = useState(false); + + // Reuse existing trash operations logic + const trashOperations = useTrashOperations({ + restoreTrashedProject, + deleteTrashedProject, + emptyTrash, + trashedProjects, + }); + + return { + // Dialog state + showTrashDialog, + setShowTrashDialog, + + // Trash operations (spread from existing hook) + ...trashOperations, + }; +} From 7ddd9f8be103c7fa7f51462a0033549a0d25db3f Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sun, 21 Dec 2025 15:33:43 -0500 Subject: [PATCH 30/59] feat: enhance terminal navigation and session management - Implemented spatial navigation between terminal panes using directional shortcuts (Ctrl+Alt+Arrow keys). - Improved session handling by ensuring stale sessions are automatically removed when the server indicates they are invalid. - Added customizable keyboard shortcuts for terminal actions and enhanced search functionality with dedicated highlighting colors. - Updated terminal themes to include search highlighting colors for better visibility during searches. - Refactored terminal layout saving logic to prevent incomplete state saves during project restoration. --- .../ui/src/components/views/terminal-view.tsx | 163 ++++++++++++--- .../views/terminal-view/terminal-panel.tsx | 197 +++++++++++++++--- apps/ui/src/config/terminal-themes.ts | 85 ++++++++ apps/ui/src/hooks/use-keyboard-shortcuts.ts | 96 +++++++++ apps/ui/src/store/app-store.ts | 33 +++ docs/terminal.md | 26 ++- 6 files changed, 536 insertions(+), 64 deletions(-) diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 5f2b1a42..36e0054d 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -447,7 +447,8 @@ export function TerminalView() { // The path check in restoreLayout will handle this // Save layout for previous project (if there was one and has terminals) - if (prevPath && terminalState.tabs.length > 0) { + // BUT don't save if we were mid-restore for that project (would save incomplete state) + if (prevPath && terminalState.tabs.length > 0 && restoringProjectPathRef.current !== prevPath) { saveTerminalLayout(prevPath); } @@ -460,19 +461,25 @@ export function TerminalView() { return; } + // ALWAYS clear existing terminals when switching projects + // This is critical - prevents old project's terminals from "bleeding" into new project + clearTerminalState(); + // Check for saved layout for this project const savedLayout = getPersistedTerminalLayout(currentPath); - if (savedLayout && savedLayout.tabs.length > 0) { - // Restore the saved layout - try to reconnect to existing sessions - // Track which project we're restoring to detect stale restores - restoringProjectPathRef.current = currentPath; + // If no saved layout or no tabs, we're done - terminal starts fresh for this project + if (!savedLayout || savedLayout.tabs.length === 0) { + console.log("[Terminal] No saved layout for project, starting fresh"); + return; + } - // Clear existing terminals first (only client state, sessions stay on server) - clearTerminalState(); + // Restore the saved layout - try to reconnect to existing sessions + // Track which project we're restoring to detect stale restores + restoringProjectPathRef.current = currentPath; - // Create terminals and build layout - try to reconnect or create new - const restoreLayout = async () => { + // Create terminals and build layout - try to reconnect or create new + const restoreLayout = async () => { // Check if we're still restoring the same project (user may have switched) if (restoringProjectPathRef.current !== currentPath) { console.log("[Terminal] Restore cancelled - project changed"); @@ -643,21 +650,29 @@ export function TerminalView() { }; restoreLayout(); - } }, [currentProject?.path, saveTerminalLayout, getPersistedTerminalLayout, clearTerminalState, addTerminalTab, serverUrl]); // Save terminal layout whenever it changes (debounced to prevent excessive writes) // Also save when tabs become empty so closed terminals stay closed on refresh const saveLayoutTimeoutRef = useRef(null); + const pendingSavePathRef = useRef(null); useEffect(() => { + const projectPath = currentProject?.path; // Don't save while restoring this project's layout - if (currentProject?.path && restoringProjectPathRef.current !== currentProject.path) { + if (projectPath && restoringProjectPathRef.current !== projectPath) { // Debounce saves to prevent excessive localStorage writes during rapid changes if (saveLayoutTimeoutRef.current) { clearTimeout(saveLayoutTimeoutRef.current); } + // Capture the project path at schedule time so we save to the correct project + // even if user switches projects before the timeout fires + pendingSavePathRef.current = projectPath; saveLayoutTimeoutRef.current = setTimeout(() => { - saveTerminalLayout(currentProject.path); + // Only save if we're still on the same project + if (pendingSavePathRef.current === projectPath) { + saveTerminalLayout(projectPath); + } + pendingSavePathRef.current = null; saveLayoutTimeoutRef.current = null; }, 500); // 500ms debounce } @@ -949,28 +964,93 @@ export function TerminalView() { }); }, []); - // Navigate between terminal panes with Ctrl+Alt+Arrow keys - const navigateToTerminal = useCallback((direction: "next" | "prev") => { + // Navigate between terminal panes with directional awareness + // Arrow keys navigate in the actual spatial direction within the layout + const navigateToTerminal = useCallback((direction: "up" | "down" | "left" | "right") => { if (!activeTab?.layout) return; - const terminalIds = getTerminalIds(activeTab.layout); - if (terminalIds.length <= 1) return; - - const currentIndex = terminalIds.indexOf(terminalState.activeSessionId || ""); - if (currentIndex === -1) { + const currentSessionId = terminalState.activeSessionId; + if (!currentSessionId) { // If no terminal is active, focus the first one - setActiveTerminalSession(terminalIds[0]); + const terminalIds = getTerminalIds(activeTab.layout); + if (terminalIds.length > 0) { + setActiveTerminalSession(terminalIds[0]); + } return; } - let newIndex: number; - if (direction === "next") { - newIndex = (currentIndex + 1) % terminalIds.length; - } else { - newIndex = (currentIndex - 1 + terminalIds.length) % terminalIds.length; - } + // Find the terminal in the given direction + // The algorithm traverses the layout tree to find spatially adjacent terminals + const findTerminalInDirection = ( + layout: TerminalPanelContent, + targetId: string, + dir: "up" | "down" | "left" | "right" + ): string | null => { + // Helper to get all terminal IDs from a layout subtree + const getAllTerminals = (node: TerminalPanelContent): string[] => { + if (node.type === "terminal") return [node.sessionId]; + return node.panels.flatMap(getAllTerminals); + }; - setActiveTerminalSession(terminalIds[newIndex]); + // Helper to find terminal and its path in the tree + type PathEntry = { node: TerminalPanelContent; index: number; direction: "horizontal" | "vertical" }; + const findPath = ( + node: TerminalPanelContent, + target: string, + path: PathEntry[] = [] + ): PathEntry[] | null => { + if (node.type === "terminal") { + return node.sessionId === target ? path : null; + } + for (let i = 0; i < node.panels.length; i++) { + const result = findPath(node.panels[i], target, [ + ...path, + { node, index: i, direction: node.direction }, + ]); + if (result) return result; + } + return null; + }; + + const path = findPath(layout, targetId); + if (!path || path.length === 0) return null; + + // Determine which split direction we need based on arrow direction + // left/right navigation works in "horizontal" splits (panels side by side) + // up/down navigation works in "vertical" splits (panels stacked) + const neededDirection = dir === "left" || dir === "right" ? "horizontal" : "vertical"; + const goingForward = dir === "right" || dir === "down"; + + // Walk up the path to find a split in the right direction with an adjacent panel + for (let i = path.length - 1; i >= 0; i--) { + const entry = path[i]; + if (entry.direction === neededDirection) { + const siblings = entry.node.type === "split" ? entry.node.panels : []; + const nextIndex = goingForward ? entry.index + 1 : entry.index - 1; + + if (nextIndex >= 0 && nextIndex < siblings.length) { + // Found an adjacent panel in the right direction + const adjacentPanel = siblings[nextIndex]; + const adjacentTerminals = getAllTerminals(adjacentPanel); + + if (adjacentTerminals.length > 0) { + // When moving forward (right/down), pick the first terminal in that subtree + // When moving backward (left/up), pick the last terminal in that subtree + return goingForward + ? adjacentTerminals[0] + : adjacentTerminals[adjacentTerminals.length - 1]; + } + } + } + } + + return null; + }; + + const nextTerminal = findTerminalInDirection(activeTab.layout, currentSessionId, direction); + if (nextTerminal) { + setActiveTerminalSession(nextTerminal); + } }, [activeTab?.layout, terminalState.activeSessionId, setActiveTerminalSession]); // Handle global keyboard shortcuts for pane navigation @@ -978,12 +1058,18 @@ export function TerminalView() { const handleKeyDown = (e: KeyboardEvent) => { // Ctrl+Alt+Arrow (or Cmd+Alt+Arrow on Mac) for pane navigation if ((e.ctrlKey || e.metaKey) && e.altKey && !e.shiftKey) { - if (e.key === "ArrowRight" || e.key === "ArrowDown") { + if (e.key === "ArrowRight") { e.preventDefault(); - navigateToTerminal("next"); - } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + navigateToTerminal("right"); + } else if (e.key === "ArrowLeft") { e.preventDefault(); - navigateToTerminal("prev"); + navigateToTerminal("left"); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + navigateToTerminal("down"); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + navigateToTerminal("up"); } } }; @@ -1019,6 +1105,16 @@ export function TerminalView() { onSplitHorizontal={() => createTerminal("horizontal", content.sessionId)} onSplitVertical={() => createTerminal("vertical", content.sessionId)} onNewTab={createTerminalInNewTab} + onNavigateUp={() => navigateToTerminal("up")} + onNavigateDown={() => navigateToTerminal("down")} + onNavigateLeft={() => navigateToTerminal("left")} + onNavigateRight={() => navigateToTerminal("right")} + onSessionInvalid={() => { + // Auto-remove stale session when server says it doesn't exist + // This handles cases like server restart where sessions are lost + console.log(`[Terminal] Session ${content.sessionId} is invalid, removing from layout`); + killTerminal(content.sessionId); + }} isDragging={activeDragId === content.sessionId} isDropTarget={activeDragId !== null && activeDragId !== content.sessionId} fontSize={terminalFontSize} @@ -1384,6 +1480,11 @@ export function TerminalView() { onSplitHorizontal={() => createTerminal("horizontal", terminalState.maximizedSessionId!)} onSplitVertical={() => createTerminal("vertical", terminalState.maximizedSessionId!)} onNewTab={createTerminalInNewTab} + onSessionInvalid={() => { + const sessionId = terminalState.maximizedSessionId!; + console.log(`[Terminal] Maximized session ${sessionId} is invalid, removing from layout`); + killTerminal(sessionId); + }} isDragging={false} isDropTarget={false} fontSize={findTerminalFontSize(terminalState.maximizedSessionId)} diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index 4a10cc11..95520ef7 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -30,8 +30,9 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { useDraggable, useDroppable } from "@dnd-kit/core"; -import { useAppStore } from "@/store/app-store"; +import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, type KeyboardShortcuts } from "@/store/app-store"; import { useShallow } from "zustand/react/shallow"; +import { matchesShortcutWithCode } from "@/hooks/use-keyboard-shortcuts"; import { getTerminalTheme, TERMINAL_FONT_OPTIONS, DEFAULT_TERMINAL_FONT } from "@/config/terminal-themes"; import { toast } from "sonner"; import { getElectronAPI } from "@/lib/electron"; @@ -62,6 +63,11 @@ interface TerminalPanelProps { onSplitHorizontal: () => void; onSplitVertical: () => void; onNewTab?: () => void; + onNavigateUp?: () => void; // Navigate to terminal pane above + onNavigateDown?: () => void; // Navigate to terminal pane below + onNavigateLeft?: () => void; // Navigate to terminal pane on the left + onNavigateRight?: () => void; // Navigate to terminal pane on the right + onSessionInvalid?: () => void; // Called when session is no longer valid on server (e.g., server restarted) isDragging?: boolean; isDropTarget?: boolean; fontSize: number; @@ -87,6 +93,11 @@ export function TerminalPanel({ onSplitHorizontal, onSplitVertical, onNewTab, + onNavigateUp, + onNavigateDown, + onNavigateLeft, + onNavigateRight, + onSessionInvalid, isDragging = false, isDropTarget = false, fontSize, @@ -177,6 +188,15 @@ export function TerminalPanel({ const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme); const effectiveTheme = getEffectiveTheme(); + // Get keyboard shortcuts from store - merged with defaults + const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts); + const mergedShortcuts: KeyboardShortcuts = { + ...DEFAULT_KEYBOARD_SHORTCUTS, + ...keyboardShortcuts, + }; + const shortcutsRef = useRef(mergedShortcuts); + shortcutsRef.current = mergedShortcuts; + // Track system dark mode preference for "system" theme const [systemIsDark, setSystemIsDark] = useState(() => { if (typeof window !== "undefined") { @@ -212,6 +232,16 @@ export function TerminalPanel({ onSplitVerticalRef.current = onSplitVertical; const onNewTabRef = useRef(onNewTab); onNewTabRef.current = onNewTab; + const onNavigateUpRef = useRef(onNavigateUp); + onNavigateUpRef.current = onNavigateUp; + const onNavigateDownRef = useRef(onNavigateDown); + onNavigateDownRef.current = onNavigateDown; + const onNavigateLeftRef = useRef(onNavigateLeft); + onNavigateLeftRef.current = onNavigateLeft; + const onNavigateRightRef = useRef(onNavigateRight); + onNavigateRightRef.current = onNavigateRight; + const onSessionInvalidRef = useRef(onSessionInvalid); + onSessionInvalidRef.current = onSessionInvalid; const fontSizeRef = useRef(fontSize); fontSizeRef.current = fontSize; const themeRef = useRef(resolvedTheme); @@ -348,18 +378,33 @@ export function TerminalPanel({ xtermRef.current?.clear(); }, []); + // Get theme colors for search highlighting + const terminalTheme = getTerminalTheme(effectiveTheme); + const searchOptions = { + caseSensitive: false, + regex: false, + decorations: { + matchBackground: terminalTheme.searchMatchBackground, + matchBorder: terminalTheme.searchMatchBorder, + matchOverviewRuler: terminalTheme.searchMatchBorder, + activeMatchBackground: terminalTheme.searchActiveMatchBackground, + activeMatchBorder: terminalTheme.searchActiveMatchBorder, + activeMatchColorOverviewRuler: terminalTheme.searchActiveMatchBorder, + }, + }; + // Search functions const searchNext = useCallback(() => { if (searchAddonRef.current && searchQuery) { - searchAddonRef.current.findNext(searchQuery, { caseSensitive: false, regex: false }); + searchAddonRef.current.findNext(searchQuery, searchOptions); } - }, [searchQuery]); + }, [searchQuery, searchOptions]); const searchPrevious = useCallback(() => { if (searchAddonRef.current && searchQuery) { - searchAddonRef.current.findPrevious(searchQuery, { caseSensitive: false, regex: false }); + searchAddonRef.current.findPrevious(searchQuery, searchOptions); } - }, [searchQuery]); + }, [searchQuery, searchOptions]); const closeSearch = useCallback(() => { setShowSearch(false); @@ -369,6 +414,32 @@ export function TerminalPanel({ xtermRef.current?.focus(); }, []); + // Handle pane navigation keyboard shortcuts at container level (capture phase) + // This ensures we intercept before xterm can process the event + const handleContainerKeyDownCapture = useCallback((event: React.KeyboardEvent) => { + // Ctrl+Alt+Arrow / Cmd+Alt+Arrow - Navigate between panes directionally + if ((event.ctrlKey || event.metaKey) && event.altKey && !event.shiftKey) { + const code = event.nativeEvent.code; + if (code === 'ArrowRight') { + event.preventDefault(); + event.stopPropagation(); + onNavigateRight?.(); + } else if (code === 'ArrowLeft') { + event.preventDefault(); + event.stopPropagation(); + onNavigateLeft?.(); + } else if (code === 'ArrowDown') { + event.preventDefault(); + event.stopPropagation(); + onNavigateDown?.(); + } else if (code === 'ArrowUp') { + event.preventDefault(); + event.stopPropagation(); + onNavigateUp?.(); + } + } + }, [onNavigateUp, onNavigateDown, onNavigateLeft, onNavigateRight]); + // Scroll to bottom of terminal const scrollToBottom = useCallback(() => { if (xtermRef.current) { @@ -482,8 +553,21 @@ export function TerminalPanel({ terminal.loadAddon(searchAddon); searchAddonRef.current = searchAddon; - // Create web links addon for clickable URLs - const webLinksAddon = new WebLinksAddon(); + // Create web links addon for clickable URLs with custom handler for Electron + const webLinksAddon = new WebLinksAddon((_event: MouseEvent, uri: string) => { + // Use Electron API to open external links in system browser + const api = getElectronAPI(); + if (api?.openExternalLink) { + api.openExternalLink(uri).catch((error) => { + console.error("[Terminal] Failed to open URL:", error); + // Fallback to window.open if Electron API fails + window.open(uri, "_blank", "noopener,noreferrer"); + }); + } else { + // Web fallback + window.open(uri, "_blank", "noopener,noreferrer"); + } + }); terminal.loadAddon(webLinksAddon); // Open terminal @@ -661,15 +745,45 @@ export function TerminalPanel({ // Only intercept keydown events if (event.type !== 'keydown') return true; + // Use event.code for keyboard-layout-independent key detection + const code = event.code; + + // Ctrl+Alt+Arrow / Cmd+Alt+Arrow - Navigate between panes directionally + // Handle this FIRST before any other checks to prevent xterm from capturing it + // Use explicit check for both Ctrl and Meta to work on all platforms + if ((event.ctrlKey || event.metaKey) && event.altKey && !event.shiftKey) { + if (code === 'ArrowRight') { + event.preventDefault(); + event.stopPropagation(); + onNavigateRightRef.current?.(); + return false; + } else if (code === 'ArrowLeft') { + event.preventDefault(); + event.stopPropagation(); + onNavigateLeftRef.current?.(); + return false; + } else if (code === 'ArrowDown') { + event.preventDefault(); + event.stopPropagation(); + onNavigateDownRef.current?.(); + return false; + } else if (code === 'ArrowUp') { + event.preventDefault(); + event.stopPropagation(); + onNavigateUpRef.current?.(); + return false; + } + } + // Check cooldown to prevent rapid terminal creation const now = Date.now(); const canTrigger = now - lastShortcutTimeRef.current > SHORTCUT_COOLDOWN_MS; - // Use event.code for keyboard-layout-independent key detection - const code = event.code; + // Get current shortcuts from ref (allows customization) + const shortcuts = shortcutsRef.current; - // Alt+D - Split right - if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyD') { + // Split right (default: Alt+D) + if (matchesShortcutWithCode(event, shortcuts.splitTerminalRight)) { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -678,8 +792,8 @@ export function TerminalPanel({ return false; } - // Alt+S - Split down - if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyS') { + // Split down (default: Alt+S) + if (matchesShortcutWithCode(event, shortcuts.splitTerminalDown)) { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -688,8 +802,8 @@ export function TerminalPanel({ return false; } - // Alt+W - Close terminal - if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyW') { + // Close terminal (default: Alt+W) + if (matchesShortcutWithCode(event, shortcuts.closeTerminal)) { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -698,8 +812,8 @@ export function TerminalPanel({ return false; } - // Alt+T - New terminal tab - if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyT') { + // New terminal tab (default: Alt+T) + if (matchesShortcutWithCode(event, shortcuts.newTerminalTab)) { event.preventDefault(); if (canTrigger && onNewTabRef.current) { lastShortcutTimeRef.current = now; @@ -843,11 +957,20 @@ export function TerminalPanel({ hasRunInitialCommandRef.current = true; } break; - case "connected": + case "connected": { console.log(`[Terminal] Session connected: ${msg.shell} in ${msg.cwd}`); + // Detect shell type from path + const shellPath = (msg.shell || "").toLowerCase(); + // Windows shells use backslash paths and include powershell/pwsh/cmd + const isWindowsShell = shellPath.includes("\\") || + shellPath.includes("powershell") || + shellPath.includes("pwsh") || + shellPath.includes("cmd.exe"); + const isPowerShell = shellPath.includes("powershell") || shellPath.includes("pwsh"); + if (msg.shell) { - // Extract shell name from path (e.g., "/bin/bash" -> "bash") - const name = msg.shell.split("/").pop() || msg.shell; + // Extract shell name from path (e.g., "/bin/bash" -> "bash", "C:\...\powershell.exe" -> "powershell.exe") + const name = msg.shell.split(/[/\\]/).pop() || msg.shell; setShellName(name); } // Run initial command if specified and not already run @@ -858,15 +981,22 @@ export function TerminalPanel({ ws.readyState === WebSocket.OPEN ) { hasRunInitialCommandRef.current = true; - // Small delay to let the shell prompt appear + // Use appropriate line ending for the shell type + // Windows shells (PowerShell, cmd) expect \r\n, Unix shells expect \n + const lineEnding = isWindowsShell ? "\r\n" : "\n"; + // PowerShell takes longer to initialize (profile loading, etc.) + // Use 500ms for PowerShell, 100ms for other shells + const delay = isPowerShell ? 500 : 100; + setTimeout(() => { if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "input", data: runCommandOnConnect + "\n" })); + ws.send(JSON.stringify({ type: "input", data: runCommandOnConnect + lineEnding })); onCommandRan?.(); } - }, 100); + }, delay); } break; + } case "exit": terminal.write(`\r\n\x1b[33m[Process exited with code ${msg.exitCode}]\x1b[0m\r\n`); setProcessExitCode(msg.exitCode); @@ -907,10 +1037,20 @@ export function TerminalPanel({ if (event.code === 4004) { setConnectionStatus("disconnected"); - toast.error("Terminal session not found", { - description: "The session may have expired. Please create a new terminal.", - duration: 5000, - }); + // Notify parent that this session is no longer valid on the server + // This allows automatic cleanup of stale sessions (e.g., after server restart) + if (onSessionInvalidRef.current) { + onSessionInvalidRef.current(); + toast.info("Terminal session expired", { + description: "The session was automatically removed. Create a new terminal to continue.", + duration: 5000, + }); + } else { + toast.error("Terminal session not found", { + description: "The session may have expired. Please create a new terminal.", + duration: 5000, + }); + } return; } @@ -1472,6 +1612,7 @@ export function TerminalPanel({ isOver && isDropTarget && "ring-2 ring-green-500 ring-inset" )} onClick={onFocus} + onKeyDownCapture={handleContainerKeyDownCapture} tabIndex={0} data-terminal-container="true" > @@ -1818,7 +1959,7 @@ export function TerminalPanel({ setSearchQuery(e.target.value); // Auto-search as user types if (searchAddonRef.current && e.target.value) { - searchAddonRef.current.findNext(e.target.value, { caseSensitive: false, regex: false }); + searchAddonRef.current.findNext(e.target.value, searchOptions); } else if (searchAddonRef.current) { searchAddonRef.current.clearDecorations(); } diff --git a/apps/ui/src/config/terminal-themes.ts b/apps/ui/src/config/terminal-themes.ts index c4c6ce6a..2aa44209 100644 --- a/apps/ui/src/config/terminal-themes.ts +++ b/apps/ui/src/config/terminal-themes.ts @@ -28,6 +28,11 @@ export interface TerminalTheme { brightMagenta: string; brightCyan: string; brightWhite: string; + // Search highlighting colors - for xterm SearchAddon + searchMatchBackground: string; + searchMatchBorder: string; + searchActiveMatchBackground: string; + searchActiveMatchBorder: string; } /** @@ -77,6 +82,11 @@ const darkTheme: TerminalTheme = { brightMagenta: "#c586c0", brightCyan: "#4ec9b0", brightWhite: "#ffffff", + // Search colors - bright yellow for visibility on dark background + searchMatchBackground: "#6b5300", + searchMatchBorder: "#e2ac00", + searchActiveMatchBackground: "#ff8c00", + searchActiveMatchBorder: "#ffb74d", }; // Light theme @@ -102,6 +112,11 @@ const lightTheme: TerminalTheme = { brightMagenta: "#c678dd", brightCyan: "#56b6c2", brightWhite: "#ffffff", + // Search colors - darker for visibility on light background + searchMatchBackground: "#fff3b0", + searchMatchBorder: "#c9a500", + searchActiveMatchBackground: "#ffcc00", + searchActiveMatchBorder: "#996600", }; // Retro / Cyberpunk theme - neon green on black @@ -128,6 +143,11 @@ const retroTheme: TerminalTheme = { brightMagenta: "#ff55ff", brightCyan: "#55ffff", brightWhite: "#ffffff", + // Search colors - magenta/pink for contrast with green text + searchMatchBackground: "#660066", + searchMatchBorder: "#ff00ff", + searchActiveMatchBackground: "#cc00cc", + searchActiveMatchBorder: "#ff66ff", }; // Dracula theme @@ -153,6 +173,11 @@ const draculaTheme: TerminalTheme = { brightMagenta: "#ff92df", brightCyan: "#a4ffff", brightWhite: "#ffffff", + // Search colors - orange for visibility + searchMatchBackground: "#8b5a00", + searchMatchBorder: "#ffb86c", + searchActiveMatchBackground: "#ff9500", + searchActiveMatchBorder: "#ffcc80", }; // Nord theme @@ -178,6 +203,11 @@ const nordTheme: TerminalTheme = { brightMagenta: "#b48ead", brightCyan: "#8fbcbb", brightWhite: "#eceff4", + // Search colors - warm yellow/orange for cold blue theme + searchMatchBackground: "#5e4a00", + searchMatchBorder: "#ebcb8b", + searchActiveMatchBackground: "#d08770", + searchActiveMatchBorder: "#e8a87a", }; // Monokai theme @@ -203,6 +233,11 @@ const monokaiTheme: TerminalTheme = { brightMagenta: "#ae81ff", brightCyan: "#a1efe4", brightWhite: "#f9f8f5", + // Search colors - orange/gold for contrast + searchMatchBackground: "#6b4400", + searchMatchBorder: "#f4bf75", + searchActiveMatchBackground: "#e69500", + searchActiveMatchBorder: "#ffd080", }; // Tokyo Night theme @@ -228,6 +263,11 @@ const tokyonightTheme: TerminalTheme = { brightMagenta: "#bb9af7", brightCyan: "#7dcfff", brightWhite: "#c0caf5", + // Search colors - warm orange for cold blue theme + searchMatchBackground: "#5c4a00", + searchMatchBorder: "#e0af68", + searchActiveMatchBackground: "#ff9e64", + searchActiveMatchBorder: "#ffb380", }; // Solarized Dark theme (improved contrast for WCAG compliance) @@ -253,6 +293,11 @@ const solarizedTheme: TerminalTheme = { brightMagenta: "#6c71c4", brightCyan: "#93a1a1", brightWhite: "#fdf6e3", + // Search colors - orange (solarized orange) for visibility + searchMatchBackground: "#5c3d00", + searchMatchBorder: "#b58900", + searchActiveMatchBackground: "#cb4b16", + searchActiveMatchBorder: "#e07040", }; // Gruvbox Dark theme @@ -278,6 +323,11 @@ const gruvboxTheme: TerminalTheme = { brightMagenta: "#d3869b", brightCyan: "#8ec07c", brightWhite: "#ebdbb2", + // Search colors - bright orange for gruvbox + searchMatchBackground: "#6b4500", + searchMatchBorder: "#d79921", + searchActiveMatchBackground: "#fe8019", + searchActiveMatchBorder: "#ffaa40", }; // Catppuccin Mocha theme @@ -303,6 +353,11 @@ const catppuccinTheme: TerminalTheme = { brightMagenta: "#cba6f7", brightCyan: "#94e2d5", brightWhite: "#a6adc8", + // Search colors - peach/orange from catppuccin palette + searchMatchBackground: "#5c4020", + searchMatchBorder: "#fab387", + searchActiveMatchBackground: "#fab387", + searchActiveMatchBorder: "#fcc8a0", }; // One Dark theme @@ -328,6 +383,11 @@ const onedarkTheme: TerminalTheme = { brightMagenta: "#c678dd", brightCyan: "#56b6c2", brightWhite: "#ffffff", + // Search colors - orange/gold for visibility + searchMatchBackground: "#5c4500", + searchMatchBorder: "#e5c07b", + searchActiveMatchBackground: "#d19a66", + searchActiveMatchBorder: "#e8b888", }; // Synthwave '84 theme @@ -353,6 +413,11 @@ const synthwaveTheme: TerminalTheme = { brightMagenta: "#ff7edb", brightCyan: "#03edf9", brightWhite: "#ffffff", + // Search colors - hot pink/magenta for synthwave aesthetic + searchMatchBackground: "#6b2a7a", + searchMatchBorder: "#ff7edb", + searchActiveMatchBackground: "#ff7edb", + searchActiveMatchBorder: "#ffffff", }; // Red theme - Dark theme with red accents @@ -378,6 +443,11 @@ const redTheme: TerminalTheme = { brightMagenta: "#cc77aa", brightCyan: "#77aaaa", brightWhite: "#d0c0c0", + // Search colors - orange/gold to contrast with red theme + searchMatchBackground: "#5a3520", + searchMatchBorder: "#ccaa55", + searchActiveMatchBackground: "#ddbb66", + searchActiveMatchBorder: "#ffdd88", }; // Cream theme - Warm, soft, easy on the eyes @@ -403,6 +473,11 @@ const creamTheme: TerminalTheme = { brightMagenta: "#c080a0", brightCyan: "#70b0a0", brightWhite: "#d0c0b0", + // Search colors - blue for contrast on light cream background + searchMatchBackground: "#c0d4e8", + searchMatchBorder: "#6b8aaa", + searchActiveMatchBackground: "#6b8aaa", + searchActiveMatchBorder: "#4a6a8a", }; // Sunset theme - Mellow oranges and soft pastels @@ -428,6 +503,11 @@ const sunsetTheme: TerminalTheme = { brightMagenta: "#dd88aa", brightCyan: "#88ddbb", brightWhite: "#f5e8dd", + // Search colors - orange for warm sunset theme + searchMatchBackground: "#5a3a30", + searchMatchBorder: "#ddaa66", + searchActiveMatchBackground: "#eebb77", + searchActiveMatchBorder: "#ffdd99", }; // Gray theme - Modern, minimal gray scheme inspired by Cursor @@ -453,6 +533,11 @@ const grayTheme: TerminalTheme = { brightMagenta: "#c098c8", brightCyan: "#80b8c8", brightWhite: "#e0e0e8", + // Search colors - blue for modern feel + searchMatchBackground: "#3a4a60", + searchMatchBorder: "#7090c0", + searchActiveMatchBackground: "#90b0d8", + searchActiveMatchBorder: "#b0d0f0", }; // Theme mapping diff --git a/apps/ui/src/hooks/use-keyboard-shortcuts.ts b/apps/ui/src/hooks/use-keyboard-shortcuts.ts index f5e72d12..2e77e160 100644 --- a/apps/ui/src/hooks/use-keyboard-shortcuts.ts +++ b/apps/ui/src/hooks/use-keyboard-shortcuts.ts @@ -77,6 +77,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 = { + "`": "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 */ diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index aa1f63f0..5c942a18 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -2239,6 +2239,9 @@ export const useAppStore = create()( ...current, activeTabId: tabId, activeSessionId: newActiveSessionId, + // Clear maximized state when switching tabs - the maximized terminal + // belongs to the previous tab and shouldn't persist across tab switches + maximizedSessionId: null, }, }); }, @@ -2636,6 +2639,36 @@ export const useAppStore = create()( { name: "automaker-storage", version: 2, // Increment when making breaking changes to persisted state + // Custom merge function to properly restore terminal settings on every load + // The default shallow merge doesn't work because we persist terminalSettings + // separately from terminalState (to avoid persisting session data like tabs) + merge: (persistedState, currentState) => { + const persisted = persistedState as Partial & { terminalSettings?: PersistedTerminalSettings }; + const current = currentState as AppState & AppActions; + + // Start with default shallow merge + const merged = { ...current, ...persisted } as AppState & AppActions; + + // Restore terminal settings into terminalState + // terminalSettings is persisted separately from terminalState to avoid + // persisting session data (tabs, activeSessionId, etc.) + if (persisted.terminalSettings) { + merged.terminalState = { + // Start with current (initial) terminalState for session fields + ...current.terminalState, + // Override with persisted settings + defaultFontSize: persisted.terminalSettings.defaultFontSize ?? current.terminalState.defaultFontSize, + defaultRunScript: persisted.terminalSettings.defaultRunScript ?? current.terminalState.defaultRunScript, + screenReaderMode: persisted.terminalSettings.screenReaderMode ?? current.terminalState.screenReaderMode, + fontFamily: persisted.terminalSettings.fontFamily ?? current.terminalState.fontFamily, + scrollbackLines: persisted.terminalSettings.scrollbackLines ?? current.terminalState.scrollbackLines, + lineHeight: persisted.terminalSettings.lineHeight ?? current.terminalState.lineHeight, + maxSessions: persisted.terminalSettings.maxSessions ?? current.terminalState.maxSessions, + }; + } + + return merged; + }, migrate: (persistedState: unknown, version: number) => { const state = persistedState as Partial; diff --git a/docs/terminal.md b/docs/terminal.md index 8a30678f..ae78623f 100644 --- a/docs/terminal.md +++ b/docs/terminal.md @@ -32,11 +32,27 @@ When password protection is enabled: When the terminal is focused, the following shortcuts are available: -| Shortcut | Action | -| -------- | --------------------------------------- | -| `Alt+D` | Split terminal right (horizontal split) | -| `Alt+S` | Split terminal down (vertical split) | -| `Alt+W` | Close current terminal | +| Shortcut | Action | +| -------- | ---------------------------------------- | +| `Alt+T` | Open new terminal tab | +| `Alt+D` | Split terminal right (horizontal split) | +| `Alt+S` | Split terminal down (vertical split) | +| `Alt+W` | Close current terminal | + +These shortcuts are customizable via the keyboard shortcuts settings (Settings > Keyboard Shortcuts). + +### Split Pane Navigation + +Navigate between terminal panes using directional shortcuts: + +| Shortcut | Action | +| --------------------------------- | ----------------------------------- | +| `Ctrl+Alt+ArrowUp` (or `Cmd+Alt`) | Move focus to terminal pane above | +| `Ctrl+Alt+ArrowDown` | Move focus to terminal pane below | +| `Ctrl+Alt+ArrowLeft` | Move focus to terminal pane on left | +| `Ctrl+Alt+ArrowRight` | Move focus to terminal pane on right| + +The navigation is spatially aware - pressing Down will move to the terminal below your current one, not just cycle through terminals in order. Global shortcut (works anywhere in the app): | Shortcut | Action | From ee9ccd03d636641f48607348077a749a037dc150 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 21 Dec 2025 15:37:50 -0500 Subject: [PATCH 31/59] chore: remove Claude Code Review workflow file This commit deletes the .github/workflows/claude-code-review.yml file, which contained the configuration for the Claude Code Review GitHub Action. The removal is part of a cleanup process to streamline workflows and eliminate unused configurations. --- .github/workflows/claude-code-review.yml | 57 ------------------------ 1 file changed, 57 deletions(-) delete mode 100644 .github/workflows/claude-code-review.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 8452b0f2..00000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - prompt: | - REPO: ${{ github.repository }} - PR NUMBER: ${{ github.event.pull_request.number }} - - Please review this pull request and provide feedback on: - - Code quality and best practices - - Potential bugs or issues - - Performance considerations - - Security concerns - - Test coverage - - Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. - - Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. - - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' - From 9ea80123fd166bb6ba97cafefbc03adef57a0e26 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 21:44:02 +0100 Subject: [PATCH 32/59] =?UTF-8?q?=E2=9C=A8=20update:=20enhance=20WikiView?= =?UTF-8?q?=20component=20with=20improved=20type=20definitions=20and=20doc?= =?UTF-8?q?umentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated type imports for `icon` and `content` in the `WikiSection` interface to use `ElementType` and `ReactNode` for better clarity and type safety. - Expanded the content description in the WikiView to include shared libraries and updated technology stack details. - Revised the directory structure representation for clarity and completeness, reflecting the current organization of the codebase. - Adjusted file paths in the feature list for better accuracy and organization. These changes aim to improve the documentation and type safety within the WikiView component, enhancing developer experience and understanding of the project structure. --- apps/ui/src/components/views/wiki-view.tsx | 326 ++++++++++----------- 1 file changed, 159 insertions(+), 167 deletions(-) diff --git a/apps/ui/src/components/views/wiki-view.tsx b/apps/ui/src/components/views/wiki-view.tsx index fed946fa..7192c9b4 100644 --- a/apps/ui/src/components/views/wiki-view.tsx +++ b/apps/ui/src/components/views/wiki-view.tsx @@ -1,5 +1,4 @@ -import { useState } from "react"; -import { cn } from "@/lib/utils"; +import { useState, type ReactNode, type ElementType } from 'react'; import { ChevronDown, ChevronRight, @@ -13,7 +12,6 @@ import { PlayCircle, Bot, LayoutGrid, - FileText, Terminal, Palette, Keyboard, @@ -23,13 +21,13 @@ import { TestTube, Brain, Users, -} from "lucide-react"; +} from 'lucide-react'; interface WikiSection { id: string; title: string; - icon: React.ElementType; - content: React.ReactNode; + icon: ElementType; + content: ReactNode; } function CollapsibleSection({ @@ -52,9 +50,7 @@ function CollapsibleSection({
    - - {section.title} - + {section.title} {isOpen ? ( ) : ( @@ -90,7 +86,7 @@ function CodeBlock({ children, title }: { children: string; title?: string }) { function FeatureList({ items, }: { - items: { icon: React.ElementType; title: string; description: string }[]; + items: { icon: ElementType; title: string; description: string }[]; }) { return (
    @@ -105,12 +101,8 @@ function FeatureList({
    -
    - {item.title} -
    -
    - {item.description} -
    +
    {item.title}
    +
    {item.description}
    ); @@ -120,9 +112,7 @@ function FeatureList({ } export function WikiView() { - const [openSections, setOpenSections] = useState>( - new Set(["overview"]) - ); + const [openSections, setOpenSections] = useState>(new Set(['overview'])); const toggleSection = (id: string) => { setOpenSections((prev) => { @@ -146,66 +136,66 @@ export function WikiView() { const sections: WikiSection[] = [ { - id: "overview", - title: "Project Overview", + id: 'overview', + title: 'Project Overview', icon: Rocket, content: (

    - Automaker is an - autonomous AI development studio that helps developers build - software faster using AI agents. + Automaker is an autonomous AI development + studio that helps developers build software faster using AI agents.

    - At its core, Automaker provides a visual Kanban board to manage - features. When you're ready, AI agents automatically implement those - features in your codebase, complete with git worktree isolation for - safe parallel development. + At its core, Automaker provides a visual Kanban board to manage features. When you're + ready, AI agents automatically implement those features in your codebase, complete with + git worktree isolation for safe parallel development.

    - Think of it as having a team of AI developers that can work on - multiple features simultaneously while you focus on the bigger - picture. + Think of it as having a team of AI developers that can work on multiple features + simultaneously while you focus on the bigger picture.

    ), }, { - id: "architecture", - title: "Architecture", + id: 'architecture', + title: 'Architecture', icon: Layers, content: (
    -

    Automaker is built as a monorepo with two main applications:

    +

    Automaker is built as a monorepo with two main applications and shared libraries:

    • - apps/ui - Next.js + + apps/ui - React + TanStack Router + Electron frontend for the desktop application
    • - apps/server - Express - backend handling API requests and agent orchestration + apps/server - Express backend handling + API requests and agent orchestration +
    • +
    • + libs/ - Shared packages for types, + utilities, and common logic used across apps

    Key Technologies:

      -
    • Electron wraps Next.js for cross-platform desktop support
    • -
    • - Real-time communication via WebSocket for live agent updates -
    • +
    • Electron + React + TanStack Router for cross-platform desktop support
    • +
    • Real-time communication via WebSocket for live agent updates
    • State management with Zustand for reactive UI updates
    • Claude Agent SDK for AI capabilities
    • +
    • Shared monorepo packages (@automaker/*) for code reuse
    ), }, { - id: "features", - title: "Key Features", + id: 'features', + title: 'Key Features', icon: Sparkles, content: (
    @@ -213,73 +203,69 @@ export function WikiView() { items={[ { icon: LayoutGrid, - title: "Kanban Board", + title: 'Kanban Board', description: - "4 columns: Backlog, In Progress, Waiting Approval, Verified. Drag and drop to manage feature lifecycle.", + '4 columns: Backlog, In Progress, Waiting Approval, Verified. Drag and drop to manage feature lifecycle.', }, { icon: Bot, - title: "AI Agent Integration", + title: 'AI Agent Integration', description: - "Powered by Claude via the Agent SDK with full file, bash, and git access.", + 'Powered by Claude via the Agent SDK with full file, bash, and git access.', }, { icon: Cpu, - title: "Multi-Model Support", + title: 'Multi-Model Support', description: - "Claude Haiku/Sonnet/Opus models. Choose the right model for each task.", + 'Claude Haiku/Sonnet/Opus models. Choose the right model for each task.', }, { icon: Brain, - title: "Extended Thinking", + title: 'Extended Thinking', description: - "Configurable thinking levels (none, low, medium, high, ultrathink) for complex tasks.", + 'Configurable thinking levels (none, low, medium, high, ultrathink) for complex tasks.', }, { icon: Zap, - title: "Real-time Streaming", - description: - "Watch AI agents work in real-time with live output streaming.", + title: 'Real-time Streaming', + description: 'Watch AI agents work in real-time with live output streaming.', }, { icon: GitBranch, - title: "Git Worktree Isolation", + title: 'Git Worktree Isolation', description: - "Each feature runs in its own git worktree for safe parallel development.", + 'Each feature runs in its own git worktree for safe parallel development.', }, { icon: Users, - title: "AI Profiles", + title: 'AI Profiles', description: - "Pre-configured model + thinking level combinations for different task types.", + 'Pre-configured model + thinking level combinations for different task types.', }, { icon: Terminal, - title: "Integrated Terminal", - description: - "Built-in terminal with tab support and split panes.", + title: 'Integrated Terminal', + description: 'Built-in terminal with tab support and split panes.', }, { icon: Keyboard, - title: "Keyboard Shortcuts", - description: "Fully customizable shortcuts for power users.", + title: 'Keyboard Shortcuts', + description: 'Fully customizable shortcuts for power users.', }, { icon: Palette, - title: "14 Themes", - description: - "From light to dark, retro to synthwave - pick your style.", + title: '14 Themes', + description: 'From light to dark, retro to synthwave - pick your style.', }, { icon: Image, - title: "Image Support", - description: "Attach images to features for visual context.", + title: 'Image Support', + description: 'Attach images to features for visual context.', }, { icon: TestTube, - title: "Test Integration", - description: - "Automatic test running and TDD support for quality assurance.", + title: 'Test Integration', + description: 'Automatic test running and TDD support for quality assurance.', }, ]} /> @@ -287,26 +273,23 @@ export function WikiView() { ), }, { - id: "data-flow", - title: "How It Works (Data Flow)", + id: 'data-flow', + title: 'How It Works (Data Flow)', icon: GitBranch, content: (
    -

    - Here's what happens when you use Automaker to implement a feature: -

    +

    Here's what happens when you use Automaker to implement a feature:

    1. Create Feature

      - Add a new feature card to the Kanban board with description and - steps + Add a new feature card to the Kanban board with description and steps

    2. Feature Saved

      - Feature saved to{" "} + Feature saved to{' '} .automaker/features/{id}/feature.json @@ -315,15 +298,13 @@ export function WikiView() {

    3. Start Work

      - Drag to "In Progress" or enable auto mode to start - implementation + Drag to "In Progress" or enable auto mode to start implementation

    4. Git Worktree Created

      - Backend AutoModeService creates isolated git worktree (if - enabled) + Backend AutoModeService creates isolated git worktree (if enabled)

    5. @@ -355,38 +336,64 @@ export function WikiView() { ), }, { - id: "structure", - title: "Project Structure", + id: 'structure', + title: 'Project Structure', icon: FolderTree, content: (
      -

      - The Automaker codebase is organized as follows: -

      +

      The Automaker codebase is organized as follows:

      - {`/automaker/ -├── apps/ -│ ├── app/ # Frontend (Next.js + Electron) -│ │ ├── electron/ # Electron main process -│ │ └── src/ -│ │ ├── app/ # Next.js App Router pages -│ │ ├── components/ # React components -│ │ ├── store/ # Zustand state management -│ │ ├── hooks/ # Custom React hooks -│ │ └── lib/ # Utilities and helpers -│ └── server/ # Backend (Express) -│ └── src/ -│ ├── routes/ # API endpoints -│ └── services/ # Business logic (AutoModeService, etc.) -├── docs/ # Documentation -└── package.json # Workspace root`} + {`automaker/ +├─ apps/ +│ ├─ ui/ Frontend (React + Electron) +│ │ └─ src/ +│ │ ├─ routes/ TanStack Router pages +│ │ ├─ components/ +│ │ │ ├─ layout/ Layout components (sidebar, etc.) +│ │ │ ├─ views/ View components (board, agent, etc.) +│ │ │ ├─ dialogs/ Dialog components +│ │ │ └─ ui/ shadcn/ui components +│ │ ├─ store/ Zustand state management +│ │ ├─ hooks/ Custom React hooks +│ │ ├─ lib/ Utilities and helpers +│ │ ├─ config/ App configuration files +│ │ ├─ contexts/ React context providers +│ │ ├─ styles/ CSS styles and theme definitions +│ │ ├─ types/ TypeScript type definitions +│ │ ├─ utils/ Utility functions +│ │ ├─ main.ts Electron main process entry +│ │ ├─ preload.ts Electron preload script +│ │ └─ renderer.tsx React renderer entry +│ │ +│ └─ server/ Backend (Express) +│ └─ src/ +│ ├─ routes/ API endpoints +│ ├─ services/ Business logic (AutoModeService, etc.) +│ ├─ lib/ Library utilities +│ ├─ middleware/ Express middleware +│ ├─ providers/ AI provider implementations +│ ├─ types/ TypeScript type definitions +│ └─ index.ts Server entry point +│ +├─ libs/ Shared packages (monorepo) +│ ├─ types/ TypeScript type definitions +│ ├─ utils/ Common utilities (logging, errors) +│ ├─ prompts/ AI prompt templates +│ ├─ platform/ Platform & path utilities +│ ├─ model-resolver/ Claude model resolution +│ ├─ dependency-resolver/ Feature dependency ordering +│ └─ git-utils/ Git operations & parsing +│ +├─ docs/ Documentation +└─ package.json Workspace root +`}
      ), }, { - id: "components", - title: "Key Components", + id: 'components', + title: 'Key Components', icon: Component, content: (
      @@ -394,33 +401,36 @@ export function WikiView() {
      {[ { - file: "sidebar.tsx", - desc: "Main navigation with project picker and view switching", + file: 'layout/sidebar.tsx', + desc: 'Main navigation with project picker and view switching', }, { - file: "board-view.tsx", - desc: "Kanban board with drag-and-drop cards", + file: 'views/board-view.tsx', + desc: 'Kanban board with drag-and-drop cards', }, { - file: "agent-view.tsx", - desc: "AI chat interface for conversational development", - }, - { file: "spec-view.tsx", desc: "Project specification editor" }, - { - file: "context-view.tsx", - desc: "Context file manager for AI context", + file: 'views/agent-view.tsx', + desc: 'AI chat interface for conversational development', }, { - file: "terminal-view.tsx", - desc: "Integrated terminal with splits and tabs", + file: 'views/spec-view/', + desc: 'Project specification editor with AI generation', }, { - file: "profiles-view.tsx", - desc: "AI profile management (model + thinking presets)", + file: 'views/context-view.tsx', + desc: 'Context file manager for AI context', }, { - file: "app-store.ts", - desc: "Central Zustand state management", + file: 'views/terminal-view/', + desc: 'Integrated terminal with splits and tabs', + }, + { + file: 'views/profiles-view.tsx', + desc: 'AI profile management (model + thinking presets)', + }, + { + file: 'store/app-store.ts', + desc: 'Central Zustand state management', }, ].map((item) => (
      {item.file} - - {item.desc} - + {item.desc}
      ))}
      @@ -440,31 +448,28 @@ export function WikiView() { ), }, { - id: "configuration", - title: "Configuration", + id: 'configuration', + title: 'Configuration', icon: Settings, content: (

      - Automaker stores project configuration in the{" "} - - .automaker/ - {" "} - directory: + Automaker stores project configuration in the{' '} + .automaker/ directory:

      {[ { - file: "app_spec.txt", - desc: "Project specification describing your app for AI context", + file: 'app_spec.txt', + desc: 'Project specification describing your app for AI context', }, { - file: "context/", - desc: "Additional context files (docs, examples) for AI", + file: 'context/', + desc: 'Additional context files (docs, examples) for AI', }, { - file: "features/", - desc: "Feature definitions with descriptions and steps", + file: 'features/', + desc: 'Feature definitions with descriptions and steps', }, ].map((item) => (
      {item.file} - - {item.desc} - + {item.desc}
      ))}
      -

      - Tip: App Spec Best Practices -

      +

      Tip: App Spec Best Practices

      • Include your tech stack and key dependencies
      • Describe the project structure and conventions
      • @@ -495,8 +496,8 @@ export function WikiView() { ), }, { - id: "getting-started", - title: "Getting Started", + id: 'getting-started', + title: 'Getting Started', icon: PlayCircle, content: (
        @@ -505,43 +506,38 @@ export function WikiView() {
      • Create or Open a Project

        - Use the sidebar to create a new project or open an existing - folder + Use the sidebar to create a new project or open an existing folder

      • Write an App Spec

        - Go to Spec Editor and describe your project. This helps AI - understand your codebase. + Go to Spec Editor and describe your project. This helps AI understand your codebase.

      • Add Context (Optional)

        - Add relevant documentation or examples to the Context view for - better AI results + Add relevant documentation or examples to the Context view for better AI results

      • Create Features

        - Add feature cards to your Kanban board with clear descriptions - and implementation steps + Add feature cards to your Kanban board with clear descriptions and implementation + steps

      • Configure AI Profile

        - Choose an AI profile or customize model/thinking settings per - feature + Choose an AI profile or customize model/thinking settings per feature

      • Start Implementation

        - Drag features to "In Progress" or enable auto mode to let AI - work + Drag features to "In Progress" or enable auto mode to let AI work

      • @@ -555,16 +551,12 @@ export function WikiView() {

        Pro Tips:

        • - Use keyboard shortcuts for faster navigation (press{" "} - ?{" "} - to see all) + Use keyboard shortcuts for faster navigation (press{' '} + ? to see all)
        • +
        • Enable git worktree isolation for parallel feature development
        • - Enable git worktree isolation for parallel feature development -
        • -
        • - Start with "Quick Edit" profile for simple tasks, use "Heavy - Task" for complex work + Start with "Quick Edit" profile for simple tasks, use "Heavy Task" for complex work
        • Keep your app spec up to date as your project evolves
        From 7b1b2fa463b3424c15fe7b2fae335226ef1b53f3 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 22:16:59 +0100 Subject: [PATCH 33/59] fix: project creation process with structured app_spec.txt - Updated the project creation logic to write a detailed app_spec.txt file in XML format, including project name, overview, technology stack, core capabilities, and implemented features. - Improved handling for projects created from templates and custom repositories, ensuring relevant information is captured in the app_spec.txt. - Enhanced user feedback with success messages upon project creation, improving overall user experience. These changes aim to provide a clearer project structure and facilitate better integration with AI analysis tools. --- .../sidebar/hooks/use-project-creation.ts | 122 ++++++++++++++++-- 1 file changed, 113 insertions(+), 9 deletions(-) diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts index 3d75fabb..c50c3d76 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts @@ -37,11 +37,31 @@ export function useProjectCreation({ // Initialize .automaker directory structure await initializeProject(projectPath); - // Write initial app_spec.txt with basic XML structure + // Write initial app_spec.txt with proper XML structure + // Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts const api = getElectronAPI(); await api.fs.writeFile( - `${projectPath}/app_spec.txt`, - `\n\n ${projectName}\n Add your project description here\n` + `${projectPath}/.automaker/app_spec.txt`, + ` + ${projectName} + + + Describe your project here. This file will be analyzed by an AI agent + to understand your project structure and tech stack. + + + + + + + + + + + + + +` ); // Determine theme: try trashed project theme, then current project theme, then global @@ -112,8 +132,50 @@ export function useProjectCreation({ // Clone template repository await api.git.clone(template.githubUrl, projectPath); - // Finalize project setup - await finalizeProjectCreation(projectPath, projectName); + // Initialize .automaker directory structure + await initializeProject(projectPath); + + // Write app_spec.txt with template-specific info + await api.fs.writeFile( + `${projectPath}/.automaker/app_spec.txt`, + ` + ${projectName} + + + This project was created from the "${template.name}" starter template. + ${template.description} + + + + ${template.techStack.map((tech) => `${tech}`).join('\n ')} + + + + ${template.features.map((feature) => `${feature}`).join('\n ')} + + + + + +` + ); + + // Determine theme + const trashedProject = trashedProjects.find((p) => p.path === projectPath); + const effectiveTheme = + (trashedProject?.theme as ThemeMode | undefined) || + (currentProject?.theme as ThemeMode | undefined) || + globalTheme; + + upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); + setShowNewProjectModal(false); + setNewProjectName(projectName); + setNewProjectPath(projectPath); + setShowOnboardingDialog(true); + + toast.success('Project created from template', { + description: `Created ${projectName} from ${template.name}`, + }); } catch (error) { console.error('[ProjectCreation] Failed to create from template:', error); toast.error('Failed to create project from template', { @@ -123,7 +185,7 @@ export function useProjectCreation({ setIsCreatingProject(false); } }, - [finalizeProjectCreation] + [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] ); /** @@ -139,8 +201,50 @@ export function useProjectCreation({ // Clone custom repository await api.git.clone(repoUrl, projectPath); - // Finalize project setup - await finalizeProjectCreation(projectPath, projectName); + // Initialize .automaker directory structure + await initializeProject(projectPath); + + // Write app_spec.txt with custom URL info + await api.fs.writeFile( + `${projectPath}/.automaker/app_spec.txt`, + ` + ${projectName} + + + This project was cloned from ${repoUrl}. + The AI agent will analyze the project structure. + + + + + + + + + + + + + +` + ); + + // Determine theme + const trashedProject = trashedProjects.find((p) => p.path === projectPath); + const effectiveTheme = + (trashedProject?.theme as ThemeMode | undefined) || + (currentProject?.theme as ThemeMode | undefined) || + globalTheme; + + upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); + setShowNewProjectModal(false); + setNewProjectName(projectName); + setNewProjectPath(projectPath); + setShowOnboardingDialog(true); + + toast.success('Project created from repository', { + description: `Created ${projectName} from ${repoUrl}`, + }); } catch (error) { console.error('[ProjectCreation] Failed to create from custom URL:', error); toast.error('Failed to create project from URL', { @@ -150,7 +254,7 @@ export function useProjectCreation({ setIsCreatingProject(false); } }, - [finalizeProjectCreation] + [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] ); return { From 43c93fe19a7345f2139422120d5fa73935ce8b4d Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 22:41:17 +0100 Subject: [PATCH 34/59] chore: remove pnpm-lock.yaml and add tests for ClaudeUsageService - Deleted the pnpm-lock.yaml file as part of project cleanup. - Introduced comprehensive unit tests for the ClaudeUsageService, covering methods for checking CLI availability, parsing reset times, and handling usage output. - Enhanced test coverage for both macOS and Windows environments, ensuring robust functionality across platforms. These changes aim to streamline project dependencies and improve the reliability of the Claude usage tracking service through thorough testing. --- .../services/claude-usage-service.test.ts | 637 ++++++++++++++++++ pnpm-lock.yaml | 70 -- 2 files changed, 637 insertions(+), 70 deletions(-) create mode 100644 apps/server/tests/unit/services/claude-usage-service.test.ts delete mode 100644 pnpm-lock.yaml diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts new file mode 100644 index 00000000..ed1ef69f --- /dev/null +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -0,0 +1,637 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ClaudeUsageService } from '@/services/claude-usage-service.js'; +import { spawn } from 'child_process'; +import * as pty from 'node-pty'; +import * as os from 'os'; + +vi.mock('child_process'); +vi.mock('node-pty'); +vi.mock('os'); + +describe('claude-usage-service.ts', () => { + let service: ClaudeUsageService; + let mockSpawnProcess: any; + let mockPtyProcess: any; + + beforeEach(() => { + vi.clearAllMocks(); + service = new ClaudeUsageService(); + + // Mock spawn process for isAvailable and Mac commands + mockSpawnProcess = { + on: vi.fn(), + kill: vi.fn(), + stdout: { + on: vi.fn(), + }, + stderr: { + on: vi.fn(), + }, + }; + + // Mock PTY process for Windows + mockPtyProcess = { + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + kill: vi.fn(), + }; + + vi.mocked(spawn).mockReturnValue(mockSpawnProcess as any); + vi.mocked(pty.spawn).mockReturnValue(mockPtyProcess); + }); + + describe('isAvailable', () => { + it('should return true when Claude CLI is available', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + + // Simulate successful which/where command + mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === 'close') { + callback(0); // Exit code 0 = found + } + return mockSpawnProcess; + }); + + const result = await service.isAvailable(); + + expect(result).toBe(true); + expect(spawn).toHaveBeenCalledWith('which', ['claude']); + }); + + it('should return false when Claude CLI is not available', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + + mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === 'close') { + callback(1); // Exit code 1 = not found + } + return mockSpawnProcess; + }); + + const result = await service.isAvailable(); + + expect(result).toBe(false); + }); + + it('should return false on error', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + + mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === 'error') { + callback(new Error('Command failed')); + } + return mockSpawnProcess; + }); + + const result = await service.isAvailable(); + + expect(result).toBe(false); + }); + + it("should use 'where' command on Windows", async () => { + vi.mocked(os.platform).mockReturnValue('win32'); + const windowsService = new ClaudeUsageService(); // Create new service after platform mock + + mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === 'close') { + callback(0); + } + return mockSpawnProcess; + }); + + await windowsService.isAvailable(); + + expect(spawn).toHaveBeenCalledWith('where', ['claude']); + }); + }); + + describe('stripAnsiCodes', () => { + it('should strip ANSI color codes from text', () => { + const service = new ClaudeUsageService(); + const input = '\x1B[31mRed text\x1B[0m Normal text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Red text Normal text'); + }); + + it('should handle text without ANSI codes', () => { + const service = new ClaudeUsageService(); + const input = 'Plain text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Plain text'); + }); + }); + + describe('parseResetTime', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-15T10:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should parse duration format with hours and minutes', () => { + const service = new ClaudeUsageService(); + const text = 'Resets in 2h 15m'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + const expected = new Date('2025-01-15T12:15:00Z'); + expect(new Date(result)).toEqual(expected); + }); + + it('should parse duration format with only minutes', () => { + const service = new ClaudeUsageService(); + const text = 'Resets in 30m'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + const expected = new Date('2025-01-15T10:30:00Z'); + expect(new Date(result)).toEqual(expected); + }); + + it('should parse simple time format (AM)', () => { + const service = new ClaudeUsageService(); + const text = 'Resets 11am'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + // Should be today at 11am, or tomorrow if already passed + const resultDate = new Date(result); + expect(resultDate.getHours()).toBe(11); + expect(resultDate.getMinutes()).toBe(0); + }); + + it('should parse simple time format (PM)', () => { + const service = new ClaudeUsageService(); + const text = 'Resets 3pm'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + const resultDate = new Date(result); + expect(resultDate.getHours()).toBe(15); + expect(resultDate.getMinutes()).toBe(0); + }); + + it('should parse date format with month, day, and time', () => { + const service = new ClaudeUsageService(); + const text = 'Resets Dec 22 at 8pm'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'weekly'); + + const resultDate = new Date(result); + expect(resultDate.getMonth()).toBe(11); // December = 11 + expect(resultDate.getDate()).toBe(22); + expect(resultDate.getHours()).toBe(20); + }); + + it('should parse date format with comma separator', () => { + const service = new ClaudeUsageService(); + const text = 'Resets Jan 15, 3:30pm'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'weekly'); + + const resultDate = new Date(result); + expect(resultDate.getMonth()).toBe(0); // January = 0 + expect(resultDate.getDate()).toBe(15); + expect(resultDate.getHours()).toBe(15); + expect(resultDate.getMinutes()).toBe(30); + }); + + it('should handle 12am correctly', () => { + const service = new ClaudeUsageService(); + const text = 'Resets 12am'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + const resultDate = new Date(result); + expect(resultDate.getHours()).toBe(0); + }); + + it('should handle 12pm correctly', () => { + const service = new ClaudeUsageService(); + const text = 'Resets 12pm'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + const resultDate = new Date(result); + expect(resultDate.getHours()).toBe(12); + }); + + it('should return default reset time for unparseable text', () => { + const service = new ClaudeUsageService(); + const text = 'Invalid reset text'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + // @ts-expect-error - accessing private method for testing + const defaultResult = service.getDefaultResetTime('session'); + + expect(result).toBe(defaultResult); + }); + }); + + describe('getDefaultResetTime', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-15T10:00:00Z')); // Wednesday + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should return session default (5 hours from now)', () => { + const service = new ClaudeUsageService(); + // @ts-expect-error - accessing private method for testing + const result = service.getDefaultResetTime('session'); + + const expected = new Date('2025-01-15T15:00:00Z'); + expect(new Date(result)).toEqual(expected); + }); + + it('should return weekly default (next Monday at noon)', () => { + const service = new ClaudeUsageService(); + // @ts-expect-error - accessing private method for testing + const result = service.getDefaultResetTime('weekly'); + + const resultDate = new Date(result); + // Next Monday from Wednesday should be 5 days away + expect(resultDate.getDay()).toBe(1); // Monday + expect(resultDate.getHours()).toBe(12); + expect(resultDate.getMinutes()).toBe(59); + }); + }); + + describe('parseSection', () => { + it('should parse section with percentage left', () => { + const service = new ClaudeUsageService(); + const lines = ['Current session', '████████████████░░░░ 65% left', 'Resets in 2h 15m']; + // @ts-expect-error - accessing private method for testing + const result = service.parseSection(lines, 'Current session', 'session'); + + expect(result.percentage).toBe(35); // 100 - 65 = 35% used + expect(result.resetText).toBe('Resets in 2h 15m'); + }); + + it('should parse section with percentage used', () => { + const service = new ClaudeUsageService(); + const lines = [ + 'Current week (all models)', + '██████████░░░░░░░░░░ 40% used', + 'Resets Jan 15, 3:30pm', + ]; + // @ts-expect-error - accessing private method for testing + const result = service.parseSection(lines, 'Current week (all models)', 'weekly'); + + expect(result.percentage).toBe(40); // Already in % used + }); + + it('should return zero percentage when section not found', () => { + const service = new ClaudeUsageService(); + const lines = ['Some other text', 'No matching section']; + // @ts-expect-error - accessing private method for testing + const result = service.parseSection(lines, 'Current session', 'session'); + + expect(result.percentage).toBe(0); + }); + + it('should strip timezone from reset text', () => { + const service = new ClaudeUsageService(); + const lines = ['Current session', '65% left', 'Resets 3pm (America/Los_Angeles)']; + // @ts-expect-error - accessing private method for testing + const result = service.parseSection(lines, 'Current session', 'session'); + + expect(result.resetText).toBe('Resets 3pm'); + expect(result.resetText).not.toContain('America/Los_Angeles'); + }); + + it('should handle case-insensitive section matching', () => { + const service = new ClaudeUsageService(); + const lines = ['CURRENT SESSION', '65% left', 'Resets in 2h']; + // @ts-expect-error - accessing private method for testing + const result = service.parseSection(lines, 'current session', 'session'); + + expect(result.percentage).toBe(35); + }); + }); + + describe('parseUsageOutput', () => { + it('should parse complete usage output', () => { + const service = new ClaudeUsageService(); + const output = ` +Claude Code v1.0.27 + +Current session +████████████████░░░░ 65% left +Resets in 2h 15m + +Current week (all models) +██████████░░░░░░░░░░ 35% left +Resets Jan 15, 3:30pm (America/Los_Angeles) + +Current week (Sonnet only) +████████████████████ 80% left +Resets Jan 15, 3:30pm (America/Los_Angeles) +`; + // @ts-expect-error - accessing private method for testing + const result = service.parseUsageOutput(output); + + expect(result.sessionPercentage).toBe(35); // 100 - 65 + expect(result.weeklyPercentage).toBe(65); // 100 - 35 + expect(result.sonnetWeeklyPercentage).toBe(20); // 100 - 80 + expect(result.sessionResetText).toContain('Resets in 2h 15m'); + expect(result.weeklyResetText).toContain('Resets Jan 15, 3:30pm'); + expect(result.userTimezone).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone); + }); + + it('should handle output with ANSI codes', () => { + const service = new ClaudeUsageService(); + const output = ` +\x1B[1mClaude Code v1.0.27\x1B[0m + +\x1B[1mCurrent session\x1B[0m +\x1B[32m████████████████░░░░\x1B[0m 65% left +Resets in 2h 15m +`; + // @ts-expect-error - accessing private method for testing + const result = service.parseUsageOutput(output); + + expect(result.sessionPercentage).toBe(35); + }); + + it('should handle Opus section name', () => { + const service = new ClaudeUsageService(); + const output = ` +Current session +65% left +Resets in 2h + +Current week (all models) +35% left +Resets Jan 15, 3pm + +Current week (Opus) +90% left +Resets Jan 15, 3pm +`; + // @ts-expect-error - accessing private method for testing + const result = service.parseUsageOutput(output); + + expect(result.sonnetWeeklyPercentage).toBe(10); // 100 - 90 + }); + + it('should set default values for missing sections', () => { + const service = new ClaudeUsageService(); + const output = 'Claude Code v1.0.27'; + // @ts-expect-error - accessing private method for testing + const result = service.parseUsageOutput(output); + + expect(result.sessionPercentage).toBe(0); + expect(result.weeklyPercentage).toBe(0); + expect(result.sonnetWeeklyPercentage).toBe(0); + expect(result.sessionTokensUsed).toBe(0); + expect(result.sessionLimit).toBe(0); + expect(result.costUsed).toBeNull(); + expect(result.costLimit).toBeNull(); + expect(result.costCurrency).toBeNull(); + }); + }); + + describe('executeClaudeUsageCommandMac', () => { + beforeEach(() => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.spyOn(process, 'env', 'get').mockReturnValue({ HOME: '/Users/testuser' }); + }); + + it('should execute expect script and return output', async () => { + const mockOutput = ` +Current session +65% left +Resets in 2h +`; + + let stdoutCallback: Function; + let closeCallback: Function; + + mockSpawnProcess.stdout = { + on: vi.fn((event: string, callback: Function) => { + if (event === 'data') { + stdoutCallback = callback; + } + }), + }; + mockSpawnProcess.stderr = { + on: vi.fn(), + }; + mockSpawnProcess.on = vi.fn((event: string, callback: Function) => { + if (event === 'close') { + closeCallback = callback; + } + return mockSpawnProcess; + }); + + const promise = service.fetchUsageData(); + + // Simulate stdout data + stdoutCallback!(Buffer.from(mockOutput)); + + // Simulate successful close + closeCallback!(0); + + const result = await promise; + + expect(result.sessionPercentage).toBe(35); // 100 - 65 + expect(spawn).toHaveBeenCalledWith( + 'expect', + expect.arrayContaining(['-c']), + expect.any(Object) + ); + }); + + it('should handle authentication errors', async () => { + const mockOutput = 'token_expired'; + + let stdoutCallback: Function; + let closeCallback: Function; + + mockSpawnProcess.stdout = { + on: vi.fn((event: string, callback: Function) => { + if (event === 'data') { + stdoutCallback = callback; + } + }), + }; + mockSpawnProcess.stderr = { + on: vi.fn(), + }; + mockSpawnProcess.on = vi.fn((event: string, callback: Function) => { + if (event === 'close') { + closeCallback = callback; + } + return mockSpawnProcess; + }); + + const promise = service.fetchUsageData(); + + stdoutCallback!(Buffer.from(mockOutput)); + closeCallback!(1); + + await expect(promise).rejects.toThrow('Authentication required'); + }); + + it('should handle timeout', async () => { + vi.useFakeTimers(); + + mockSpawnProcess.stdout = { + on: vi.fn(), + }; + mockSpawnProcess.stderr = { + on: vi.fn(), + }; + mockSpawnProcess.on = vi.fn(() => mockSpawnProcess); + mockSpawnProcess.kill = vi.fn(); + + const promise = service.fetchUsageData(); + + // Advance time past timeout (30 seconds) + vi.advanceTimersByTime(31000); + + await expect(promise).rejects.toThrow('Command timed out'); + + vi.useRealTimers(); + }); + }); + + describe('executeClaudeUsageCommandWindows', () => { + beforeEach(() => { + vi.mocked(os.platform).mockReturnValue('win32'); + vi.mocked(os.homedir).mockReturnValue('C:\\Users\\testuser'); + vi.spyOn(process, 'env', 'get').mockReturnValue({ USERPROFILE: 'C:\\Users\\testuser' }); + }); + + it('should use node-pty on Windows and return output', async () => { + const windowsService = new ClaudeUsageService(); // Create new service for Windows platform + const mockOutput = ` +Current session +65% left +Resets in 2h +`; + + let dataCallback: Function | undefined; + let exitCallback: Function | undefined; + + const mockPty = { + onData: vi.fn((callback: Function) => { + dataCallback = callback; + }), + onExit: vi.fn((callback: Function) => { + exitCallback = callback; + }), + write: vi.fn(), + kill: vi.fn(), + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + const promise = windowsService.fetchUsageData(); + + // Simulate data + dataCallback!(mockOutput); + + // Simulate successful exit + exitCallback!({ exitCode: 0 }); + + const result = await promise; + + expect(result.sessionPercentage).toBe(35); + expect(pty.spawn).toHaveBeenCalledWith( + 'cmd.exe', + ['/c', 'claude', '/usage'], + expect.any(Object) + ); + }); + + it('should send escape key after seeing usage data', async () => { + vi.useFakeTimers(); + const windowsService = new ClaudeUsageService(); + + const mockOutput = 'Current session\n65% left'; + + let dataCallback: Function | undefined; + + const mockPty = { + onData: vi.fn((callback: Function) => { + dataCallback = callback; + }), + onExit: vi.fn(), + write: vi.fn(), + kill: vi.fn(), + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + windowsService.fetchUsageData(); + + // Simulate seeing usage data + dataCallback!(mockOutput); + + // Advance time to trigger escape key sending + vi.advanceTimersByTime(2100); + + expect(mockPty.write).toHaveBeenCalledWith('\x1b'); + + vi.useRealTimers(); + }); + + it('should handle authentication errors on Windows', async () => { + const windowsService = new ClaudeUsageService(); + let dataCallback: Function | undefined; + let exitCallback: Function | undefined; + + const mockPty = { + onData: vi.fn((callback: Function) => { + dataCallback = callback; + }), + onExit: vi.fn((callback: Function) => { + exitCallback = callback; + }), + write: vi.fn(), + kill: vi.fn(), + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + const promise = windowsService.fetchUsageData(); + + dataCallback!('authentication_error'); + exitCallback!({ exitCode: 1 }); + + await expect(promise).rejects.toThrow('Authentication required'); + }); + + it('should handle timeout on Windows', async () => { + vi.useFakeTimers(); + const windowsService = new ClaudeUsageService(); + + const mockPty = { + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + kill: vi.fn(), + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + const promise = windowsService.fetchUsageData(); + + vi.advanceTimersByTime(31000); + + await expect(promise).rejects.toThrow('Command timed out'); + expect(mockPty.kill).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 06e3abdf..00000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,70 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - cross-spawn: - specifier: ^7.0.6 - version: 7.0.6 - tree-kill: - specifier: ^1.2.2 - version: 1.2.2 - -packages: - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - -snapshots: - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - isexe@2.0.0: {} - - path-key@3.1.1: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - tree-kill@1.2.2: {} - - which@2.0.2: - dependencies: - isexe: 2.0.0 From 26236d3d5b0c5fccfd0cb605aab8b5fcfee731f1 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 23:08:08 +0100 Subject: [PATCH 35/59] feat: enhance ESLint configuration and improve component error handling - Updated ESLint configuration to include support for `.mjs` and `.cjs` file types, adding necessary global variables for Node.js and browser environments. - Introduced a new `vite-env.d.ts` file to define environment variables for Vite, improving type safety. - Refactored error handling in `file-browser-dialog.tsx`, `description-image-dropzone.tsx`, and `feature-image-upload.tsx` to omit error parameters, simplifying the catch blocks. - Removed unused bug report button functionality from the sidebar, streamlining the component structure. - Adjusted various components to improve code readability and maintainability, including updates to type imports and component props. These changes aim to enhance the development experience by improving linting support and simplifying error handling across components. --- apps/ui/eslint.config.mjs | 76 +++ .../dialogs/file-browser-dialog.tsx | 112 ++-- apps/ui/src/components/layout/sidebar.tsx | 12 +- .../sidebar/components/bug-report-button.tsx | 16 +- .../project-selector-with-options.tsx | 2 +- .../sidebar/components/sidebar-header.tsx | 7 +- .../ui/src/components/layout/sidebar/types.ts | 6 +- apps/ui/src/components/ui/accordion.tsx | 138 +++-- .../ui/description-image-dropzone.tsx | 186 ++++--- .../components/ui/feature-image-upload.tsx | 77 +-- apps/ui/src/components/ui/image-drop-zone.tsx | 244 +++++---- apps/ui/src/components/ui/sheet.tsx | 73 ++- apps/ui/src/components/views/agent-view.tsx | 306 +++++------ .../ui/src/components/views/analysis-view.tsx | 503 +++++++----------- .../views/board-view/board-header.tsx | 26 +- .../kanban-card/agent-info-panel.tsx | 130 ++--- .../board-view/dialogs/agent-output-modal.tsx | 193 ++++--- .../dialogs/edit-feature-dialog.tsx | 179 +++---- .../views/board-view/kanban-board.tsx | 67 +-- apps/ui/src/components/views/context-view.tsx | 255 ++++----- .../authentication-status-display.tsx | 44 +- .../components/delete-project-dialog.tsx | 17 +- .../setup-view/steps/claude-setup-step.tsx | 362 +++++-------- .../views/setup-view/steps/complete-step.tsx | 26 +- .../views/setup-view/steps/welcome-step.tsx | 14 +- .../ui/src/components/views/terminal-view.tsx | 228 ++++---- apps/ui/src/components/views/welcome-view.tsx | 20 +- apps/ui/src/config/api-providers.ts | 34 +- apps/ui/src/lib/file-picker.ts | 109 ++-- apps/ui/src/lib/http-api-client.ts | 385 ++++++-------- apps/ui/src/lib/utils.ts | 18 +- apps/ui/src/lib/workspace-config.ts | 23 +- apps/ui/src/routes/__root.tsx | 2 +- apps/ui/src/vite-env.d.ts | 11 + apps/ui/tests/feature-lifecycle.spec.ts | 225 +++----- apps/ui/tests/spec-editor-persistence.spec.ts | 161 +++--- apps/ui/tests/utils/components/toasts.ts | 24 +- apps/ui/tests/utils/git/worktree.ts | 158 +++--- apps/ui/tests/utils/views/board.ts | 94 +--- apps/ui/tests/utils/views/setup.ts | 37 +- 40 files changed, 2013 insertions(+), 2587 deletions(-) create mode 100644 apps/ui/src/vite-env.d.ts diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs index 150f0bad..0b7d6f0e 100644 --- a/apps/ui/eslint.config.mjs +++ b/apps/ui/eslint.config.mjs @@ -5,6 +5,18 @@ import tsParser from "@typescript-eslint/parser"; const eslintConfig = defineConfig([ js.configs.recommended, + { + files: ["**/*.mjs", "**/*.cjs"], + languageOptions: { + globals: { + console: "readonly", + process: "readonly", + require: "readonly", + __dirname: "readonly", + __filename: "readonly", + }, + }, + }, { files: ["**/*.ts", "**/*.tsx"], languageOptions: { @@ -13,6 +25,70 @@ const eslintConfig = defineConfig([ ecmaVersion: "latest", sourceType: "module", }, + globals: { + // Browser/DOM APIs + window: "readonly", + document: "readonly", + navigator: "readonly", + Navigator: "readonly", + localStorage: "readonly", + sessionStorage: "readonly", + fetch: "readonly", + WebSocket: "readonly", + File: "readonly", + FileList: "readonly", + FileReader: "readonly", + Blob: "readonly", + atob: "readonly", + crypto: "readonly", + prompt: "readonly", + confirm: "readonly", + getComputedStyle: "readonly", + requestAnimationFrame: "readonly", + // DOM Element Types + HTMLElement: "readonly", + HTMLInputElement: "readonly", + HTMLDivElement: "readonly", + HTMLButtonElement: "readonly", + HTMLSpanElement: "readonly", + HTMLTextAreaElement: "readonly", + HTMLHeadingElement: "readonly", + HTMLParagraphElement: "readonly", + HTMLImageElement: "readonly", + Element: "readonly", + // Event Types + Event: "readonly", + KeyboardEvent: "readonly", + DragEvent: "readonly", + PointerEvent: "readonly", + CustomEvent: "readonly", + ClipboardEvent: "readonly", + WheelEvent: "readonly", + DataTransfer: "readonly", + // Web APIs + ResizeObserver: "readonly", + AbortSignal: "readonly", + Audio: "readonly", + ScrollBehavior: "readonly", + // Timers + setTimeout: "readonly", + setInterval: "readonly", + clearTimeout: "readonly", + clearInterval: "readonly", + // Node.js (for scripts and Electron) + process: "readonly", + require: "readonly", + __dirname: "readonly", + __filename: "readonly", + NodeJS: "readonly", + // React + React: "readonly", + JSX: "readonly", + // Electron + Electron: "readonly", + // Console + console: "readonly", + }, }, plugins: { "@typescript-eslint": ts, diff --git a/apps/ui/src/components/dialogs/file-browser-dialog.tsx b/apps/ui/src/components/dialogs/file-browser-dialog.tsx index b6a05ab0..dc9c1c2e 100644 --- a/apps/ui/src/components/dialogs/file-browser-dialog.tsx +++ b/apps/ui/src/components/dialogs/file-browser-dialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback } from 'react'; import { FolderOpen, Folder, @@ -9,7 +9,7 @@ import { CornerDownLeft, Clock, X, -} from "lucide-react"; +} from 'lucide-react'; import { Dialog, DialogContent, @@ -17,14 +17,11 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { getJSON, setJSON } from "@/lib/storage"; -import { - getDefaultWorkspaceDirectory, - saveLastProjectDirectory, -} from "@/lib/workspace-config"; +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { getJSON, setJSON } from '@/lib/storage'; +import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config'; interface DirectoryEntry { name: string; @@ -50,7 +47,7 @@ interface FileBrowserDialogProps { initialPath?: string; } -const RECENT_FOLDERS_KEY = "file-browser-recent-folders"; +const RECENT_FOLDERS_KEY = 'file-browser-recent-folders'; const MAX_RECENT_FOLDERS = 5; function getRecentFolders(): string[] { @@ -76,18 +73,18 @@ export function FileBrowserDialog({ open, onOpenChange, onSelect, - title = "Select Project Directory", - description = "Navigate to your project folder or paste a path directly", + title = 'Select Project Directory', + description = 'Navigate to your project folder or paste a path directly', initialPath, }: FileBrowserDialogProps) { - const [currentPath, setCurrentPath] = useState(""); - const [pathInput, setPathInput] = useState(""); + const [currentPath, setCurrentPath] = useState(''); + const [pathInput, setPathInput] = useState(''); const [parentPath, setParentPath] = useState(null); const [directories, setDirectories] = useState([]); const [drives, setDrives] = useState([]); const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [warning, setWarning] = useState(""); + const [error, setError] = useState(''); + const [warning, setWarning] = useState(''); const [recentFolders, setRecentFolders] = useState([]); const pathInputRef = useRef(null); @@ -98,28 +95,24 @@ export function FileBrowserDialog({ } }, [open]); - const handleRemoveRecent = useCallback( - (e: React.MouseEvent, path: string) => { - e.stopPropagation(); - const updated = removeRecentFolder(path); - setRecentFolders(updated); - }, - [] - ); + const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => { + e.stopPropagation(); + const updated = removeRecentFolder(path); + setRecentFolders(updated); + }, []); const browseDirectory = useCallback(async (dirPath?: string) => { setLoading(true); - setError(""); - setWarning(""); + setError(''); + setWarning(''); try { // Get server URL from environment or default - const serverUrl = - import.meta.env.VITE_SERVER_URL || "http://localhost:3008"; + const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; const response = await fetch(`${serverUrl}/api/fs/browse`, { - method: "POST", - headers: { "Content-Type": "application/json" }, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dirPath }), }); @@ -131,14 +124,12 @@ export function FileBrowserDialog({ setParentPath(result.parentPath); setDirectories(result.directories); setDrives(result.drives || []); - setWarning(result.warning || ""); + setWarning(result.warning || ''); } else { - setError(result.error || "Failed to browse directory"); + setError(result.error || 'Failed to browse directory'); } } catch (err) { - setError( - err instanceof Error ? err.message : "Failed to load directories" - ); + setError(err instanceof Error ? err.message : 'Failed to load directories'); } finally { setLoading(false); } @@ -154,12 +145,12 @@ export function FileBrowserDialog({ // Reset current path when dialog closes useEffect(() => { if (!open) { - setCurrentPath(""); - setPathInput(""); + setCurrentPath(''); + setPathInput(''); setParentPath(null); setDirectories([]); - setError(""); - setWarning(""); + setError(''); + setWarning(''); } }, [open]); @@ -189,7 +180,7 @@ export function FileBrowserDialog({ // No default directory, browse home directory browseDirectory(); } - } catch (err) { + } catch { // If config fetch fails, try initialPath or fall back to home directory if (initialPath) { setPathInput(initialPath); @@ -230,7 +221,7 @@ export function FileBrowserDialog({ }; const handlePathInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { + if (e.key === 'Enter') { e.preventDefault(); handleGoToPath(); } @@ -252,7 +243,7 @@ export function FileBrowserDialog({ const handleKeyDown = (e: KeyboardEvent) => { // Check for Command+Enter (Mac) or Ctrl+Enter (Windows/Linux) - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); if (currentPath && !loading) { handleSelect(); @@ -260,8 +251,8 @@ export function FileBrowserDialog({ } }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); }, [open, currentPath, loading, handleSelect]); // Helper to get folder name from path @@ -326,9 +317,7 @@ export function FileBrowserDialog({ title={folder} > - - {getFolderName(folder)} - + {getFolderName(folder)} ))}
      • @@ -388,7 +375,7 @@ export function FileBrowserDialog({ )}
        - {currentPath || "Loading..."} + {currentPath || 'Loading...'}
      @@ -396,9 +383,7 @@ export function FileBrowserDialog({
      {loading && (
      -
      - Loading directories... -
      +
      Loading directories...
      )} @@ -416,9 +401,7 @@ export function FileBrowserDialog({ {!loading && !error && !warning && directories.length === 0 && (
      -
      - No subdirectories found -
      +
      No subdirectories found
      )} @@ -440,8 +423,8 @@ export function FileBrowserDialog({
      - Paste a full path above, or click on folders to navigate. Press - Enter or click Go to jump to a path. + Paste a full path above, or click on folders to navigate. Press Enter or click Go to + jump to a path.
      @@ -458,10 +441,9 @@ export function FileBrowserDialog({ Select Current Folder - {typeof navigator !== "undefined" && - navigator.platform?.includes("Mac") - ? "⌘" - : "Ctrl"} + {typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') + ? '⌘' + : 'Ctrl'} +↵ diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index e59c6744..16b1e5cb 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -155,12 +155,6 @@ export function Sidebar() { setNewProjectPath, }); - // Handle bug report button click - const handleBugReportClick = useCallback(() => { - const api = getElectronAPI(); - api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); - }, []); - /** * Opens the system folder selection dialog and initializes the selected project. * Used by both the 'O' keyboard shortcut and the folder icon button. @@ -273,11 +267,7 @@ export function Sidebar() { />
      - + {/* Project Actions - Moved above project selector */} {sidebarOpen && ( diff --git a/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx b/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx index 68a413c4..8139dc55 100644 --- a/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx +++ b/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx @@ -1,11 +1,21 @@ import { Bug } from 'lucide-react'; import { cn } from '@/lib/utils'; -import type { BugReportButtonProps } from '../types'; +import { useCallback } from 'react'; +import { getElectronAPI } from '@/lib/electron'; + +interface BugReportButtonProps { + sidebarExpanded: boolean; +} + +export function BugReportButton({ sidebarExpanded }: BugReportButtonProps) { + const handleBugReportClick = useCallback(() => { + const api = getElectronAPI(); + api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); + }, []); -export function BugReportButton({ sidebarExpanded, onClick }: BugReportButtonProps) { return (
      {/* Bug Report Button - Collapsed sidebar version */} {!sidebarOpen && (
      - +
      )} diff --git a/apps/ui/src/components/layout/sidebar/types.ts b/apps/ui/src/components/layout/sidebar/types.ts index e76e4917..4d9ecc35 100644 --- a/apps/ui/src/components/layout/sidebar/types.ts +++ b/apps/ui/src/components/layout/sidebar/types.ts @@ -1,4 +1,5 @@ import type { Project } from '@/lib/electron'; +import type React from 'react'; export interface NavSection { label?: string; @@ -29,8 +30,3 @@ export interface ThemeMenuItemProps { onPreviewEnter: (value: string) => void; onPreviewLeave: (e: React.PointerEvent) => void; } - -export interface BugReportButtonProps { - sidebarExpanded: boolean; - onClick: () => void; -} diff --git a/apps/ui/src/components/ui/accordion.tsx b/apps/ui/src/components/ui/accordion.tsx index 0c8b6101..3cb256b3 100644 --- a/apps/ui/src/components/ui/accordion.tsx +++ b/apps/ui/src/components/ui/accordion.tsx @@ -1,9 +1,10 @@ +/* eslint-disable @typescript-eslint/no-empty-object-type */ -import * as React from "react"; -import { ChevronDown } from "lucide-react"; -import { cn } from "@/lib/utils"; +import * as React from 'react'; +import { ChevronDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; -type AccordionType = "single" | "multiple"; +type AccordionType = 'single' | 'multiple'; interface AccordionContextValue { type: AccordionType; @@ -12,12 +13,10 @@ interface AccordionContextValue { collapsible?: boolean; } -const AccordionContext = React.createContext( - null -); +const AccordionContext = React.createContext(null); interface AccordionProps extends React.HTMLAttributes { - type?: "single" | "multiple"; + type?: 'single' | 'multiple'; value?: string | string[]; defaultValue?: string | string[]; onValueChange?: (value: string | string[]) => void; @@ -27,7 +26,7 @@ interface AccordionProps extends React.HTMLAttributes { const Accordion = React.forwardRef( ( { - type = "single", + type = 'single', value, defaultValue, onValueChange, @@ -38,13 +37,11 @@ const Accordion = React.forwardRef( }, ref ) => { - const [internalValue, setInternalValue] = React.useState( - () => { - if (value !== undefined) return value; - if (defaultValue !== undefined) return defaultValue; - return type === "single" ? "" : []; - } - ); + const [internalValue, setInternalValue] = React.useState(() => { + if (value !== undefined) return value; + if (defaultValue !== undefined) return defaultValue; + return type === 'single' ? '' : []; + }); const currentValue = value !== undefined ? value : internalValue; @@ -52,9 +49,9 @@ const Accordion = React.forwardRef( (itemValue: string) => { let newValue: string | string[]; - if (type === "single") { + if (type === 'single') { if (currentValue === itemValue && collapsible) { - newValue = ""; + newValue = ''; } else if (currentValue === itemValue && !collapsible) { return; } else { @@ -91,27 +88,21 @@ const Accordion = React.forwardRef( return ( -
      +
      {children}
      ); } ); -Accordion.displayName = "Accordion"; +Accordion.displayName = 'Accordion'; interface AccordionItemContextValue { value: string; isOpen: boolean; } -const AccordionItemContext = - React.createContext(null); +const AccordionItemContext = React.createContext(null); interface AccordionItemProps extends React.HTMLAttributes { value: string; @@ -122,25 +113,22 @@ const AccordionItem = React.forwardRef( const accordionContext = React.useContext(AccordionContext); if (!accordionContext) { - throw new Error("AccordionItem must be used within an Accordion"); + throw new Error('AccordionItem must be used within an Accordion'); } const isOpen = Array.isArray(accordionContext.value) ? accordionContext.value.includes(value) : accordionContext.value === value; - const contextValue = React.useMemo( - () => ({ value, isOpen }), - [value, isOpen] - ); + const contextValue = React.useMemo(() => ({ value, isOpen }), [value, isOpen]); return (
      {children} @@ -149,47 +137,45 @@ const AccordionItem = React.forwardRef( ); } ); -AccordionItem.displayName = "AccordionItem"; +AccordionItem.displayName = 'AccordionItem'; -interface AccordionTriggerProps - extends React.ButtonHTMLAttributes {} +interface AccordionTriggerProps extends React.ButtonHTMLAttributes {} -const AccordionTrigger = React.forwardRef< - HTMLButtonElement, - AccordionTriggerProps ->(({ className, children, ...props }, ref) => { - const accordionContext = React.useContext(AccordionContext); - const itemContext = React.useContext(AccordionItemContext); +const AccordionTrigger = React.forwardRef( + ({ className, children, ...props }, ref) => { + const accordionContext = React.useContext(AccordionContext); + const itemContext = React.useContext(AccordionItemContext); - if (!accordionContext || !itemContext) { - throw new Error("AccordionTrigger must be used within an AccordionItem"); + if (!accordionContext || !itemContext) { + throw new Error('AccordionTrigger must be used within an AccordionItem'); + } + + const { onValueChange } = accordionContext; + const { value, isOpen } = itemContext; + + return ( +
      + +
      + ); } - - const { onValueChange } = accordionContext; - const { value, isOpen } = itemContext; - - return ( -
      - -
      - ); -}); -AccordionTrigger.displayName = "AccordionTrigger"; +); +AccordionTrigger.displayName = 'AccordionTrigger'; interface AccordionContentProps extends React.HTMLAttributes {} @@ -200,7 +186,7 @@ const AccordionContent = React.forwardRef const [height, setHeight] = React.useState(undefined); if (!itemContext) { - throw new Error("AccordionContent must be used within an AccordionItem"); + throw new Error('AccordionContent must be used within an AccordionItem'); } const { isOpen } = itemContext; @@ -220,16 +206,16 @@ const AccordionContent = React.forwardRef return (
      -
      +
      {children}
      @@ -237,6 +223,6 @@ const AccordionContent = React.forwardRef ); } ); -AccordionContent.displayName = "AccordionContent"; +AccordionContent.displayName = 'AccordionContent'; export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/apps/ui/src/components/ui/description-image-dropzone.tsx b/apps/ui/src/components/ui/description-image-dropzone.tsx index af3f9019..7020ca75 100644 --- a/apps/ui/src/components/ui/description-image-dropzone.tsx +++ b/apps/ui/src/components/ui/description-image-dropzone.tsx @@ -1,10 +1,9 @@ - -import React, { useState, useRef, useCallback } from "react"; -import { cn } from "@/lib/utils"; -import { ImageIcon, X, Loader2 } from "lucide-react"; -import { Textarea } from "@/components/ui/textarea"; -import { getElectronAPI } from "@/lib/electron"; -import { useAppStore, type FeatureImagePath } from "@/store/app-store"; +import React, { useState, useRef, useCallback } from 'react'; +import { cn } from '@/lib/utils'; +import { ImageIcon, X, Loader2 } from 'lucide-react'; +import { Textarea } from '@/components/ui/textarea'; +import { getElectronAPI } from '@/lib/electron'; +import { useAppStore, type FeatureImagePath } from '@/store/app-store'; // Map to store preview data by image ID (persisted across component re-mounts) export type ImagePreviewMap = Map; @@ -26,13 +25,7 @@ interface DescriptionImageDropZoneProps { error?: boolean; // Show error state with red border } -const ACCEPTED_IMAGE_TYPES = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/webp", -]; +const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB export function DescriptionImageDropZone({ @@ -40,7 +33,7 @@ export function DescriptionImageDropZone({ onChange, images, onImagesChange, - placeholder = "Describe the feature...", + placeholder = 'Describe the feature...', className, disabled = false, maxFiles = 5, @@ -59,71 +52,76 @@ export function DescriptionImageDropZone({ // Determine which preview map to use - prefer parent-controlled state const previewImages = previewMap !== undefined ? previewMap : localPreviewImages; - const setPreviewImages = useCallback((updater: Map | ((prev: Map) => Map)) => { - if (onPreviewMapChange) { - const currentMap = previewMap !== undefined ? previewMap : localPreviewImages; - const newMap = typeof updater === 'function' ? updater(currentMap) : updater; - onPreviewMapChange(newMap); - } else { - setLocalPreviewImages((prev) => { - const newMap = typeof updater === 'function' ? updater(prev) : updater; - return newMap; - }); - } - }, [onPreviewMapChange, previewMap, localPreviewImages]); + const setPreviewImages = useCallback( + (updater: Map | ((prev: Map) => Map)) => { + if (onPreviewMapChange) { + const currentMap = previewMap !== undefined ? previewMap : localPreviewImages; + const newMap = typeof updater === 'function' ? updater(currentMap) : updater; + onPreviewMapChange(newMap); + } else { + setLocalPreviewImages((prev) => { + const newMap = typeof updater === 'function' ? updater(prev) : updater; + return newMap; + }); + } + }, + [onPreviewMapChange, previewMap, localPreviewImages] + ); const fileInputRef = useRef(null); const currentProject = useAppStore((state) => state.currentProject); // Construct server URL for loading saved images - const getImageServerUrl = useCallback((imagePath: string): string => { - const serverUrl = import.meta.env.VITE_SERVER_URL || "http://localhost:3008"; - const projectPath = currentProject?.path || ""; - return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`; - }, [currentProject?.path]); + const getImageServerUrl = useCallback( + (imagePath: string): string => { + const serverUrl = import.meta.env.VITE_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 => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { - if (typeof reader.result === "string") { + if (typeof reader.result === 'string') { resolve(reader.result); } else { - reject(new Error("Failed to read file as base64")); + reject(new Error('Failed to read file as base64')); } }; - reader.onerror = () => reject(new Error("Failed to read file")); + reader.onerror = () => reject(new Error('Failed to read file')); reader.readAsDataURL(file); }); }; - const saveImageToTemp = useCallback(async ( - base64Data: string, - filename: string, - mimeType: string - ): Promise => { - try { - const api = getElectronAPI(); - // Check if saveImageToTemp method exists - if (!api.saveImageToTemp) { - // Fallback path when saveImageToTemp is not available - console.log("[DescriptionImageDropZone] Using fallback path for image"); - return `.automaker/images/${Date.now()}_${filename}`; - } + const saveImageToTemp = useCallback( + async (base64Data: string, filename: string, mimeType: string): Promise => { + try { + const api = getElectronAPI(); + // Check if saveImageToTemp method exists + if (!api.saveImageToTemp) { + // Fallback path when saveImageToTemp is not available + console.log('[DescriptionImageDropZone] Using fallback path for image'); + return `.automaker/images/${Date.now()}_${filename}`; + } - // Get projectPath from the store if available - const projectPath = currentProject?.path; - const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath); - if (result.success && result.path) { - return result.path; + // Get projectPath from the store if available + const projectPath = currentProject?.path; + const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath); + if (result.success && result.path) { + return result.path; + } + console.error('[DescriptionImageDropZone] Failed to save image:', result.error); + return null; + } catch (error) { + console.error('[DescriptionImageDropZone] Error saving image:', error); + return null; } - console.error("[DescriptionImageDropZone] Failed to save image:", result.error); - return null; - } catch (error) { - console.error("[DescriptionImageDropZone] Error saving image:", error); - return null; - } - }, [currentProject?.path]); + }, + [currentProject?.path] + ); const processFiles = useCallback( async (files: FileList) => { @@ -137,18 +135,14 @@ export function DescriptionImageDropZone({ for (const file of Array.from(files)) { // Validate file type if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - errors.push( - `${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.` - ); + errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); continue; } // Validate file size if (file.size > maxFileSize) { const maxSizeMB = maxFileSize / (1024 * 1024); - errors.push( - `${file.name}: File too large. Maximum size is ${maxSizeMB}MB.` - ); + errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); continue; } @@ -176,13 +170,13 @@ export function DescriptionImageDropZone({ } else { errors.push(`${file.name}: Failed to save image.`); } - } catch (error) { + } catch { errors.push(`${file.name}: Failed to process image.`); } } if (errors.length > 0) { - console.warn("Image upload errors:", errors); + console.warn('Image upload errors:', errors); } if (newImages.length > 0) { @@ -192,7 +186,16 @@ export function DescriptionImageDropZone({ setIsProcessing(false); }, - [disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages, saveImageToTemp] + [ + disabled, + isProcessing, + images, + maxFiles, + maxFileSize, + onImagesChange, + previewImages, + saveImageToTemp, + ] ); const handleDrop = useCallback( @@ -236,7 +239,7 @@ export function DescriptionImageDropZone({ } // Reset the input so the same file can be selected again if (fileInputRef.current) { - fileInputRef.current.value = ""; + fileInputRef.current.value = ''; } }, [processFiles] @@ -276,17 +279,15 @@ export function DescriptionImageDropZone({ const item = clipboardItems[i]; // Check if the item is an image - if (item.type.startsWith("image/")) { + if (item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) { // Generate a filename for pasted images since they don't have one - const extension = item.type.split("/")[1] || "png"; - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const renamedFile = new File( - [file], - `pasted-image-${timestamp}.${extension}`, - { type: file.type } - ); + const extension = item.type.split('/')[1] || 'png'; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const renamedFile = new File([file], `pasted-image-${timestamp}.${extension}`, { + type: file.type, + }); imageFiles.push(renamedFile); } } @@ -307,13 +308,13 @@ export function DescriptionImageDropZone({ ); return ( -
      +
      {/* Hidden file input */} {/* Drag overlay */} {isDragOver && !disabled && ( @@ -355,17 +352,14 @@ export function DescriptionImageDropZone({ disabled={disabled} autoFocus={autoFocus} aria-invalid={error} - className={cn( - "min-h-[120px]", - isProcessing && "opacity-50 pointer-events-none" - )} + className={cn('min-h-[120px]', isProcessing && 'opacity-50 pointer-events-none')} data-testid="feature-description-input" />
      {/* Hint text */}

      - Paste, drag and drop images, or{" "} + Paste, drag and drop images, or{' '} {" "} + {' '} to attach context images

      @@ -390,7 +384,7 @@ export function DescriptionImageDropZone({

      - {images.length} image{images.length > 1 ? "s" : ""} attached + {images.length} image{images.length > 1 ? 's' : ''} attached

      ))} diff --git a/apps/ui/src/components/ui/feature-image-upload.tsx b/apps/ui/src/components/ui/feature-image-upload.tsx index a16dfcb6..0cb5403c 100644 --- a/apps/ui/src/components/ui/feature-image-upload.tsx +++ b/apps/ui/src/components/ui/feature-image-upload.tsx @@ -1,7 +1,6 @@ - -import React, { useState, useRef, useCallback } from "react"; -import { cn } from "@/lib/utils"; -import { ImageIcon, X, Upload } from "lucide-react"; +import React, { useState, useRef, useCallback } from 'react'; +import { cn } from '@/lib/utils'; +import { ImageIcon, X, Upload } from 'lucide-react'; export interface FeatureImage { id: string; @@ -20,13 +19,7 @@ interface FeatureImageUploadProps { disabled?: boolean; } -const ACCEPTED_IMAGE_TYPES = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/webp", -]; +const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB export function FeatureImageUpload({ @@ -45,13 +38,13 @@ export function FeatureImageUpload({ return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { - if (typeof reader.result === "string") { + if (typeof reader.result === 'string') { resolve(reader.result); } else { - reject(new Error("Failed to read file as base64")); + reject(new Error('Failed to read file as base64')); } }; - reader.onerror = () => reject(new Error("Failed to read file")); + reader.onerror = () => reject(new Error('Failed to read file')); reader.readAsDataURL(file); }); }; @@ -67,18 +60,14 @@ export function FeatureImageUpload({ for (const file of Array.from(files)) { // Validate file type if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - errors.push( - `${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.` - ); + errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); continue; } // Validate file size if (file.size > maxFileSize) { const maxSizeMB = maxFileSize / (1024 * 1024); - errors.push( - `${file.name}: File too large. Maximum size is ${maxSizeMB}MB.` - ); + errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); continue; } @@ -98,13 +87,13 @@ export function FeatureImageUpload({ size: file.size, }; newImages.push(imageAttachment); - } catch (error) { + } catch { errors.push(`${file.name}: Failed to process image.`); } } if (errors.length > 0) { - console.warn("Image upload errors:", errors); + console.warn('Image upload errors:', errors); } if (newImages.length > 0) { @@ -157,7 +146,7 @@ export function FeatureImageUpload({ } // Reset the input so the same file can be selected again if (fileInputRef.current) { - fileInputRef.current.value = ""; + fileInputRef.current.value = ''; } }, [processFiles] @@ -180,22 +169,14 @@ export function FeatureImageUpload({ onImagesChange([]); }, [onImagesChange]); - const formatFileSize = (bytes: number): string => { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; - }; - return ( -
      +
      {/* Hidden file input */}
      {isProcessing ? ( @@ -237,13 +215,10 @@ export function FeatureImageUpload({ )}

      - {isDragOver && !disabled - ? "Drop images here" - : "Click or drag images here"} + {isDragOver && !disabled ? 'Drop images here' : 'Click or drag images here'}

      - Up to {maxFiles} images, max{" "} - {Math.round(maxFileSize / (1024 * 1024))}MB each + Up to {maxFiles} images, max {Math.round(maxFileSize / (1024 * 1024))}MB each

      @@ -253,7 +228,7 @@ export function FeatureImageUpload({

      - {images.length} image{images.length > 1 ? "s" : ""} selected + {images.length} image{images.length > 1 ? 's' : ''} selected

      ))} diff --git a/apps/ui/src/components/ui/image-drop-zone.tsx b/apps/ui/src/components/ui/image-drop-zone.tsx index 5494bdc3..04e53491 100644 --- a/apps/ui/src/components/ui/image-drop-zone.tsx +++ b/apps/ui/src/components/ui/image-drop-zone.tsx @@ -1,8 +1,7 @@ - -import React, { useState, useRef, useCallback } from "react"; -import { cn } from "@/lib/utils"; -import { ImageIcon, X, Upload } from "lucide-react"; -import type { ImageAttachment } from "@/store/app-store"; +import React, { useState, useRef, useCallback } from 'react'; +import { cn } from '@/lib/utils'; +import { ImageIcon, X, Upload } from 'lucide-react'; +import type { ImageAttachment } from '@/store/app-store'; interface ImageDropZoneProps { onImagesSelected: (images: ImageAttachment[]) => void; @@ -35,88 +34,100 @@ export function ImageDropZone({ const selectedImages = images ?? internalImages; // Update images - for controlled mode, just call the callback; for uncontrolled, also update internal state - const updateImages = useCallback((newImages: ImageAttachment[]) => { - if (images === undefined) { - setInternalImages(newImages); - } - onImagesSelected(newImages); - }, [images, onImagesSelected]); + const updateImages = useCallback( + (newImages: ImageAttachment[]) => { + if (images === undefined) { + setInternalImages(newImages); + } + onImagesSelected(newImages); + }, + [images, onImagesSelected] + ); - const processFiles = useCallback(async (files: FileList) => { - if (disabled || isProcessing) return; + const processFiles = useCallback( + async (files: FileList) => { + if (disabled || isProcessing) return; - setIsProcessing(true); - const newImages: ImageAttachment[] = []; - const errors: string[] = []; + setIsProcessing(true); + const newImages: ImageAttachment[] = []; + const errors: string[] = []; - for (const file of Array.from(files)) { - // Validate file type - if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); - continue; + for (const file of Array.from(files)) { + // Validate file type + if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { + errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); + continue; + } + + // Validate file size + if (file.size > maxFileSize) { + const maxSizeMB = maxFileSize / (1024 * 1024); + errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); + continue; + } + + // Check if we've reached max files + if (newImages.length + selectedImages.length >= maxFiles) { + errors.push(`Maximum ${maxFiles} images allowed.`); + break; + } + + try { + const base64 = await fileToBase64(file); + const imageAttachment: ImageAttachment = { + id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + data: base64, + mimeType: file.type, + filename: file.name, + size: file.size, + }; + newImages.push(imageAttachment); + } catch { + errors.push(`${file.name}: Failed to process image.`); + } } - // Validate file size - if (file.size > maxFileSize) { - const maxSizeMB = maxFileSize / (1024 * 1024); - errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); - continue; + if (errors.length > 0) { + console.warn('Image upload errors:', errors); + // You could show these errors to the user via a toast or notification } - // Check if we've reached max files - if (newImages.length + selectedImages.length >= maxFiles) { - errors.push(`Maximum ${maxFiles} images allowed.`); - break; + if (newImages.length > 0) { + const allImages = [...selectedImages, ...newImages]; + updateImages(allImages); } - try { - const base64 = await fileToBase64(file); - const imageAttachment: ImageAttachment = { - id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - data: base64, - mimeType: file.type, - filename: file.name, - size: file.size, - }; - newImages.push(imageAttachment); - } catch (error) { - errors.push(`${file.name}: Failed to process image.`); + setIsProcessing(false); + }, + [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, updateImages] + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + if (disabled) return; + + const files = e.dataTransfer.files; + if (files.length > 0) { + processFiles(files); } - } + }, + [disabled, processFiles] + ); - if (errors.length > 0) { - console.warn('Image upload errors:', errors); - // You could show these errors to the user via a toast or notification - } - - if (newImages.length > 0) { - const allImages = [...selectedImages, ...newImages]; - updateImages(allImages); - } - - setIsProcessing(false); - }, [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, updateImages]); - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - - if (disabled) return; - - const files = e.dataTransfer.files; - if (files.length > 0) { - processFiles(files); - } - }, [disabled, processFiles]); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!disabled) { - setIsDragOver(true); - } - }, [disabled]); + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!disabled) { + setIsDragOver(true); + } + }, + [disabled] + ); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -124,16 +135,19 @@ export function ImageDropZone({ setIsDragOver(false); }, []); - const handleFileSelect = useCallback((e: React.ChangeEvent) => { - const files = e.target.files; - if (files && files.length > 0) { - processFiles(files); - } - // Reset the input so the same file can be selected again - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }, [processFiles]); + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + processFiles(files); + } + // Reset the input so the same file can be selected again + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, + [processFiles] + ); const handleBrowseClick = useCallback(() => { if (!disabled && fileInputRef.current) { @@ -141,17 +155,20 @@ export function ImageDropZone({ } }, [disabled]); - const removeImage = useCallback((imageId: string) => { - const updated = selectedImages.filter(img => img.id !== imageId); - updateImages(updated); - }, [selectedImages, updateImages]); + const removeImage = useCallback( + (imageId: string) => { + const updated = selectedImages.filter((img) => img.id !== imageId); + updateImages(updated); + }, + [selectedImages, updateImages] + ); const clearAllImages = useCallback(() => { updateImages([]); }, [updateImages]); return ( -
      +
      {/* Hidden file input */} {children || (
      -
      +
      {isProcessing ? ( ) : ( @@ -191,10 +208,13 @@ export function ImageDropZone({ )}

      - {isDragOver && !disabled ? "Drop your images here" : "Drag images here or click to browse"} + {isDragOver && !disabled + ? 'Drop your images here' + : 'Drag images here or click to browse'}

      - {maxFiles > 1 ? `Up to ${maxFiles} images` : "1 image"}, max {Math.round(maxFileSize / (1024 * 1024))}MB each + {maxFiles > 1 ? `Up to ${maxFiles} images` : '1 image'}, max{' '} + {Math.round(maxFileSize / (1024 * 1024))}MB each

      {!disabled && (