diff --git a/.gitignore b/.gitignore index 710cc052..7787ba75 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,9 @@ out/ /.automaker/* /.automaker/ -/logs +.worktrees/ +/logs # Logs logs/ *.log diff --git a/apps/app/playwright.config.ts b/apps/app/playwright.config.ts index d4d0f866..26f06499 100644 --- a/apps/app/playwright.config.ts +++ b/apps/app/playwright.config.ts @@ -1,7 +1,9 @@ import { defineConfig, devices } from "@playwright/test"; const port = process.env.TEST_PORT || 3007; +const serverPort = process.env.TEST_SERVER_PORT || 3008; const reuseServer = process.env.TEST_REUSE_SERVER === "true"; +const mockAgent = process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true"; export default defineConfig({ testDir: "./tests", @@ -25,15 +27,33 @@ export default defineConfig({ ...(reuseServer ? {} : { - webServer: { - command: `npx next dev -p ${port}`, - url: `http://localhost:${port}`, - reuseExistingServer: !process.env.CI, - timeout: 120000, - env: { - ...process.env, - NEXT_PUBLIC_SKIP_SETUP: "true", + webServer: [ + // Backend server - runs with mock agent enabled in CI + { + command: `cd ../server && npm run dev`, + url: `http://localhost:${serverPort}/api/health`, + reuseExistingServer: true, + timeout: 60000, + env: { + ...process.env, + PORT: String(serverPort), + // Enable mock agent in CI to avoid real API calls + AUTOMAKER_MOCK_AGENT: mockAgent ? "true" : "false", + // Allow access to test directories and common project paths + ALLOWED_PROJECT_DIRS: "/Users,/home,/tmp,/var/folders", + }, }, - }, + // Frontend Next.js server + { + command: `npx next dev -p ${port}`, + url: `http://localhost:${port}`, + reuseExistingServer: true, + timeout: 120000, + env: { + ...process.env, + NEXT_PUBLIC_SKIP_SETUP: "true", + }, + }, + ], }), }); diff --git a/apps/app/public/sounds/ding.mp3 b/apps/app/public/sounds/ding.mp3 index 0b2b0445..660e5819 100644 Binary files a/apps/app/public/sounds/ding.mp3 and b/apps/app/public/sounds/ding.mp3 differ diff --git a/apps/app/src/app/globals.css b/apps/app/src/app/globals.css index 1f791058..6dc31c53 100644 --- a/apps/app/src/app/globals.css +++ b/apps/app/src/app/globals.css @@ -13,6 +13,9 @@ @custom-variant onedark (&:is(.onedark *)); @custom-variant synthwave (&:is(.synthwave *)); @custom-variant red (&:is(.red *)); +@custom-variant cream (&:is(.cream *)); +@custom-variant sunset (&:is(.sunset *)); +@custom-variant gray (&:is(.gray *)); @theme inline { --color-background: var(--background); @@ -1220,6 +1223,252 @@ --running-indicator-text: oklch(0.6 0.23 25); } +.cream { + /* Cream Theme - Warm, soft, easy on the eyes */ + --background: oklch(0.95 0.01 70); /* Warm cream background */ + --background-50: oklch(0.95 0.01 70 / 0.5); + --background-80: oklch(0.95 0.01 70 / 0.8); + + --foreground: oklch(0.25 0.02 60); /* Dark warm brown */ + --foreground-secondary: oklch(0.45 0.02 60); /* Medium brown */ + --foreground-muted: oklch(0.55 0.02 60); /* Light brown */ + + --card: oklch(0.98 0.005 70); /* Slightly lighter cream */ + --card-foreground: oklch(0.25 0.02 60); + --popover: oklch(0.97 0.008 70); + --popover-foreground: oklch(0.25 0.02 60); + + --primary: oklch(0.5 0.12 45); /* Warm terracotta/rust */ + --primary-foreground: oklch(0.98 0.005 70); + + --brand-400: oklch(0.55 0.12 45); + --brand-500: oklch(0.5 0.12 45); /* Terracotta */ + --brand-600: oklch(0.45 0.13 45); + + --secondary: oklch(0.88 0.02 70); + --secondary-foreground: oklch(0.25 0.02 60); + + --muted: oklch(0.9 0.015 70); + --muted-foreground: oklch(0.45 0.02 60); + + --accent: oklch(0.85 0.025 70); + --accent-foreground: oklch(0.25 0.02 60); + + --destructive: oklch(0.55 0.22 25); /* Warm red */ + + --border: oklch(0.85 0.015 70); + --border-glass: oklch(0.5 0.12 45 / 0.2); + + --input: oklch(0.98 0.005 70); + --ring: oklch(0.5 0.12 45); + + --chart-1: oklch(0.5 0.12 45); /* Terracotta */ + --chart-2: oklch(0.55 0.15 35); /* Burnt orange */ + --chart-3: oklch(0.6 0.12 100); /* Olive */ + --chart-4: oklch(0.5 0.15 20); /* Deep rust */ + --chart-5: oklch(0.65 0.1 80); /* Golden */ + + --sidebar: oklch(0.93 0.012 70); + --sidebar-foreground: oklch(0.25 0.02 60); + --sidebar-primary: oklch(0.5 0.12 45); + --sidebar-primary-foreground: oklch(0.98 0.005 70); + --sidebar-accent: oklch(0.88 0.02 70); + --sidebar-accent-foreground: oklch(0.25 0.02 60); + --sidebar-border: oklch(0.85 0.015 70); + --sidebar-ring: oklch(0.5 0.12 45); + + /* Action button colors - Warm earth tones */ + --action-view: oklch(0.5 0.12 45); /* Terracotta */ + --action-view-hover: oklch(0.45 0.13 45); + --action-followup: oklch(0.55 0.15 35); /* Burnt orange */ + --action-followup-hover: oklch(0.5 0.16 35); + --action-commit: oklch(0.55 0.12 130); /* Sage green */ + --action-commit-hover: oklch(0.5 0.13 130); + --action-verify: oklch(0.55 0.12 130); /* Sage green */ + --action-verify-hover: oklch(0.5 0.13 130); + + /* Running indicator - Terracotta */ + --running-indicator: oklch(0.5 0.12 45); + --running-indicator-text: oklch(0.55 0.12 45); + + /* Status colors - Cream theme */ + --status-success: oklch(0.55 0.15 130); + --status-success-bg: oklch(0.55 0.15 130 / 0.15); + --status-warning: oklch(0.6 0.15 70); + --status-warning-bg: oklch(0.6 0.15 70 / 0.15); + --status-error: oklch(0.55 0.22 25); + --status-error-bg: oklch(0.55 0.22 25 / 0.15); + --status-info: oklch(0.5 0.15 230); + --status-info-bg: oklch(0.5 0.15 230 / 0.15); + --status-backlog: oklch(0.6 0.02 60); + --status-in-progress: oklch(0.6 0.15 70); + --status-waiting: oklch(0.58 0.13 50); +} + +.sunset { + /* Sunset Theme - Mellow oranges and soft purples */ + --background: oklch(0.15 0.02 280); /* Deep twilight blue-purple */ + --background-50: oklch(0.15 0.02 280 / 0.5); + --background-80: oklch(0.15 0.02 280 / 0.8); + + --foreground: oklch(0.95 0.01 80); /* Warm white */ + --foreground-secondary: oklch(0.75 0.02 60); + --foreground-muted: oklch(0.6 0.02 60); + + --card: oklch(0.2 0.025 280); + --card-foreground: oklch(0.95 0.01 80); + --popover: oklch(0.18 0.02 280); + --popover-foreground: oklch(0.95 0.01 80); + + --primary: oklch(0.68 0.18 45); /* Mellow sunset orange */ + --primary-foreground: oklch(0.15 0.02 280); + + --brand-400: oklch(0.72 0.17 45); + --brand-500: oklch(0.68 0.18 45); /* Soft sunset orange */ + --brand-600: oklch(0.64 0.19 42); + + --secondary: oklch(0.25 0.03 280); + --secondary-foreground: oklch(0.95 0.01 80); + + --muted: oklch(0.27 0.03 280); + --muted-foreground: oklch(0.6 0.02 60); + + --accent: oklch(0.35 0.04 310); + --accent-foreground: oklch(0.95 0.01 80); + + --destructive: oklch(0.6 0.2 25); /* Muted red */ + + --border: oklch(0.32 0.04 280); + --border-glass: oklch(0.68 0.18 45 / 0.3); + + --input: oklch(0.2 0.025 280); + --ring: oklch(0.68 0.18 45); + + --chart-1: oklch(0.68 0.18 45); /* Mellow orange */ + --chart-2: oklch(0.75 0.16 340); /* Soft pink sunset */ + --chart-3: oklch(0.78 0.18 70); /* Soft golden */ + --chart-4: oklch(0.66 0.19 42); /* Subtle coral */ + --chart-5: oklch(0.72 0.14 310); /* Pastel purple */ + + --sidebar: oklch(0.13 0.015 280); + --sidebar-foreground: oklch(0.95 0.01 80); + --sidebar-primary: oklch(0.68 0.18 45); + --sidebar-primary-foreground: oklch(0.15 0.02 280); + --sidebar-accent: oklch(0.25 0.03 280); + --sidebar-accent-foreground: oklch(0.95 0.01 80); + --sidebar-border: oklch(0.32 0.04 280); + --sidebar-ring: oklch(0.68 0.18 45); + + /* Action button colors - Mellow sunset palette */ + --action-view: oklch(0.68 0.18 45); /* Mellow orange */ + --action-view-hover: oklch(0.64 0.19 42); + --action-followup: oklch(0.75 0.16 340); /* Soft pink */ + --action-followup-hover: oklch(0.7 0.17 340); + --action-commit: oklch(0.65 0.16 140); /* Soft green */ + --action-commit-hover: oklch(0.6 0.17 140); + --action-verify: oklch(0.65 0.16 140); /* Soft green */ + --action-verify-hover: oklch(0.6 0.17 140); + + /* Running indicator - Mellow orange */ + --running-indicator: oklch(0.68 0.18 45); + --running-indicator-text: oklch(0.72 0.17 45); + + /* Status colors - Sunset theme */ + --status-success: oklch(0.65 0.16 140); + --status-success-bg: oklch(0.65 0.16 140 / 0.2); + --status-warning: oklch(0.78 0.18 70); + --status-warning-bg: oklch(0.78 0.18 70 / 0.2); + --status-error: oklch(0.65 0.2 25); + --status-error-bg: oklch(0.65 0.2 25 / 0.2); + --status-info: oklch(0.75 0.16 340); + --status-info-bg: oklch(0.75 0.16 340 / 0.2); + --status-backlog: oklch(0.65 0.02 280); + --status-in-progress: oklch(0.78 0.18 70); + --status-waiting: oklch(0.72 0.17 60); +} + +.gray { + /* Gray Theme - Modern, minimal gray scheme inspired by Cursor */ + --background: oklch(0.2 0.005 250); /* Medium-dark neutral gray */ + --background-50: oklch(0.2 0.005 250 / 0.5); + --background-80: oklch(0.2 0.005 250 / 0.8); + + --foreground: oklch(0.9 0.005 250); /* Light gray */ + --foreground-secondary: oklch(0.65 0.005 250); + --foreground-muted: oklch(0.5 0.005 250); + + --card: oklch(0.24 0.005 250); + --card-foreground: oklch(0.9 0.005 250); + --popover: oklch(0.22 0.005 250); + --popover-foreground: oklch(0.9 0.005 250); + + --primary: oklch(0.6 0.08 250); /* Subtle blue-gray */ + --primary-foreground: oklch(0.95 0.005 250); + + --brand-400: oklch(0.65 0.08 250); + --brand-500: oklch(0.6 0.08 250); /* Blue-gray */ + --brand-600: oklch(0.55 0.09 250); + + --secondary: oklch(0.28 0.005 250); + --secondary-foreground: oklch(0.9 0.005 250); + + --muted: oklch(0.3 0.005 250); + --muted-foreground: oklch(0.6 0.005 250); + + --accent: oklch(0.35 0.01 250); + --accent-foreground: oklch(0.9 0.005 250); + + --destructive: oklch(0.6 0.2 25); /* Muted red */ + + --border: oklch(0.32 0.005 250); + --border-glass: oklch(0.6 0.08 250 / 0.2); + + --input: oklch(0.24 0.005 250); + --ring: oklch(0.6 0.08 250); + + --chart-1: oklch(0.6 0.08 250); /* Blue-gray */ + --chart-2: oklch(0.65 0.1 210); /* Cyan */ + --chart-3: oklch(0.7 0.12 160); /* Teal */ + --chart-4: oklch(0.65 0.1 280); /* Purple */ + --chart-5: oklch(0.7 0.08 300); /* Violet */ + + --sidebar: oklch(0.18 0.005 250); + --sidebar-foreground: oklch(0.9 0.005 250); + --sidebar-primary: oklch(0.6 0.08 250); + --sidebar-primary-foreground: oklch(0.95 0.005 250); + --sidebar-accent: oklch(0.28 0.005 250); + --sidebar-accent-foreground: oklch(0.9 0.005 250); + --sidebar-border: oklch(0.32 0.005 250); + --sidebar-ring: oklch(0.6 0.08 250); + + /* Action button colors - Subtle modern colors */ + --action-view: oklch(0.6 0.08 250); /* Blue-gray */ + --action-view-hover: oklch(0.55 0.09 250); + --action-followup: oklch(0.65 0.1 210); /* Cyan */ + --action-followup-hover: oklch(0.6 0.11 210); + --action-commit: oklch(0.65 0.12 150); /* Teal-green */ + --action-commit-hover: oklch(0.6 0.13 150); + --action-verify: oklch(0.65 0.12 150); /* Teal-green */ + --action-verify-hover: oklch(0.6 0.13 150); + + /* Running indicator - Blue-gray */ + --running-indicator: oklch(0.6 0.08 250); + --running-indicator-text: oklch(0.65 0.08 250); + + /* Status colors - Gray theme */ + --status-success: oklch(0.65 0.12 150); + --status-success-bg: oklch(0.65 0.12 150 / 0.2); + --status-warning: oklch(0.7 0.15 70); + --status-warning-bg: oklch(0.7 0.15 70 / 0.2); + --status-error: oklch(0.6 0.2 25); + --status-error-bg: oklch(0.6 0.2 25 / 0.2); + --status-info: oklch(0.65 0.1 210); + --status-info-bg: oklch(0.65 0.1 210 / 0.2); + --status-backlog: oklch(0.6 0.005 250); + --status-in-progress: oklch(0.7 0.15 70); + --status-waiting: oklch(0.68 0.1 220); +} + @layer base { * { @apply border-border outline-ring/50; @@ -1255,12 +1504,12 @@ } /* Custom scrollbar for dark themes */ -:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red) ::-webkit-scrollbar { +:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red, .sunset, .gray) ::-webkit-scrollbar { width: 8px; height: 8px; } -:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red) ::-webkit-scrollbar-track { +:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red, .sunset, .gray) ::-webkit-scrollbar-track { background: var(--muted); } @@ -1296,6 +1545,62 @@ background: oklch(0.15 0.05 25); } +/* Cream theme scrollbar */ +.cream ::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.cream ::-webkit-scrollbar-thumb, +.cream .scrollbar-visible::-webkit-scrollbar-thumb { + background: oklch(0.7 0.03 60); + border-radius: 4px; +} + +.cream ::-webkit-scrollbar-thumb:hover, +.cream .scrollbar-visible::-webkit-scrollbar-thumb:hover { + background: oklch(0.6 0.04 60); +} + +.cream ::-webkit-scrollbar-track, +.cream .scrollbar-visible::-webkit-scrollbar-track { + background: oklch(0.9 0.015 70); +} + +/* Sunset theme scrollbar */ +.sunset ::-webkit-scrollbar-thumb, +.sunset .scrollbar-visible::-webkit-scrollbar-thumb { + background: oklch(0.5 0.14 45); + border-radius: 4px; +} + +.sunset ::-webkit-scrollbar-thumb:hover, +.sunset .scrollbar-visible::-webkit-scrollbar-thumb:hover { + background: oklch(0.58 0.16 45); +} + +.sunset ::-webkit-scrollbar-track, +.sunset .scrollbar-visible::-webkit-scrollbar-track { + background: oklch(0.18 0.03 280); +} + +/* Gray theme scrollbar */ +.gray ::-webkit-scrollbar-thumb, +.gray .scrollbar-visible::-webkit-scrollbar-thumb { + background: oklch(0.4 0.01 250); + border-radius: 4px; +} + +.gray ::-webkit-scrollbar-thumb:hover, +.gray .scrollbar-visible::-webkit-scrollbar-thumb:hover { + background: oklch(0.5 0.02 250); +} + +.gray ::-webkit-scrollbar-track, +.gray .scrollbar-visible::-webkit-scrollbar-track { + background: oklch(0.25 0.005 250); +} + /* Always visible scrollbar for file diffs and code blocks */ .scrollbar-visible { overflow-y: auto !important; diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 29a74578..0ac464f2 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -133,10 +133,10 @@ function HomeContent() { // Apply theme class to document (uses effective theme - preview, project-specific, or global) useEffect(() => { const root = document.documentElement; - root.classList.remove( + const themeClasses = [ "dark", - "retro", "light", + "retro", "dracula", "nord", "monokai", @@ -146,43 +146,22 @@ function HomeContent() { "catppuccin", "onedark", "synthwave", - "red" - ); + "red", + "cream", + "sunset", + "gray", + ]; - if (effectiveTheme === "dark") { - root.classList.add("dark"); - } else if (effectiveTheme === "retro") { - root.classList.add("retro"); - } else if (effectiveTheme === "dracula") { - root.classList.add("dracula"); - } else if (effectiveTheme === "nord") { - root.classList.add("nord"); - } else if (effectiveTheme === "monokai") { - root.classList.add("monokai"); - } else if (effectiveTheme === "tokyonight") { - root.classList.add("tokyonight"); - } else if (effectiveTheme === "solarized") { - root.classList.add("solarized"); - } else if (effectiveTheme === "gruvbox") { - root.classList.add("gruvbox"); - } else if (effectiveTheme === "catppuccin") { - root.classList.add("catppuccin"); - } else if (effectiveTheme === "onedark") { - root.classList.add("onedark"); - } else if (effectiveTheme === "synthwave") { - root.classList.add("synthwave"); - } else if (effectiveTheme === "red") { - root.classList.add("red"); - } else if (effectiveTheme === "light") { - root.classList.add("light"); + // Remove all theme classes + root.classList.remove(...themeClasses); + + // Apply the effective theme + if (themeClasses.includes(effectiveTheme)) { + root.classList.add(effectiveTheme); } else if (effectiveTheme === "system") { - // System theme + // System theme - detect OS preference const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - if (isDark) { - root.classList.add("dark"); - } else { - root.classList.add("light"); - } + root.classList.add(isDark ? "dark" : "light"); } }, [effectiveTheme, previewTheme, currentProject, theme]); diff --git a/apps/app/src/components/dialogs/file-browser-dialog.tsx b/apps/app/src/components/dialogs/file-browser-dialog.tsx index 351534d5..2276a718 100644 --- a/apps/app/src/components/dialogs/file-browser-dialog.tsx +++ b/apps/app/src/components/dialogs/file-browser-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { FolderOpen, Folder, @@ -9,6 +9,8 @@ import { ArrowLeft, HardDrive, CornerDownLeft, + Clock, + X, } from "lucide-react"; import { Dialog, @@ -45,6 +47,44 @@ interface FileBrowserDialogProps { initialPath?: string; } +const RECENT_FOLDERS_KEY = "file-browser-recent-folders"; +const MAX_RECENT_FOLDERS = 5; + +function getRecentFolders(): string[] { + if (typeof window === "undefined") return []; + try { + const stored = localStorage.getItem(RECENT_FOLDERS_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +} + +function addRecentFolder(path: string): void { + if (typeof window === "undefined") return; + try { + const recent = getRecentFolders(); + // Remove if already exists, then add to front + const filtered = recent.filter((p) => p !== path); + const updated = [path, ...filtered].slice(0, MAX_RECENT_FOLDERS); + localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated)); + } catch { + // Ignore localStorage errors + } +} + +function removeRecentFolder(path: string): string[] { + if (typeof window === "undefined") return []; + try { + const recent = getRecentFolders(); + const updated = recent.filter((p) => p !== path); + localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated)); + return updated; + } catch { + return []; + } +} + export function FileBrowserDialog({ open, onOpenChange, @@ -61,8 +101,26 @@ export function FileBrowserDialog({ const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [warning, setWarning] = useState(""); + const [recentFolders, setRecentFolders] = useState([]); const pathInputRef = useRef(null); + // Load recent folders when dialog opens + useEffect(() => { + if (open) { + setRecentFolders(getRecentFolders()); + } + }, [open]); + + const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => { + e.stopPropagation(); + const updated = removeRecentFolder(path); + setRecentFolders(updated); + }, []); + + const handleSelectRecent = useCallback((path: string) => { + browseDirectory(path); + }, []); + const browseDirectory = async (dirPath?: string) => { setLoading(true); setError(""); @@ -153,27 +211,34 @@ export function FileBrowserDialog({ const handleSelect = () => { if (currentPath) { + addRecentFolder(currentPath); onSelect(currentPath); onOpenChange(false); } }; + // Helper to get folder name from path + const getFolderName = (path: string) => { + const parts = path.split(/[/\\]/).filter(Boolean); + return parts[parts.length - 1] || path; + }; + return ( - - - - + + + + {title} - + {description} -
+
{/* Direct path input */} -
+
setPathInput(e.target.value)} onKeyDown={handlePathInputKeyDown} - className="flex-1 font-mono text-sm" + className="flex-1 font-mono text-xs h-8" data-testid="path-input" disabled={loading} /> @@ -191,16 +256,46 @@ export function FileBrowserDialog({ onClick={handleGoToPath} disabled={loading || !pathInput.trim()} data-testid="go-to-path-button" + className="h-8 px-2" > - + Go
+ {/* Recent folders */} + {recentFolders.length > 0 && ( +
+
+ + Recent: +
+ {recentFolders.map((folder) => ( + + + ))} +
+ )} + {/* Drives selector (Windows only) */} {drives.length > 0 && ( -
-
+
+
Drives:
@@ -212,7 +307,7 @@ export function FileBrowserDialog({ } size="sm" onClick={() => handleSelectDrive(drive)} - className="h-7 px-3 text-xs" + className="h-6 px-2 text-xs" disabled={loading} > {drive.replace("\\", "")} @@ -222,57 +317,57 @@ export function FileBrowserDialog({ )} {/* Current path breadcrumb */} -
+
{parentPath && ( )} -
+
{currentPath || "Loading..."}
{/* Directory list */} -
+
{loading && ( -
-
+
+
Loading directories...
)} {error && ( -
-
{error}
+
+
{error}
)} {warning && ( -
-
{warning}
+
+
{warning}
)} {!loading && !error && !warning && directories.length === 0 && ( -
-
+
+
No subdirectories found
@@ -284,29 +379,29 @@ export function FileBrowserDialog({ ))}
)}
-
+
Paste a full path above, or click on folders to navigate. Press Enter or click Go to jump to a path.
- - - diff --git a/apps/app/src/components/ui/autocomplete.tsx b/apps/app/src/components/ui/autocomplete.tsx new file mode 100644 index 00000000..23e094c6 --- /dev/null +++ b/apps/app/src/components/ui/autocomplete.tsx @@ -0,0 +1,223 @@ +"use client"; + +import * as React from "react"; +import { Check, ChevronsUpDown, LucideIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +export interface AutocompleteOption { + value: string; + label?: string; + badge?: string; + isDefault?: boolean; +} + +interface AutocompleteProps { + value: string; + onChange: (value: string) => void; + options: (string | AutocompleteOption)[]; + placeholder?: string; + searchPlaceholder?: string; + emptyMessage?: string; + className?: string; + disabled?: boolean; + icon?: LucideIcon; + allowCreate?: boolean; + createLabel?: (value: string) => string; + "data-testid"?: string; + itemTestIdPrefix?: string; +} + +function normalizeOption(opt: string | AutocompleteOption): AutocompleteOption { + if (typeof opt === "string") { + return { value: opt, label: opt }; + } + return { ...opt, label: opt.label ?? opt.value }; +} + +export function Autocomplete({ + value, + onChange, + options, + placeholder = "Select an option...", + searchPlaceholder = "Search...", + emptyMessage = "No results found.", + className, + disabled = false, + icon: Icon, + allowCreate = false, + createLabel = (v) => `Create "${v}"`, + "data-testid": testId, + itemTestIdPrefix = "option", +}: AutocompleteProps) { + const [open, setOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(""); + const [triggerWidth, setTriggerWidth] = React.useState(0); + const triggerRef = React.useRef(null); + + const normalizedOptions = React.useMemo( + () => options.map(normalizeOption), + [options] + ); + + // Update trigger width when component mounts or value changes + React.useEffect(() => { + if (triggerRef.current) { + const updateWidth = () => { + setTriggerWidth(triggerRef.current?.offsetWidth || 0); + }; + + updateWidth(); + + const resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(triggerRef.current); + + return () => { + resizeObserver.disconnect(); + }; + } + }, [value]); + + // Filter options based on input + const filteredOptions = React.useMemo(() => { + if (!inputValue) return normalizedOptions; + const lower = inputValue.toLowerCase(); + return normalizedOptions.filter( + (opt) => + opt.value.toLowerCase().includes(lower) || + opt.label?.toLowerCase().includes(lower) + ); + }, [normalizedOptions, inputValue]); + + // Check if user typed a new value that doesn't exist + const isNewValue = + allowCreate && + inputValue.trim() && + !normalizedOptions.some( + (opt) => opt.value.toLowerCase() === inputValue.toLowerCase() + ); + + // Get display value + const displayValue = React.useMemo(() => { + if (!value) return null; + const found = normalizedOptions.find((opt) => opt.value === value); + return found?.label ?? value; + }, [value, normalizedOptions]); + + return ( + + + + + + + + + + {isNewValue ? ( +
+ Press enter to create{" "} + {inputValue} +
+ ) : ( + emptyMessage + )} +
+ + {/* Show "Create new" option if typing a new value */} + {isNewValue && ( + { + onChange(inputValue); + setInputValue(""); + setOpen(false); + }} + className="text-[var(--status-success)]" + data-testid={`${itemTestIdPrefix}-create-new`} + > + {Icon && } + {createLabel(inputValue)} + + (new) + + + )} + {filteredOptions.map((option) => ( + { + onChange(currentValue === value ? "" : currentValue); + setInputValue(""); + setOpen(false); + }} + data-testid={`${itemTestIdPrefix}-${option.value.toLowerCase().replace(/[\s/\\]+/g, "-")}`} + > + {Icon && } + {option.label} + + {option.badge && ( + + ({option.badge}) + + )} + + ))} + +
+
+
+
+ ); +} diff --git a/apps/app/src/components/ui/branch-autocomplete.tsx b/apps/app/src/components/ui/branch-autocomplete.tsx new file mode 100644 index 00000000..60838354 --- /dev/null +++ b/apps/app/src/components/ui/branch-autocomplete.tsx @@ -0,0 +1,53 @@ +"use client"; + +import * as React from "react"; +import { GitBranch } from "lucide-react"; +import { Autocomplete, AutocompleteOption } from "@/components/ui/autocomplete"; + +interface BranchAutocompleteProps { + value: string; + onChange: (value: string) => void; + branches: string[]; + placeholder?: string; + className?: string; + disabled?: boolean; + "data-testid"?: string; +} + +export function BranchAutocomplete({ + value, + onChange, + branches, + placeholder = "Select a branch...", + className, + disabled = false, + "data-testid": testId, +}: BranchAutocompleteProps) { + // Always include "main" at the top of suggestions + const branchOptions: AutocompleteOption[] = React.useMemo(() => { + const branchSet = new Set(["main", ...branches]); + return Array.from(branchSet).map((branch) => ({ + value: branch, + label: branch, + badge: branch === "main" ? "default" : undefined, + })); + }, [branches]); + + return ( + `Create "${v}"`} + data-testid={testId} + itemTestIdPrefix="branch-option" + /> + ); +} diff --git a/apps/app/src/components/ui/category-autocomplete.tsx b/apps/app/src/components/ui/category-autocomplete.tsx index 8f4b0054..125a15b7 100644 --- a/apps/app/src/components/ui/category-autocomplete.tsx +++ b/apps/app/src/components/ui/category-autocomplete.tsx @@ -1,23 +1,7 @@ "use client"; import * as React from "react"; -import { Check, ChevronsUpDown } from "lucide-react"; - -import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; +import { Autocomplete } from "@/components/ui/autocomplete"; interface CategoryAutocompleteProps { value: string; @@ -38,81 +22,18 @@ export function CategoryAutocomplete({ disabled = false, "data-testid": testId, }: CategoryAutocompleteProps) { - const [open, setOpen] = React.useState(false); - const [triggerWidth, setTriggerWidth] = React.useState(0); - const triggerRef = React.useRef(null); - - // Update trigger width when component mounts or value changes - React.useEffect(() => { - if (triggerRef.current) { - const updateWidth = () => { - setTriggerWidth(triggerRef.current?.offsetWidth || 0); - }; - - updateWidth(); - - // Listen for resize events to handle responsive behavior - const resizeObserver = new ResizeObserver(updateWidth); - resizeObserver.observe(triggerRef.current); - - return () => { - resizeObserver.disconnect(); - }; - } - }, [value]); - return ( - - - - - - - - - No category found. - - {suggestions.map((suggestion) => ( - { - onChange(currentValue === value ? "" : currentValue); - setOpen(false); - }} - data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`} - > - {suggestion} - - - ))} - - - - - + ); } diff --git a/apps/app/src/components/ui/log-viewer.tsx b/apps/app/src/components/ui/log-viewer.tsx index 169c626f..a926e2d9 100644 --- a/apps/app/src/components/ui/log-viewer.tsx +++ b/apps/app/src/components/ui/log-viewer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect, useRef } from "react"; import { ChevronDown, ChevronRight, @@ -14,13 +14,26 @@ import { Info, FileOutput, Brain, + Eye, + Pencil, + Terminal, + Search, + ListTodo, + Layers, + X, + Filter, + Circle, + Play, + Loader2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { parseLogOutput, getLogTypeColors, + shouldCollapseByDefault, type LogEntry, type LogEntryType, + type ToolCategory, } from "@/lib/log-parser"; interface LogViewerProps { @@ -53,6 +66,160 @@ const getLogIcon = (type: LogEntryType) => { } }; +/** + * Returns a tool-specific icon based on the tool category + */ +const getToolCategoryIcon = (category: ToolCategory | undefined) => { + switch (category) { + case "read": + return ; + case "edit": + return ; + case "write": + return ; + case "bash": + return ; + case "search": + return ; + case "todo": + return ; + case "task": + return ; + default: + return ; + } +}; + +/** + * Returns color classes for a tool category + */ +const getToolCategoryColor = (category: ToolCategory | undefined): string => { + switch (category) { + case "read": + return "text-blue-400 bg-blue-500/10 border-blue-500/30"; + case "edit": + return "text-amber-400 bg-amber-500/10 border-amber-500/30"; + case "write": + return "text-emerald-400 bg-emerald-500/10 border-emerald-500/30"; + case "bash": + return "text-purple-400 bg-purple-500/10 border-purple-500/30"; + case "search": + return "text-cyan-400 bg-cyan-500/10 border-cyan-500/30"; + case "todo": + return "text-green-400 bg-green-500/10 border-green-500/30"; + case "task": + return "text-indigo-400 bg-indigo-500/10 border-indigo-500/30"; + default: + return "text-zinc-400 bg-zinc-500/10 border-zinc-500/30"; + } +}; + +/** + * Interface for parsed todo items from TodoWrite tool + */ +interface TodoItem { + content: string; + status: "pending" | "in_progress" | "completed"; + activeForm?: string; +} + +/** + * Parses TodoWrite JSON content and extracts todo items + */ +function parseTodoContent(content: string): TodoItem[] | null { + try { + // Find the JSON object in the content + const jsonMatch = content.match(/\{[\s\S]*"todos"[\s\S]*\}/); + if (!jsonMatch) return null; + + const parsed = JSON.parse(jsonMatch[0]) as { todos?: TodoItem[] }; + if (!parsed.todos || !Array.isArray(parsed.todos)) return null; + + return parsed.todos; + } catch { + return null; + } +} + +/** + * Renders a list of todo items with status icons and colors + */ +function TodoListRenderer({ todos }: { todos: TodoItem[] }) { + const getStatusIcon = (status: TodoItem["status"]) => { + switch (status) { + case "completed": + return ; + case "in_progress": + return ; + case "pending": + return ; + default: + return ; + } + }; + + const getStatusColor = (status: TodoItem["status"]) => { + switch (status) { + case "completed": + return "text-emerald-300 line-through opacity-70"; + case "in_progress": + return "text-amber-300"; + case "pending": + return "text-zinc-400"; + default: + return "text-zinc-400"; + } + }; + + const getStatusBadge = (status: TodoItem["status"]) => { + switch (status) { + case "completed": + return ( + + Done + + ); + case "in_progress": + return ( + + In Progress + + ); + default: + return null; + } + }; + + return ( +
+ {todos.map((todo, index) => ( +
+
{getStatusIcon(todo.status)}
+
+

+ {todo.content} +

+ {todo.status === "in_progress" && todo.activeForm && ( +

+ {todo.activeForm} +

+ )} +
+ {getStatusBadge(todo.status)} +
+ ))} +
+ ); +} + interface LogEntryItemProps { entry: LogEntry; isExpanded: boolean; @@ -63,9 +230,54 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { const colors = getLogTypeColors(entry.type); const hasContent = entry.content.length > 100; + // For tool_call entries, use tool-specific styling + const isToolCall = entry.type === "tool_call"; + const toolCategory = entry.metadata?.toolCategory; + const toolCategoryColors = isToolCall ? getToolCategoryColor(toolCategory) : ""; + + // Check if this is a TodoWrite entry and parse the todos + const isTodoWrite = entry.metadata?.toolName === "TodoWrite"; + const parsedTodos = useMemo(() => { + if (!isTodoWrite) return null; + return parseTodoContent(entry.content); + }, [isTodoWrite, entry.content]); + + // Get the appropriate icon based on entry type and tool category + const icon = isToolCall ? getToolCategoryIcon(toolCategory) : getLogIcon(entry.type); + + // Get collapsed preview text - prefer smart summary for tool calls + const collapsedPreview = useMemo(() => { + if (isExpanded) return ""; + + // Use smart summary if available + if (entry.metadata?.summary) { + return entry.metadata.summary; + } + + // Fallback to truncated content + return entry.content.slice(0, 80) + (entry.content.length > 80 ? "..." : ""); + }, [isExpanded, entry.metadata?.summary, entry.content]); + // Format content - detect and highlight JSON const formattedContent = useMemo(() => { - const content = entry.content; + let content = entry.content; + + // For tool_call entries, remove redundant "Tool: X" and "Input:" prefixes + // since we already show the tool name in the header badge + if (isToolCall) { + // Remove "🔧 Tool: ToolName\n" or "Tool: ToolName\n" prefix + content = content.replace(/^(?:🔧\s*)?Tool:\s*\w+\s*\n?/i, ""); + // Remove standalone "Input:" label (keep the JSON that follows) + content = content.replace(/^Input:\s*\n?/i, ""); + content = content.trim(); + } + + // For summary entries, remove the and tags + if (entry.title === "Summary") { + content = content.replace(/^\s*/i, ""); + content = content.replace(/\s*<\/summary>\s*$/i, ""); + content = content.trim(); + } // Try to find and format JSON blocks const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g; @@ -103,14 +315,20 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { } return parts.length > 0 ? parts : [{ type: "text" as const, content }]; - }, [entry.content]); + }, [entry.content, entry.title, isToolCall]); + + // Get colors - use tool category colors for tool_call entries + const colorParts = toolCategoryColors.split(" "); + const textColor = isToolCall ? (colorParts[0] || "text-zinc-400") : colors.text; + const bgColor = isToolCall ? (colorParts[1] || "bg-zinc-500/10") : colors.bg; + const borderColor = isToolCall ? (colorParts[2] || "border-zinc-500/30") : colors.border; return (
)} - - {getLogIcon(entry.type)} + + {icon} @@ -145,9 +363,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { - {!isExpanded && - entry.content.slice(0, 80) + - (entry.content.length > 80 ? "..." : "")} + {collapsedPreview} @@ -156,36 +372,140 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { className="px-4 pb-3 pt-1" data-testid={`log-entry-content-${entry.id}`} > -
- {formattedContent.map((part, index) => ( -
- {part.type === "json" ? ( -
-                    {part.content}
-                  
- ) : ( -
-                    {part.content}
-                  
- )} -
- ))} -
+ {/* Render TodoWrite entries with special formatting */} + {parsedTodos ? ( + + ) : ( +
+ {formattedContent.map((part, index) => ( +
+ {part.type === "json" ? ( +
+                      {part.content}
+                    
+ ) : ( +
+                      {part.content}
+                    
+ )} +
+ ))} +
+ )}
)}
); } +interface ToolCategoryStats { + read: number; + edit: number; + write: number; + bash: number; + search: number; + todo: number; + task: number; + other: number; +} + export function LogViewer({ output, className }: LogViewerProps) { const [expandedIds, setExpandedIds] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(""); + const [hiddenTypes, setHiddenTypes] = useState>(new Set()); + const [hiddenCategories, setHiddenCategories] = useState>(new Set()); - const entries = useMemo(() => parseLogOutput(output), [output]); + // Parse entries and compute initial expanded state together + const { entries, initialExpandedIds } = useMemo(() => { + const parsedEntries = parseLogOutput(output); + const toExpand: string[] = []; + + parsedEntries.forEach((entry) => { + // If entry should NOT collapse by default, mark it for expansion + if (!shouldCollapseByDefault(entry)) { + toExpand.push(entry.id); + } + }); + + return { + entries: parsedEntries, + initialExpandedIds: new Set(toExpand), + }; + }, [output]); + + // Merge initial expanded IDs with user-toggled ones + // Use a ref to track if we've applied initial state + const appliedInitialRef = useRef>(new Set()); + + // Apply initial expanded state for new entries + const effectiveExpandedIds = useMemo(() => { + const result = new Set(expandedIds); + initialExpandedIds.forEach((id) => { + if (!appliedInitialRef.current.has(id)) { + appliedInitialRef.current.add(id); + result.add(id); + } + }); + return result; + }, [expandedIds, initialExpandedIds]); + + // Calculate stats for tool categories + const stats = useMemo(() => { + const toolCalls = entries.filter((e) => e.type === "tool_call"); + const byCategory: ToolCategoryStats = { + read: 0, + edit: 0, + write: 0, + bash: 0, + search: 0, + todo: 0, + task: 0, + other: 0, + }; + + toolCalls.forEach((tc) => { + const cat = tc.metadata?.toolCategory || "other"; + byCategory[cat]++; + }); + + return { + total: toolCalls.length, + byCategory, + errors: entries.filter((e) => e.type === "error").length, + }; + }, [entries]); + + // Filter entries based on search and hidden types/categories + const filteredEntries = useMemo(() => { + return entries.filter((entry) => { + // Filter by hidden types + if (hiddenTypes.has(entry.type)) return false; + + // Filter by hidden tool categories (for tool_call entries) + if (entry.type === "tool_call" && entry.metadata?.toolCategory) { + if (hiddenCategories.has(entry.metadata.toolCategory)) return false; + } + + // Filter by search query + if (searchQuery) { + const query = searchQuery.toLowerCase(); + return ( + entry.content.toLowerCase().includes(query) || + entry.title.toLowerCase().includes(query) || + entry.metadata?.toolName?.toLowerCase().includes(query) || + entry.metadata?.summary?.toLowerCase().includes(query) || + entry.metadata?.filePath?.toLowerCase().includes(query) + ); + } + + return true; + }); + }, [entries, hiddenTypes, hiddenCategories, searchQuery]); const toggleEntry = (id: string) => { setExpandedIds((prev) => { @@ -200,13 +520,45 @@ export function LogViewer({ output, className }: LogViewerProps) { }; const expandAll = () => { - setExpandedIds(new Set(entries.map((e) => e.id))); + setExpandedIds(new Set(filteredEntries.map((e) => e.id))); }; const collapseAll = () => { setExpandedIds(new Set()); }; + const toggleTypeFilter = (type: LogEntryType) => { + setHiddenTypes((prev) => { + const next = new Set(prev); + if (next.has(type)) { + next.delete(type); + } else { + next.add(type); + } + return next; + }); + }; + + const toggleCategoryFilter = (category: ToolCategory) => { + setHiddenCategories((prev) => { + const next = new Set(prev); + if (next.has(category)) { + next.delete(category); + } else { + next.add(category); + } + return next; + }); + }; + + const clearFilters = () => { + setSearchQuery(""); + setHiddenTypes(new Set()); + setHiddenCategories(new Set()); + }; + + const hasActiveFilters = searchQuery || hiddenTypes.size > 0 || hiddenCategories.size > 0; + if (entries.length === 0) { return (
@@ -229,28 +581,123 @@ export function LogViewer({ output, className }: LogViewerProps) { return acc; }, {} as Record); + // Tool categories to display in stats bar + const toolCategoryLabels: { key: ToolCategory; label: string }[] = [ + { key: "read", label: "Read" }, + { key: "edit", label: "Edit" }, + { key: "write", label: "Write" }, + { key: "bash", label: "Bash" }, + { key: "search", label: "Search" }, + { key: "todo", label: "Todo" }, + { key: "task", label: "Task" }, + { key: "other", label: "Other" }, + ]; + return ( -
- {/* Header with controls */} +
+ {/* Sticky header with search, stats, and filters */} + {/* Use -top-4 to compensate for parent's p-4 padding, pt-4 to restore visual spacing */} +
+ {/* Search bar */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search logs..." + className="w-full pl-8 pr-8 py-1.5 text-xs bg-zinc-900/50 border border-zinc-700/50 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-zinc-600" + data-testid="log-search-input" + /> + {searchQuery && ( + + )} +
+ {hasActiveFilters && ( + + )} +
+ + {/* Tool category stats bar */} + {stats.total > 0 && ( +
+ + + {stats.total} tools: + + {toolCategoryLabels.map(({ key, label }) => { + const count = stats.byCategory[key]; + if (count === 0) return null; + const isHidden = hiddenCategories.has(key); + const colorClasses = getToolCategoryColor(key); + return ( + + ); + })} + {stats.errors > 0 && ( + + + {stats.errors} + + )} +
+ )} + + {/* Header with type filters and controls */}
-
+
+ {Object.entries(typeCounts).map(([type, count]) => { const colors = getLogTypeColors(type as LogEntryType); + const isHidden = hiddenTypes.has(type as LogEntryType); return ( - toggleTypeFilter(type as LogEntryType)} className={cn( - "text-xs px-2 py-0.5 rounded-full", - colors.badge + "text-xs px-2 py-0.5 rounded-full transition-all", + colors.badge, + isHidden && "opacity-40 line-through" )} - data-testid={`log-type-count-${type}`} + title={isHidden ? `Show ${type}` : `Hide ${type}`} + data-testid={`log-type-filter-${type}`} > {type}: {count} - + ); })}
+ + {filteredEntries.length}/{entries.length} +
+
{/* Log entries */} -
- {entries.map((entry) => ( - toggleEntry(entry.id)} - /> - ))} +
+ {filteredEntries.length === 0 ? ( +
+ No entries match your filters. + {hasActiveFilters && ( + + )} +
+ ) : ( + filteredEntries.map((entry) => ( + toggleEntry(entry.id)} + /> + )) + )}
); diff --git a/apps/app/src/components/views/agent-view.tsx b/apps/app/src/components/views/agent-view.tsx index 8386554d..daa9e48b 100644 --- a/apps/app/src/components/views/agent-view.tsx +++ b/apps/app/src/components/views/agent-view.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useCallback, useRef, useEffect, useMemo } from "react"; -import { useAppStore } from "@/store/app-store"; +import { useAppStore, type AgentModel } from "@/store/app-store"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ImageDropZone } from "@/components/ui/image-drop-zone"; @@ -18,6 +18,7 @@ import { Paperclip, X, ImageIcon, + ChevronDown, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useElectronAgent } from "@/hooks/use-electron-agent"; @@ -29,6 +30,13 @@ import { useKeyboardShortcutsConfig, KeyboardShortcut, } from "@/hooks/use-keyboard-shortcuts"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { CLAUDE_MODELS } from "@/components/views/board-view/shared/model-constants"; export function AgentView() { const { currentProject, setLastSelectedSession, getLastSelectedSession } = @@ -41,6 +49,7 @@ export function AgentView() { const [currentSessionId, setCurrentSessionId] = useState(null); const [showSessionManager, setShowSessionManager] = useState(true); const [isDragOver, setIsDragOver] = useState(false); + const [selectedModel, setSelectedModel] = useState("sonnet"); // Track if initial session has been loaded const initialSessionLoadedRef = useRef(false); @@ -66,6 +75,7 @@ export function AgentView() { } = useElectronAgent({ sessionId: currentSessionId || "", workingDirectory: currentProject?.path, + model: selectedModel, onToolUse: (toolName) => { setCurrentTool(toolName); setTimeout(() => setCurrentTool(null), 2000); @@ -501,6 +511,43 @@ export function AgentView() { {/* Status indicators & actions */}
+ {/* Model Selector */} + + + + + + {CLAUDE_MODELS.map((model) => ( + setSelectedModel(model.id)} + className={cn( + "cursor-pointer", + selectedModel === model.id && "bg-accent" + )} + data-testid={`model-option-${model.id}`} + > +
+ {model.label} + + {model.description} + +
+
+ ))} +
+
+ {currentTool && (
diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index 7bf3fe37..ee29646f 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -10,6 +10,7 @@ import { } from "@dnd-kit/core"; import { useAppStore, Feature } from "@/store/app-store"; import { getElectronAPI } from "@/lib/electron"; +import { pathsEqual } from "@/lib/utils"; import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal"; import { RefreshCw } from "lucide-react"; import { useAutoMode } from "@/hooks/use-auto-mode"; @@ -31,6 +32,12 @@ import { FollowUpDialog, PlanApprovalDialog, } from "./board-view/dialogs"; +import { CreateWorktreeDialog } from "./board-view/dialogs/create-worktree-dialog"; +import { DeleteWorktreeDialog } from "./board-view/dialogs/delete-worktree-dialog"; +import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialog"; +import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog"; +import { CreateBranchDialog } from "./board-view/dialogs/create-branch-dialog"; +import { WorktreePanel } from "./board-view/worktree-panel"; import { COLUMNS } from "./board-view/constants"; import { useBoardFeatures, @@ -45,6 +52,11 @@ import { useSuggestionsState, } from "./board-view/hooks"; +// Stable empty array to avoid infinite loop in selector +const EMPTY_WORKTREES: ReturnType< + ReturnType["getWorktrees"] +> = []; + export function BoardView() { const { currentProject, @@ -60,6 +72,10 @@ export function BoardView() { pendingPlanApproval, setPendingPlanApproval, updateFeature, + getCurrentWorktree, + setCurrentWorktree, + getWorktrees, + setWorktrees, } = useAppStore(); const shortcuts = useKeyboardShortcutsConfig(); const { @@ -87,6 +103,24 @@ export function BoardView() { // State for viewing plan in read-only mode const [viewPlanFeature, setViewPlanFeature] = useState(null); + // Worktree dialog states + const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = + useState(false); + const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = + useState(false); + const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = + useState(false); + const [showCreatePRDialog, setShowCreatePRDialog] = useState(false); + const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false); + const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{ + path: string; + branch: string; + isMain: boolean; + hasChanges?: boolean; + changedFilesCount?: number; + } | null>(null); + const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0); + // Follow-up state hook const { showFollowUpDialog, @@ -194,32 +228,62 @@ export function BoardView() { return [...new Set(allCategories)].sort(); }, [hookFeatures, persistedCategories]); - // Custom collision detection that prioritizes columns over cards - const collisionDetectionStrategy = useCallback( - (args: any) => { - // First, check if pointer is within a column - const pointerCollisions = pointerWithin(args); - const columnCollisions = pointerCollisions.filter((collision: any) => - COLUMNS.some((col) => col.id === collision.id) - ); + // Branch suggestions for the branch autocomplete + // Shows all local branches as suggestions, but users can type any new branch name + // When the feature is started, a worktree will be created if needed + const [branchSuggestions, setBranchSuggestions] = useState([]); - // If we found a column collision, use that - if (columnCollisions.length > 0) { - return columnCollisions; + // Fetch branches when project changes or worktrees are created/modified + useEffect(() => { + const fetchBranches = async () => { + if (!currentProject) { + setBranchSuggestions([]); + return; } - // Otherwise, use rectangle intersection for cards - return rectIntersection(args); - }, - [] - ); + try { + const api = getElectronAPI(); + if (!api?.worktree?.listBranches) { + setBranchSuggestions([]); + return; + } + + const result = await api.worktree.listBranches(currentProject.path); + if (result.success && result.result?.branches) { + const localBranches = result.result.branches + .filter((b) => !b.isRemote) + .map((b) => b.name); + setBranchSuggestions(localBranches); + } + } catch (error) { + console.error("[BoardView] Error fetching branches:", error); + setBranchSuggestions([]); + } + }; + + fetchBranches(); + }, [currentProject, worktreeRefreshKey]); + + // Custom collision detection that prioritizes columns over cards + const collisionDetectionStrategy = useCallback((args: any) => { + // First, check if pointer is within a column + const pointerCollisions = pointerWithin(args); + const columnCollisions = pointerCollisions.filter((collision: any) => + COLUMNS.some((col) => col.id === collision.id) + ); + + // If we found a column collision, use that + if (columnCollisions.length > 0) { + return columnCollisions; + } + + // Otherwise, use rectangle intersection for cards + return rectIntersection(args); + }, []); // Use persistence hook - const { - persistFeatureCreate, - persistFeatureUpdate, - persistFeatureDelete, - } = useBoardPersistence({ currentProject }); + const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } = + useBoardPersistence({ currentProject }); // Get in-progress features for keyboard shortcuts (needed before actions hook) const inProgressFeaturesForShortcuts = useMemo(() => { @@ -229,6 +293,27 @@ export function BoardView() { }); }, [hookFeatures, runningAutoTasks]); + // Get current worktree info (path and branch) for filtering features + // This needs to be before useBoardActions so we can pass currentWorktreeBranch + const currentWorktreeInfo = currentProject + ? getCurrentWorktree(currentProject.path) + : null; + const currentWorktreePath = currentWorktreeInfo?.path ?? null; + const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null; + const worktreesByProject = useAppStore((s) => s.worktreesByProject); + const worktrees = useMemo( + () => + currentProject + ? worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES + : EMPTY_WORKTREES, + [currentProject, worktreesByProject] + ); + + // Get the branch for the currently selected worktree (for defaulting new features) + // Use the branch from currentWorktreeInfo, or fall back to main worktree's branch + const selectedWorktreeBranch = + currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main"; + // Extract all action handlers into a hook const { handleAddFeature, @@ -242,7 +327,6 @@ export function BoardView() { handleOpenFollowUp, handleSendFollowUp, handleCommitFeature, - handleRevertFeature, handleMergeFeature, handleCompleteFeature, handleUnarchiveFeature, @@ -273,6 +357,9 @@ export function BoardView() { setShowFollowUpDialog, inProgressFeaturesForShortcuts, outputFeature, + projectPath: currentProject?.path || null, + onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1), + currentWorktreeBranch, }); // Use keyboard shortcuts hook (after actions hook) @@ -291,6 +378,8 @@ export function BoardView() { runningAutoTasks, persistFeatureUpdate, handleStartImplementation, + projectPath: currentProject?.path || null, + onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1), }); // Use column features hook @@ -298,6 +387,9 @@ export function BoardView() { features: hookFeatures, runningAutoTasks, searchQuery, + currentWorktreePath, + currentWorktreeBranch, + projectPath: currentProject?.path || null, }); // Use background hook @@ -473,6 +565,35 @@ export function BoardView() { isMounted={isMounted} /> + {/* Worktree Panel */} + setShowCreateWorktreeDialog(true)} + onDeleteWorktree={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowDeleteWorktreeDialog(true); + }} + onCommit={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowCommitWorktreeDialog(true); + }} + onCreatePR={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowCreatePRDialog(true); + }} + onCreateBranch={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowCreateBranchDialog(true); + }} + runningFeatureIds={runningAutoTasks} + features={hookFeatures.map((f) => ({ + id: f.id, + worktreePath: f.worktreePath, + branchName: f.branchName, + }))} + /> + {/* Main Content Area */}
{/* Search Bar Row */} @@ -515,8 +636,6 @@ export function BoardView() { onMoveBackToInProgress={handleMoveBackToInProgress} onFollowUp={handleOpenFollowUp} onCommit={handleCommitFeature} - onRevert={handleRevertFeature} - onMerge={handleMergeFeature} onComplete={handleCompleteFeature} onImplement={handleStartImplementation} onViewPlan={(feature) => setViewPlanFeature(feature)} @@ -564,7 +683,9 @@ export function BoardView() { onOpenChange={setShowAddDialog} onAdd={handleAddFeature} categorySuggestions={categorySuggestions} + branchSuggestions={branchSuggestions} defaultSkipTests={defaultSkipTests} + defaultBranch={selectedWorktreeBranch} isMaximized={isMaximized} showProfilesOnly={showProfilesOnly} aiProfiles={aiProfiles} @@ -576,6 +697,7 @@ export function BoardView() { onClose={() => setEditingFeature(null)} onUpdate={handleUpdateFeature} categorySuggestions={categorySuggestions} + branchSuggestions={branchSuggestions} isMaximized={isMaximized} showProfilesOnly={showProfilesOnly} aiProfiles={aiProfiles} @@ -656,6 +778,101 @@ export function BoardView() { viewOnly={true} /> )} + + {/* Create Worktree Dialog */} + { + // Add the new worktree to the store immediately to avoid race condition + // when deriving currentWorktreeBranch for filtering + const currentWorktrees = getWorktrees(currentProject.path); + const newWorktreeInfo = { + path: newWorktree.path, + branch: newWorktree.branch, + isMain: false, + isCurrent: false, + hasWorktree: true, + }; + setWorktrees(currentProject.path, [ + ...currentWorktrees, + newWorktreeInfo, + ]); + + // Now set the current worktree with both path and branch + setCurrentWorktree( + currentProject.path, + newWorktree.path, + newWorktree.branch + ); + + // Trigger refresh to get full worktree details (hasChanges, etc.) + setWorktreeRefreshKey((k) => k + 1); + }} + /> + + {/* Delete Worktree Dialog */} + { + // Reset features that were assigned to the deleted worktree + hookFeatures.forEach((feature) => { + const matchesByPath = + feature.worktreePath && + pathsEqual(feature.worktreePath, deletedWorktree.path); + const matchesByBranch = + feature.branchName === deletedWorktree.branch; + + if (matchesByPath || matchesByBranch) { + // Reset the feature's worktree assignment + persistFeatureUpdate(feature.id, { + branchName: null as unknown as string | undefined, + worktreePath: null as unknown as string | undefined, + }); + } + }); + + setWorktreeRefreshKey((k) => k + 1); + setSelectedWorktreeForAction(null); + }} + /> + + {/* Commit Worktree Dialog */} + { + setWorktreeRefreshKey((k) => k + 1); + setSelectedWorktreeForAction(null); + }} + /> + + {/* Create PR Dialog */} + { + setWorktreeRefreshKey((k) => k + 1); + setSelectedWorktreeForAction(null); + }} + /> + + {/* Create Branch Dialog */} + { + setWorktreeRefreshKey((k) => k + 1); + setSelectedWorktreeForAction(null); + }} + />
); } diff --git a/apps/app/src/components/views/board-view/components/kanban-card.tsx b/apps/app/src/components/views/board-view/components/kanban-card.tsx index c4b80e78..cb348ca5 100644 --- a/apps/app/src/components/views/board-view/components/kanban-card.tsx +++ b/apps/app/src/components/views/board-view/components/kanban-card.tsx @@ -52,16 +52,16 @@ import { MoreVertical, AlertCircle, GitBranch, - Undo2, - GitMerge, ChevronDown, ChevronUp, Brain, Wand2, Archive, + Lock, } from "lucide-react"; import { CountUpTimer } from "@/components/ui/count-up-timer"; import { getElectronAPI } from "@/lib/electron"; +import { getBlockingDependencies } from "@/lib/dependency-resolver"; import { parseAgentContext, AgentTaskInfo, @@ -103,8 +103,6 @@ interface KanbanCardProps { onMoveBackToInProgress?: () => void; onFollowUp?: () => void; onCommit?: () => void; - onRevert?: () => void; - onMerge?: () => void; onImplement?: () => void; onComplete?: () => void; onViewPlan?: () => void; @@ -132,8 +130,6 @@ export const KanbanCard = memo(function KanbanCard({ onMoveBackToInProgress, onFollowUp, onCommit, - onRevert, - onMerge, onImplement, onComplete, onViewPlan, @@ -150,13 +146,18 @@ export const KanbanCard = memo(function KanbanCard({ }: KanbanCardProps) { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false); - const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false); const [agentInfo, setAgentInfo] = useState(null); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [currentTime, setCurrentTime] = useState(() => Date.now()); - const { kanbanCardDetailLevel } = useAppStore(); + const { kanbanCardDetailLevel, enableDependencyBlocking, features, useWorktrees } = useAppStore(); - const hasWorktree = !!feature.branchName; + // Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies) + const blockingDependencies = useMemo(() => { + if (!enableDependencyBlocking || feature.status !== "backlog") { + return []; + } + return getBlockingDependencies(feature, features); + }, [enableDependencyBlocking, feature, features]); const showSteps = kanbanCardDetailLevel === "standard" || @@ -341,7 +342,7 @@ export const KanbanCard = memo(function KanbanCard({
- P{feature.priority} + {feature.priority === 1 ? "H" : feature.priority === 2 ? "M" : "L"}
@@ -360,8 +361,8 @@ export const KanbanCard = memo(function KanbanCard({ {feature.priority === 1 ? "High Priority" : feature.priority === 2 - ? "Medium Priority" - : "Low Priority"} + ? "Medium Priority" + : "Low Priority"}

@@ -377,23 +378,24 @@ export const KanbanCard = memo(function KanbanCard({
)} - {/* Skip Tests (Manual) indicator badge */} - {feature.skipTests && !feature.error && ( + {/* Skip Tests (Manual) indicator badge - positioned at top right */} + {feature.skipTests && !feature.error && feature.status === "backlog" && (
- +
- +

Manual verification required

@@ -407,13 +409,14 @@ export const KanbanCard = memo(function KanbanCard({
- +
@@ -423,16 +426,42 @@ export const KanbanCard = memo(function KanbanCard({
)} + {/* Blocked by dependencies badge - positioned at top right */} + {blockingDependencies.length > 0 && !feature.error && !feature.skipTests && feature.status === "backlog" && ( + + + +
+ +
+
+ +

Blocked by {blockingDependencies.length} incomplete {blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}

+

+ {blockingDependencies.map(depId => { + const dep = features.find(f => f.id === depId); + return dep?.description || depId; + }).join(', ')} +

+
+
+
+ )} + {/* Just Finished indicator badge */} {isJustFinished && (
)} - {/* Branch badge */} - {hasWorktree && !isCurrentAutoTask && ( - - - -
- -
-
- -

- {feature.branchName} -

-
-
-
- )} - {isCurrentAutoTask && ( @@ -499,7 +496,7 @@ export const KanbanCard = memo(function KanbanCard({
)} {!isCurrentAutoTask && feature.status === "backlog" && ( -
+
- {onViewOutput && ( + <> +
- )} + {onViewOutput && ( + + )} +
+
+ +
+ + )} + {!isCurrentAutoTask && feature.status === "in_progress" && ( + <> +
+ + + + + + { + e.stopPropagation(); + onEdit(); + }} + data-testid={`edit-feature-${feature.id}`} + className="text-xs" + > + + Edit + + {onViewOutput && ( + { + e.stopPropagation(); + onViewOutput(); + }} + data-testid={`view-logs-${feature.id}`} + className="text-xs" + > + + View Logs + + )} + + +
+
- )} - {!isCurrentAutoTask && feature.status === "in_progress" && ( -
- - - - - - { - e.stopPropagation(); - onEdit(); - }} - data-testid={`edit-feature-${feature.id}`} - className="text-xs" - > - - Edit - - {onViewOutput && ( - { - e.stopPropagation(); - onViewOutput(); - }} - data-testid={`view-logs-${feature.id}`} - className="text-xs" - > - - View Logs - - )} - { - e.stopPropagation(); - handleDeleteClick(e as unknown as React.MouseEvent); - }} - data-testid={`delete-feature-${feature.id}`} - > - - Delete - - - -
+ )}
{isDraggable && ( @@ -679,6 +687,16 @@ export const KanbanCard = memo(function KanbanCard({ + {/* Target Branch Display */} + {useWorktrees && feature.branchName && ( +
+ + + {feature.branchName} + +
+ )} + {/* Steps Preview */} {showSteps && feature.steps && feature.steps.length > 0 && (
@@ -884,9 +902,9 @@ export const KanbanCard = memo(function KanbanCard({ )} {onViewOutput && ( - - -

Revert changes

-
- - - )} {/* Refine prompt button */} {onFollowUp && ( )} - {hasWorktree && onMerge && ( - - )} - {!hasWorktree && onCommit && ( + {onCommit && (
- - {/* Revert Confirmation Dialog */} - - - - - - Revert Changes - - - This will discard all changes made by the agent and move the - feature back to the backlog. - {feature.branchName && ( - - Branch{" "} - - {feature.branchName} - {" "} - will be deleted. - - )} - - This action cannot be undone. - - - - - - - - - ); diff --git a/apps/app/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/add-feature-dialog.tsx index 5dbd5155..b7d70b6f 100644 --- a/apps/app/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/app/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -14,12 +14,20 @@ import { Button } from "@/components/ui/button"; import { HotkeyButton } from "@/components/ui/hotkey-button"; import { Label } from "@/components/ui/label"; import { CategoryAutocomplete } from "@/components/ui/category-autocomplete"; +import { BranchAutocomplete } from "@/components/ui/branch-autocomplete"; import { DescriptionImageDropZone, FeatureImagePath as DescriptionImagePath, ImagePreviewMap, } from "@/components/ui/description-image-dropzone"; -import { MessageSquare, Settings2, SlidersHorizontal, Sparkles, ChevronDown } from "lucide-react"; +import { + MessageSquare, + Settings2, + SlidersHorizontal, + FlaskConical, + Sparkles, + ChevronDown, +} from "lucide-react"; import { toast } from "sonner"; import { getElectronAPI } from "@/lib/electron"; import { modelSupportsThinking } from "@/lib/utils"; @@ -58,12 +66,15 @@ interface AddFeatureDialogProps { skipTests: boolean; model: AgentModel; thinkingLevel: ThinkingLevel; + branchName: string; priority: number; planningMode: PlanningMode; requirePlanApproval: boolean; }) => void; categorySuggestions: string[]; + branchSuggestions: string[]; defaultSkipTests: boolean; + defaultBranch?: string; isMaximized: boolean; showProfilesOnly: boolean; aiProfiles: AIProfile[]; @@ -74,7 +85,9 @@ export function AddFeatureDialog({ onOpenChange, onAdd, categorySuggestions, + branchSuggestions, defaultSkipTests, + defaultBranch = "main", isMaximized, showProfilesOnly, aiProfiles, @@ -88,6 +101,7 @@ export function AddFeatureDialog({ skipTests: false, model: "opus" as AgentModel, thinkingLevel: "none" as ThinkingLevel, + branchName: "main", priority: 2 as number, // Default to medium priority }); const [newFeaturePreviewMap, setNewFeaturePreviewMap] = @@ -95,12 +109,14 @@ export function AddFeatureDialog({ const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); const [descriptionError, setDescriptionError] = useState(false); const [isEnhancing, setIsEnhancing] = useState(false); - const [enhancementMode, setEnhancementMode] = useState<'improve' | 'technical' | 'simplify' | 'acceptance'>('improve'); + const [enhancementMode, setEnhancementMode] = useState< + "improve" | "technical" | "simplify" | "acceptance" + >("improve"); const [planningMode, setPlanningMode] = useState('skip'); const [requirePlanApproval, setRequirePlanApproval] = useState(false); - // Get enhancement model and default planning mode from store - const { enhancementModel, defaultPlanningMode, defaultRequirePlanApproval } = useAppStore(); + // Get enhancement model, planning mode defaults, and worktrees setting from store + const { enhancementModel, defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore(); // Sync defaults when dialog opens useEffect(() => { @@ -108,11 +124,12 @@ export function AddFeatureDialog({ setNewFeature((prev) => ({ ...prev, skipTests: defaultSkipTests, + branchName: defaultBranch, })); setPlanningMode(defaultPlanningMode); setRequirePlanApproval(defaultRequirePlanApproval); } - }, [open, defaultSkipTests, defaultPlanningMode, defaultRequirePlanApproval]); + }, [open, defaultSkipTests, defaultBranch, defaultPlanningMode, defaultRequirePlanApproval]); const handleAdd = () => { if (!newFeature.description.trim()) { @@ -135,6 +152,7 @@ export function AddFeatureDialog({ skipTests: newFeature.skipTests, model: selectedModel, thinkingLevel: normalizedThinking, + branchName: newFeature.branchName, priority: newFeature.priority, planningMode, requirePlanApproval, @@ -151,6 +169,7 @@ export function AddFeatureDialog({ model: "opus", priority: 2, thinkingLevel: "none", + branchName: defaultBranch, }); setPlanningMode(defaultPlanningMode); setRequirePlanApproval(defaultRequirePlanApproval); @@ -183,7 +202,7 @@ export function AddFeatureDialog({ if (result?.success && result.enhancedText) { const enhancedText = result.enhancedText; - setNewFeature(prev => ({ ...prev, description: enhancedText })); + setNewFeature((prev) => ({ ...prev, description: enhancedText })); toast.success("Description enhanced!"); } else { toast.error(result?.error || "Failed to enhance description"); @@ -206,7 +225,10 @@ export function AddFeatureDialog({ }); }; - const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => { + const handleProfileSelect = ( + model: AgentModel, + thinkingLevel: ThinkingLevel + ) => { setNewFeature({ ...newFeature, model, @@ -260,7 +282,10 @@ export function AddFeatureDialog({ {/* Prompt Tab */} - +
- - setEnhancementMode('improve')}> + setEnhancementMode("improve")} + > Improve Clarity - setEnhancementMode('technical')}> + setEnhancementMode("technical")} + > Add Technical Details - setEnhancementMode('simplify')}> + setEnhancementMode("simplify")} + > Simplify - setEnhancementMode('acceptance')}> + setEnhancementMode("acceptance")} + > Add Acceptance Criteria @@ -333,6 +371,24 @@ export function AddFeatureDialog({ data-testid="feature-category-input" />
+ {useWorktrees && ( +
+ + + setNewFeature({ ...newFeature, branchName: value }) + } + branches={branchSuggestions} + placeholder="Select or create branch..." + data-testid="feature-branch-input" + /> +

+ Work will be done in this branch. A worktree will be created if + needed. +

+
+ )} {/* Priority Selector */} {/* Model Tab */} - + {/* Show Advanced Options Toggle */} {showProfilesOnly && (
@@ -429,9 +488,7 @@ export function AddFeatureDialog({ setNewFeature({ ...newFeature, skipTests }) } steps={newFeature.steps} - onStepsChange={(steps) => - setNewFeature({ ...newFeature, steps }) - } + onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })} /> diff --git a/apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx b/apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx index c968445b..e2153c0d 100644 --- a/apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx +++ b/apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx @@ -100,24 +100,6 @@ export function AgentOutputModal({ loadOutput(); }, [open, featureId]); - // Save output to file - const saveOutput = async (newContent: string) => { - if (!projectPathRef.current) return; - - const api = getElectronAPI(); - if (!api) return; - - try { - // Use features API - agent output is stored in features/{id}/agent-output.md - // We need to write it directly since there's no updateAgentOutput method - // The context-manager handles this on the backend, but for frontend edits we write directly - const outputPath = `${projectPathRef.current}/.automaker/features/${featureId}/agent-output.md`; - await api.writeFile(outputPath, newContent); - } catch (error) { - console.error("Failed to save output:", error); - } - }; - // Listen to auto mode events and update output useEffect(() => { if (!open) return; @@ -143,7 +125,7 @@ export function AgentOutputModal({ ? JSON.stringify(event.input, null, 2) : ""; newContent = `\n🔧 Tool: ${toolName}\n${ - toolInput ? `Input: ${toolInput}` : "" + toolInput ? `Input: ${toolInput}\n` : "" }`; break; case "auto_mode_phase": @@ -261,11 +243,8 @@ export function AgentOutputModal({ } if (newContent) { - setOutput((prev) => { - const updated = prev + newContent; - saveOutput(updated); - return updated; - }); + // Only update local state - server is the single source of truth for file writes + setOutput((prev) => prev + newContent); } }); diff --git a/apps/app/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx new file mode 100644 index 00000000..048169f2 --- /dev/null +++ b/apps/app/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { GitCommit, Loader2 } from "lucide-react"; +import { getElectronAPI } from "@/lib/electron"; +import { toast } from "sonner"; + +interface WorktreeInfo { + path: string; + branch: string; + isMain: boolean; + hasChanges?: boolean; + changedFilesCount?: number; +} + +interface CommitWorktreeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + worktree: WorktreeInfo | null; + onCommitted: () => void; +} + +export function CommitWorktreeDialog({ + open, + onOpenChange, + worktree, + onCommitted, +}: CommitWorktreeDialogProps) { + const [message, setMessage] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleCommit = async () => { + if (!worktree || !message.trim()) return; + + setIsLoading(true); + setError(null); + + try { + const api = getElectronAPI(); + if (!api?.worktree?.commit) { + setError("Worktree API not available"); + return; + } + const result = await api.worktree.commit(worktree.path, message); + + if (result.success && result.result) { + if (result.result.committed) { + toast.success("Changes committed", { + description: `Commit ${result.result.commitHash} on ${result.result.branch}`, + }); + onCommitted(); + onOpenChange(false); + setMessage(""); + } else { + toast.info("No changes to commit", { + description: result.result.message, + }); + } + } else { + setError(result.error || "Failed to commit changes"); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to commit"); + } finally { + setIsLoading(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && e.metaKey && !isLoading && message.trim()) { + handleCommit(); + } + }; + + if (!worktree) return null; + + return ( + + + + + + Commit Changes + + + Commit changes in the{" "} + + {worktree.branch} + {" "} + worktree. + {worktree.changedFilesCount && ( + + ({worktree.changedFilesCount} file + {worktree.changedFilesCount > 1 ? "s" : ""} changed) + + )} + + + +
+
+ +