mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
Merge remote-tracking branch 'upstream/v0.13.0rc' into feat/react-query
# Conflicts: # apps/ui/src/components/views/board-view.tsx # apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx # apps/ui/src/components/views/board-view/hooks/use-board-features.ts # apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx # apps/ui/src/hooks/use-project-settings-loader.ts
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, memo, useCallback } from 'react';
|
||||
import { useEffect, useRef, useState, memo, useCallback, useMemo } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -6,35 +6,67 @@ import { cn } from '@/lib/utils';
|
||||
import { type ThemeMode, useAppStore } from '@/store/app-store';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants';
|
||||
import {
|
||||
PROJECT_DARK_THEMES,
|
||||
PROJECT_LIGHT_THEMES,
|
||||
THEME_SUBMENU_CONSTANTS,
|
||||
} from '@/components/layout/sidebar/constants';
|
||||
import { useThemePreview } from '@/components/layout/sidebar/hooks';
|
||||
|
||||
// Constant for "use global theme" option
|
||||
/**
|
||||
* Constant representing the "use global theme" option.
|
||||
* An empty string is used to indicate that no project-specific theme is set.
|
||||
*/
|
||||
const USE_GLOBAL_THEME = '' as const;
|
||||
|
||||
// Constants for z-index values
|
||||
/**
|
||||
* Z-index values for context menu layering.
|
||||
* Ensures proper stacking order when menus overlap.
|
||||
*/
|
||||
const Z_INDEX = {
|
||||
/** Base z-index for the main context menu */
|
||||
CONTEXT_MENU: 100,
|
||||
/** Higher z-index for theme submenu to appear above parent menu */
|
||||
THEME_SUBMENU: 101,
|
||||
} as const;
|
||||
|
||||
// Theme option type - using ThemeMode for type safety
|
||||
/**
|
||||
* Represents a selectable theme option in the theme submenu.
|
||||
* Uses ThemeMode from app-store for type safety.
|
||||
*/
|
||||
interface ThemeOption {
|
||||
/** The theme mode value (e.g., 'dark', 'light', 'dracula') */
|
||||
value: ThemeMode;
|
||||
/** Display label for the theme option */
|
||||
label: string;
|
||||
/** Lucide icon component to display alongside the label */
|
||||
icon: LucideIcon;
|
||||
/** CSS color value for the icon */
|
||||
color: string;
|
||||
}
|
||||
|
||||
// Reusable theme button component to avoid duplication (DRY principle)
|
||||
/**
|
||||
* Props for the ThemeButton component.
|
||||
* Defines the interface for rendering individual theme selection buttons.
|
||||
*/
|
||||
interface ThemeButtonProps {
|
||||
/** The theme option data to display */
|
||||
option: ThemeOption;
|
||||
/** Whether this theme is currently selected */
|
||||
isSelected: boolean;
|
||||
/** Handler for pointer enter events (used for preview) */
|
||||
onPointerEnter: () => void;
|
||||
/** Handler for pointer leave events (used to clear preview) */
|
||||
onPointerLeave: (e: React.PointerEvent) => void;
|
||||
/** Handler for click events (used to select theme) */
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable button component for individual theme options.
|
||||
* Implements hover preview and selection functionality.
|
||||
* Memoized to prevent unnecessary re-renders when parent state changes.
|
||||
*/
|
||||
const ThemeButton = memo(function ThemeButton({
|
||||
option,
|
||||
isSelected,
|
||||
@@ -63,17 +95,33 @@ const ThemeButton = memo(function ThemeButton({
|
||||
);
|
||||
});
|
||||
|
||||
// Reusable theme column component
|
||||
/**
|
||||
* Props for the ThemeColumn component.
|
||||
* Defines the interface for rendering a column of related theme options (e.g., dark or light themes).
|
||||
*/
|
||||
interface ThemeColumnProps {
|
||||
/** Column header title (e.g., "Dark", "Light") */
|
||||
title: string;
|
||||
/** Icon to display in the column header */
|
||||
icon: LucideIcon;
|
||||
/** Array of theme options to display in this column */
|
||||
themes: ThemeOption[];
|
||||
/** Currently selected theme value, or null if using global theme */
|
||||
selectedTheme: ThemeMode | null;
|
||||
/** Handler called when user hovers over a theme option for preview */
|
||||
onPreviewEnter: (value: ThemeMode) => void;
|
||||
/** Handler called when user stops hovering over a theme option */
|
||||
onPreviewLeave: (e: React.PointerEvent) => void;
|
||||
/** Handler called when user clicks to select a theme */
|
||||
onSelect: (value: ThemeMode) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable column component for displaying themed options.
|
||||
* Renders a group of related themes (e.g., all dark themes or all light themes)
|
||||
* with a header and scrollable list of ThemeButton components.
|
||||
* Memoized to prevent unnecessary re-renders.
|
||||
*/
|
||||
const ThemeColumn = memo(function ThemeColumn({
|
||||
title,
|
||||
icon: Icon,
|
||||
@@ -105,13 +153,36 @@ const ThemeColumn = memo(function ThemeColumn({
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Props for the ProjectContextMenu component.
|
||||
* Defines the interface for the project right-click context menu.
|
||||
*/
|
||||
interface ProjectContextMenuProps {
|
||||
/** The project this context menu is for */
|
||||
project: Project;
|
||||
/** Screen coordinates where the context menu should appear */
|
||||
position: { x: number; y: number };
|
||||
/** Callback to close the context menu */
|
||||
onClose: () => void;
|
||||
/** Callback when user selects "Edit Name & Icon" option */
|
||||
onEdit: (project: Project) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A context menu component for project-specific actions.
|
||||
*
|
||||
* Provides options for:
|
||||
* - Editing project name and icon
|
||||
* - Setting project-specific theme (with live preview on hover)
|
||||
* - Removing project from the workspace
|
||||
*
|
||||
* Features viewport-aware positioning for the theme submenu to prevent
|
||||
* overflow, and implements delayed hover handling to improve UX when
|
||||
* navigating between the trigger button and submenu.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns The rendered context menu or null if not visible
|
||||
*/
|
||||
export function ProjectContextMenu({
|
||||
project,
|
||||
position,
|
||||
@@ -130,9 +201,82 @@ export function ProjectContextMenu({
|
||||
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
|
||||
const [removeConfirmed, setRemoveConfirmed] = useState(false);
|
||||
const themeSubmenuRef = useRef<HTMLDivElement>(null);
|
||||
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme });
|
||||
|
||||
// Handler to open theme submenu and cancel any pending close
|
||||
const handleThemeMenuEnter = useCallback(() => {
|
||||
// Cancel any pending close timeout
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
setShowThemeSubmenu(true);
|
||||
}, []);
|
||||
|
||||
// Handler to close theme submenu with a small delay
|
||||
// This prevents the submenu from closing when mouse crosses the gap between trigger and submenu
|
||||
const handleThemeMenuLeave = useCallback(() => {
|
||||
// Add a small delay before closing to allow mouse to reach submenu
|
||||
closeTimeoutRef.current = setTimeout(() => {
|
||||
setShowThemeSubmenu(false);
|
||||
setPreviewTheme(null);
|
||||
}, 100); // 100ms delay is enough to cross the gap
|
||||
}, [setPreviewTheme]);
|
||||
|
||||
/**
|
||||
* Calculates theme submenu position to prevent viewport overflow.
|
||||
*
|
||||
* This memoized calculation determines the optimal vertical position and maximum
|
||||
* height for the theme submenu based on the current viewport dimensions and
|
||||
* the trigger button's position.
|
||||
*
|
||||
* @returns Object containing:
|
||||
* - top: Vertical offset from default position (negative values shift submenu up)
|
||||
* - maxHeight: Maximum height constraint to prevent overflow with scrolling
|
||||
*/
|
||||
const submenuPosition = useMemo(() => {
|
||||
const { ESTIMATED_SUBMENU_HEIGHT, COLLISION_PADDING, THEME_BUTTON_OFFSET } =
|
||||
THEME_SUBMENU_CONSTANTS;
|
||||
|
||||
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800;
|
||||
|
||||
// Calculate where the submenu's bottom edge would be if positioned normally
|
||||
const submenuBottomY = position.y + THEME_BUTTON_OFFSET + ESTIMATED_SUBMENU_HEIGHT;
|
||||
|
||||
// Check if submenu would overflow bottom of viewport
|
||||
const wouldOverflowBottom = submenuBottomY > viewportHeight - COLLISION_PADDING;
|
||||
|
||||
// If it would overflow, calculate how much to shift it up
|
||||
if (wouldOverflowBottom) {
|
||||
// Calculate the offset needed to align submenu bottom with viewport bottom minus padding
|
||||
const overflowAmount = submenuBottomY - (viewportHeight - COLLISION_PADDING);
|
||||
return {
|
||||
top: -overflowAmount,
|
||||
maxHeight: Math.min(ESTIMATED_SUBMENU_HEIGHT, viewportHeight - COLLISION_PADDING * 2),
|
||||
};
|
||||
}
|
||||
|
||||
// Default: submenu opens at top of parent (aligned with the theme button)
|
||||
return {
|
||||
top: 0,
|
||||
maxHeight: Math.min(
|
||||
ESTIMATED_SUBMENU_HEIGHT,
|
||||
viewportHeight - position.y - THEME_BUTTON_OFFSET - COLLISION_PADDING
|
||||
),
|
||||
};
|
||||
}, [position.y]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: globalThis.MouseEvent) => {
|
||||
// Don't close if a confirmation dialog is open (dialog is in a portal)
|
||||
@@ -242,11 +386,8 @@ export function ProjectContextMenu({
|
||||
{/* Theme Submenu Trigger */}
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setShowThemeSubmenu(true)}
|
||||
onMouseLeave={() => {
|
||||
setShowThemeSubmenu(false);
|
||||
setPreviewTheme(null);
|
||||
}}
|
||||
onMouseEnter={handleThemeMenuEnter}
|
||||
onMouseLeave={handleThemeMenuLeave}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)}
|
||||
@@ -273,13 +414,18 @@ export function ProjectContextMenu({
|
||||
<div
|
||||
ref={themeSubmenuRef}
|
||||
className={cn(
|
||||
'absolute left-full top-0 ml-1 min-w-[420px] rounded-lg',
|
||||
'absolute left-full ml-1 min-w-[420px] rounded-lg',
|
||||
'bg-popover text-popover-foreground',
|
||||
'border border-border shadow-lg',
|
||||
'animate-in fade-in zoom-in-95 duration-100'
|
||||
)}
|
||||
style={{ zIndex: Z_INDEX.THEME_SUBMENU }}
|
||||
style={{
|
||||
zIndex: Z_INDEX.THEME_SUBMENU,
|
||||
top: `${submenuPosition.top}px`,
|
||||
}}
|
||||
data-testid="project-theme-submenu"
|
||||
onMouseEnter={handleThemeMenuEnter}
|
||||
onMouseLeave={handleThemeMenuLeave}
|
||||
>
|
||||
<div className="p-2">
|
||||
{/* Use Global Option */}
|
||||
@@ -306,7 +452,13 @@ export function ProjectContextMenu({
|
||||
<div className="h-px bg-border my-2" />
|
||||
|
||||
{/* Two Column Layout - Using reusable ThemeColumn component */}
|
||||
<div className="flex gap-2">
|
||||
{/* Dynamic max height with scroll for viewport overflow handling */}
|
||||
<div
|
||||
className="flex gap-2 overflow-y-auto scrollbar-styled"
|
||||
style={{
|
||||
maxHeight: `${Math.max(0, submenuPosition.maxHeight - THEME_SUBMENU_CONSTANTS.SUBMENU_HEADER_HEIGHT)}px`,
|
||||
}}
|
||||
>
|
||||
<ThemeColumn
|
||||
title="Dark"
|
||||
icon={Moon}
|
||||
|
||||
@@ -30,17 +30,41 @@ import {
|
||||
import { DndContext, closestCenter } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { SortableProjectItem, ThemeMenuItem } from './';
|
||||
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '../constants';
|
||||
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES, THEME_SUBMENU_CONSTANTS } from '../constants';
|
||||
import { useProjectPicker, useDragAndDrop, useProjectTheme } from '../hooks';
|
||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
|
||||
/**
|
||||
* Props for the ProjectSelectorWithOptions component.
|
||||
* Defines the interface for the project selector dropdown with additional options menu.
|
||||
*/
|
||||
interface ProjectSelectorWithOptionsProps {
|
||||
/** Whether the sidebar is currently expanded */
|
||||
sidebarOpen: boolean;
|
||||
/** Whether the project picker dropdown is currently open */
|
||||
isProjectPickerOpen: boolean;
|
||||
/** Callback to control the project picker dropdown open state */
|
||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||
/** Callback to show the delete project confirmation dialog */
|
||||
setShowDeleteProjectDialog: (show: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A project selector component with search, drag-and-drop reordering, and options menu.
|
||||
*
|
||||
* Features:
|
||||
* - Searchable dropdown for quick project switching
|
||||
* - Drag-and-drop reordering of projects
|
||||
* - Project-specific theme selection with live preview
|
||||
* - Project history navigation (previous/next)
|
||||
* - Option to move project to trash
|
||||
*
|
||||
* The component uses viewport-aware positioning via THEME_SUBMENU_CONSTANTS
|
||||
* for consistent submenu behavior across the application.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns The rendered project selector or null if sidebar is closed or no projects exist
|
||||
*/
|
||||
export function ProjectSelectorWithOptions({
|
||||
sidebarOpen,
|
||||
isProjectPickerOpen,
|
||||
@@ -246,6 +270,7 @@ export function ProjectSelectorWithOptions({
|
||||
<DropdownMenuSubContent
|
||||
className="w-[420px] bg-popover/95 backdrop-blur-xl"
|
||||
data-testid="project-theme-menu"
|
||||
collisionPadding={THEME_SUBMENU_CONSTANTS.COLLISION_PADDING}
|
||||
onPointerLeave={() => {
|
||||
// Clear preview theme when leaving the dropdown
|
||||
setPreviewTheme(null);
|
||||
@@ -286,7 +311,8 @@ export function ProjectSelectorWithOptions({
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Two Column Layout */}
|
||||
<div className="flex gap-2 p-2">
|
||||
{/* Max height with scroll to ensure all themes are visible when menu is near screen edge */}
|
||||
<div className="flex gap-2 p-2 max-h-[60vh] overflow-y-auto scrollbar-styled">
|
||||
{/* Dark Themes Column */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
import { darkThemes, lightThemes } from '@/config/theme-options';
|
||||
|
||||
/**
|
||||
* Shared constants for theme submenu positioning and layout.
|
||||
* Used across project-context-menu and project-selector-with-options components
|
||||
* to ensure consistent viewport-aware positioning and styling.
|
||||
*/
|
||||
export const THEME_SUBMENU_CONSTANTS = {
|
||||
/**
|
||||
* Estimated total height of the theme submenu content in pixels.
|
||||
* Includes all theme options, headers, padding, and "Use Global" button.
|
||||
*/
|
||||
ESTIMATED_SUBMENU_HEIGHT: 620,
|
||||
|
||||
/**
|
||||
* Padding from viewport edges to prevent submenu overflow.
|
||||
* Applied to both top and bottom edges when calculating available space.
|
||||
*/
|
||||
COLLISION_PADDING: 32,
|
||||
|
||||
/**
|
||||
* Vertical offset from context menu top to the "Project Theme" button.
|
||||
* Used for calculating submenu position relative to trigger button.
|
||||
*/
|
||||
THEME_BUTTON_OFFSET: 50,
|
||||
|
||||
/**
|
||||
* Height reserved for submenu header area (includes "Use Global" button and separator).
|
||||
* Subtracted from maxHeight to get scrollable content area height.
|
||||
*/
|
||||
SUBMENU_HEADER_HEIGHT: 80,
|
||||
} as const;
|
||||
|
||||
export const PROJECT_DARK_THEMES = darkThemes.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
|
||||
@@ -93,8 +93,8 @@ const logger = createLogger('Board');
|
||||
export function BoardView() {
|
||||
const {
|
||||
currentProject,
|
||||
maxConcurrency,
|
||||
setMaxConcurrency,
|
||||
maxConcurrency: legacyMaxConcurrency,
|
||||
setMaxConcurrency: legacySetMaxConcurrency,
|
||||
defaultSkipTests,
|
||||
specCreatingForProject,
|
||||
setSpecCreatingForProject,
|
||||
@@ -275,10 +275,24 @@ export function BoardView() {
|
||||
setFeaturesWithContext,
|
||||
});
|
||||
|
||||
// Auto mode hook
|
||||
const autoMode = useAutoMode();
|
||||
// Get runningTasks from the hook (scoped to current project)
|
||||
const runningAutoTasks = autoMode.runningTasks;
|
||||
// Load pipeline config when project changes
|
||||
useEffect(() => {
|
||||
if (!currentProject?.path) return;
|
||||
|
||||
const loadPipelineConfig = async () => {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.pipeline.getConfig(currentProject.path);
|
||||
if (result.success && result.config) {
|
||||
setPipelineConfig(currentProject.path, result.config);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load pipeline config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadPipelineConfig();
|
||||
}, [currentProject?.path, setPipelineConfig]);
|
||||
|
||||
// Window state hook for compact dialog mode
|
||||
const { isMaximized } = useWindowState();
|
||||
@@ -388,14 +402,6 @@ export function BoardView() {
|
||||
[hookFeatures, updateFeature, persistFeatureUpdate]
|
||||
);
|
||||
|
||||
// Get in-progress features for keyboard shortcuts (needed before actions hook)
|
||||
const inProgressFeaturesForShortcuts = useMemo(() => {
|
||||
return hookFeatures.filter((f) => {
|
||||
const isRunning = runningAutoTasks.includes(f.id);
|
||||
return isRunning || f.status === 'in_progress';
|
||||
});
|
||||
}, [hookFeatures, runningAutoTasks]);
|
||||
|
||||
// Get current worktree info (path) for filtering features
|
||||
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
|
||||
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
|
||||
@@ -421,6 +427,16 @@ export function BoardView() {
|
||||
}
|
||||
}, [worktrees, currentWorktreePath]);
|
||||
|
||||
// Auto mode hook - pass current worktree to get worktree-specific state
|
||||
// Must be after selectedWorktree is defined
|
||||
const autoMode = useAutoMode(selectedWorktree ?? undefined);
|
||||
// Get runningTasks from the hook (scoped to current project/worktree)
|
||||
const runningAutoTasks = autoMode.runningTasks;
|
||||
// Get worktree-specific maxConcurrency from the hook
|
||||
const maxConcurrency = autoMode.maxConcurrency;
|
||||
// Get worktree-specific setter
|
||||
const setMaxConcurrencyForWorktree = useAppStore((state) => state.setMaxConcurrencyForWorktree);
|
||||
|
||||
// Get the current branch from the selected worktree (not from store which may be stale)
|
||||
const currentWorktreeBranch = selectedWorktree?.branch ?? null;
|
||||
|
||||
@@ -429,6 +445,15 @@ export function BoardView() {
|
||||
const selectedWorktreeBranch =
|
||||
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
|
||||
|
||||
// Get in-progress features for keyboard shortcuts (needed before actions hook)
|
||||
// Must be after runningAutoTasks is defined
|
||||
const inProgressFeaturesForShortcuts = useMemo(() => {
|
||||
return hookFeatures.filter((f) => {
|
||||
const isRunning = runningAutoTasks.includes(f.id);
|
||||
return isRunning || f.status === 'in_progress';
|
||||
});
|
||||
}, [hookFeatures, runningAutoTasks]);
|
||||
|
||||
// Calculate unarchived card counts per branch
|
||||
const branchCardCounts = useMemo(() => {
|
||||
// Use primary worktree branch as default for features without branchName
|
||||
@@ -526,14 +551,14 @@ export function BoardView() {
|
||||
|
||||
try {
|
||||
// Determine final branch name based on work mode:
|
||||
// - 'current': Empty string to clear branch assignment (work on main/current branch)
|
||||
// - 'current': Use selected worktree branch if available, otherwise undefined (work on main)
|
||||
// - 'auto': Auto-generate branch name based on current branch
|
||||
// - 'custom': Use the provided branch name
|
||||
let finalBranchName: string | undefined;
|
||||
|
||||
if (workMode === 'current') {
|
||||
// Empty string clears the branch assignment, moving features to main/current branch
|
||||
finalBranchName = '';
|
||||
// If a worktree is selected, use its branch; otherwise work on main (undefined = no branch assignment)
|
||||
finalBranchName = currentWorktreeBranch || undefined;
|
||||
} else if (workMode === 'auto') {
|
||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
||||
// Always use primary branch to avoid nested feature/feature/... paths
|
||||
@@ -619,6 +644,7 @@ export function BoardView() {
|
||||
exitSelectionMode,
|
||||
getPrimaryWorktreeBranch,
|
||||
addAndSelectWorktree,
|
||||
currentWorktreeBranch,
|
||||
setWorktreeRefreshKey,
|
||||
]
|
||||
);
|
||||
@@ -1139,7 +1165,21 @@ export function BoardView() {
|
||||
projectPath={currentProject.path}
|
||||
maxConcurrency={maxConcurrency}
|
||||
runningAgentsCount={runningAutoTasks.length}
|
||||
onConcurrencyChange={setMaxConcurrency}
|
||||
onConcurrencyChange={(newMaxConcurrency) => {
|
||||
if (currentProject && selectedWorktree) {
|
||||
const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch;
|
||||
setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency);
|
||||
// Also update backend if auto mode is running
|
||||
if (autoMode.isRunning) {
|
||||
// Restart auto mode with new concurrency (backend will handle this)
|
||||
autoMode.stop().then(() => {
|
||||
autoMode.start().catch((error) => {
|
||||
logger.error('[AutoMode] Failed to restart with new concurrency:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
isAutoModeRunning={autoMode.isRunning}
|
||||
onAutoModeToggle={(enabled) => {
|
||||
if (enabled) {
|
||||
@@ -1406,6 +1446,7 @@ export function BoardView() {
|
||||
featureId={outputFeature?.id || ''}
|
||||
featureStatus={outputFeature?.status}
|
||||
onNumberKeyPress={handleOutputModalNumberKeyPress}
|
||||
branchName={outputFeature?.branchName}
|
||||
/>
|
||||
|
||||
{/* Archive All Verified Dialog */}
|
||||
|
||||
@@ -182,6 +182,13 @@ export function BoardHeader({
|
||||
>
|
||||
Auto Mode
|
||||
</Label>
|
||||
<span
|
||||
className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded"
|
||||
data-testid="auto-mode-max-concurrency"
|
||||
title="Max concurrent agents"
|
||||
>
|
||||
{maxConcurrency}
|
||||
</span>
|
||||
<Switch
|
||||
id="auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
|
||||
@@ -29,6 +29,8 @@ interface AgentOutputModalProps {
|
||||
onNumberKeyPress?: (key: string) => void;
|
||||
/** Project path - if not provided, falls back to window.__currentProject for backward compatibility */
|
||||
projectPath?: string;
|
||||
/** Branch name for the feature worktree - used when viewing changes */
|
||||
branchName?: string;
|
||||
}
|
||||
|
||||
type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes';
|
||||
@@ -41,6 +43,7 @@ export function AgentOutputModal({
|
||||
featureStatus,
|
||||
onNumberKeyPress,
|
||||
projectPath: projectPathProp,
|
||||
branchName,
|
||||
}: AgentOutputModalProps) {
|
||||
const isBacklogPlan = featureId.startsWith('backlog-plan:');
|
||||
|
||||
@@ -404,7 +407,7 @@ export function AgentOutputModal({
|
||||
{resolvedProjectPath ? (
|
||||
<GitDiffPanel
|
||||
projectPath={resolvedProjectPath}
|
||||
featureId={featureId}
|
||||
featureId={branchName || featureId}
|
||||
compact={false}
|
||||
useWorktrees={useWorktrees}
|
||||
className="border-0 rounded-lg"
|
||||
|
||||
@@ -8,3 +8,4 @@ export { EditFeatureDialog } from './edit-feature-dialog';
|
||||
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
|
||||
export { PlanApprovalDialog } from './plan-approval-dialog';
|
||||
export { MassEditDialog } from './mass-edit-dialog';
|
||||
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
interface ViewWorktreeChangesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
export function ViewWorktreeChangesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
projectPath,
|
||||
}: ViewWorktreeChangesDialogProps) {
|
||||
if (!worktree) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
View Changes
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Changes in the{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> worktree.
|
||||
{worktree.changedFilesCount !== undefined && worktree.changedFilesCount > 0 && (
|
||||
<span className="ml-1">
|
||||
({worktree.changedFilesCount} file
|
||||
{worktree.changedFilesCount > 1 ? 's' : ''} changed)
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 sm:min-h-[600px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
|
||||
<div className="h-full px-6 pb-6">
|
||||
<GitDiffPanel
|
||||
projectPath={projectPath}
|
||||
featureId={worktree.branch}
|
||||
useWorktrees={true}
|
||||
compact={false}
|
||||
className="mt-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -80,6 +80,13 @@ export function HeaderMobileMenu({
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Auto Mode</span>
|
||||
<span
|
||||
className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded"
|
||||
data-testid="mobile-auto-mode-max-concurrency"
|
||||
title="Max concurrent agents"
|
||||
>
|
||||
{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
|
||||
@@ -123,14 +123,15 @@ export function useBoardActions({
|
||||
const workMode = featureData.workMode || 'current';
|
||||
|
||||
// Determine final branch name based on work mode:
|
||||
// - 'current': No branch name, work on current branch (no worktree)
|
||||
// - 'current': Use current worktree's branch (or undefined if on main)
|
||||
// - 'auto': Auto-generate branch name based on current branch
|
||||
// - 'custom': Use the provided branch name
|
||||
let finalBranchName: string | undefined;
|
||||
|
||||
if (workMode === 'current') {
|
||||
// No worktree isolation - work directly on current branch
|
||||
finalBranchName = undefined;
|
||||
// Work directly on current branch - use the current worktree's branch if not on main
|
||||
// This ensures features created on a non-main worktree are associated with that worktree
|
||||
finalBranchName = currentWorktreeBranch || undefined;
|
||||
} else if (workMode === 'auto') {
|
||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
||||
// Always use primary branch to avoid nested feature/feature/... paths
|
||||
@@ -217,7 +218,7 @@ export function useBoardActions({
|
||||
const api = getElectronAPI();
|
||||
if (api?.features?.generateTitle) {
|
||||
api.features
|
||||
.generateTitle(featureData.description)
|
||||
.generateTitle(featureData.description, projectPath ?? undefined)
|
||||
.then((result) => {
|
||||
if (result.success && result.title) {
|
||||
const titleUpdates = {
|
||||
@@ -250,10 +251,12 @@ export function useBoardActions({
|
||||
updateFeature,
|
||||
saveCategory,
|
||||
currentProject,
|
||||
projectPath,
|
||||
onWorktreeCreated,
|
||||
onWorktreeAutoSelect,
|
||||
getPrimaryWorktreeBranch,
|
||||
features,
|
||||
currentWorktreeBranch,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -287,7 +290,9 @@ export function useBoardActions({
|
||||
let finalBranchName: string | undefined;
|
||||
|
||||
if (workMode === 'current') {
|
||||
finalBranchName = undefined;
|
||||
// Work directly on current branch - use the current worktree's branch if not on main
|
||||
// This ensures features updated on a non-main worktree are associated with that worktree
|
||||
finalBranchName = currentWorktreeBranch || undefined;
|
||||
} else if (workMode === 'auto') {
|
||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
||||
// Always use primary branch to avoid nested feature/feature/... paths
|
||||
@@ -402,6 +407,7 @@ export function useBoardActions({
|
||||
onWorktreeCreated,
|
||||
getPrimaryWorktreeBranch,
|
||||
features,
|
||||
currentWorktreeBranch,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -103,8 +103,25 @@ export function useBoardColumnFeatures({
|
||||
// Historically, we forced "running" features into in_progress so they never disappeared
|
||||
// during stale reload windows. With pipelines, a feature can legitimately be running while
|
||||
// its status is `pipeline_*`, so we must respect that status to render it in the right column.
|
||||
// NOTE: runningAutoTasks is already worktree-scoped, so if a feature is in runningAutoTasks,
|
||||
// it's already running for the current worktree. However, we still need to check matchesWorktree
|
||||
// to ensure the feature's branchName matches the current worktree's branch.
|
||||
if (isRunning) {
|
||||
if (!matchesWorktree) return;
|
||||
// If feature is running but doesn't match worktree, it might be a timing issue where
|
||||
// the feature was started for a different worktree. Still show it if it's running to
|
||||
// prevent disappearing features, but log a warning.
|
||||
if (!matchesWorktree) {
|
||||
// This can happen if:
|
||||
// 1. Feature was started for a different worktree (bug)
|
||||
// 2. Timing issue where branchName hasn't been set yet
|
||||
// 3. User switched worktrees while feature was starting
|
||||
// Still show it in in_progress to prevent it from disappearing
|
||||
console.debug(
|
||||
`Feature ${f.id} is running but branchName (${featureBranch}) doesn't match current worktree branch (${effectiveBranch}) - showing anyway to prevent disappearing`
|
||||
);
|
||||
map.in_progress.push(f);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.startsWith('pipeline_')) {
|
||||
if (!map[status]) map[status] = [];
|
||||
|
||||
@@ -50,7 +50,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
} catch {
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
}, [currentProject]);
|
||||
}, [currentProject, loadFeatures]);
|
||||
|
||||
// Save a new category to the persisted categories file
|
||||
const saveCategory = useCallback(
|
||||
@@ -87,11 +87,33 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
|
||||
const { removeRunningTask } = useAppStore.getState();
|
||||
const projectId = currentProject.id;
|
||||
const projectPath = currentProject.path;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
// Check if event is for the current project by matching projectPath
|
||||
const eventProjectPath = ('projectPath' in event && event.projectPath) as string | undefined;
|
||||
if (eventProjectPath && eventProjectPath !== projectPath) {
|
||||
// Event is for a different project, ignore it
|
||||
logger.debug(
|
||||
`Ignoring auto mode event for different project: ${eventProjectPath} (current: ${projectPath})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use event's projectPath or projectId if available, otherwise use current project
|
||||
// Board view only reacts to events for the currently selected project
|
||||
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
|
||||
|
||||
if (event.type === 'auto_mode_feature_complete') {
|
||||
if (event.type === 'auto_mode_feature_start') {
|
||||
// Reload features when a feature starts to ensure status update (backlog -> in_progress) is reflected
|
||||
logger.info(
|
||||
`[BoardFeatures] Feature ${event.featureId} started for project ${projectPath}, reloading features to update status...`
|
||||
);
|
||||
loadFeatures();
|
||||
} else if (event.type === 'auto_mode_feature_complete') {
|
||||
// Reload features when a feature is completed
|
||||
logger.info('Feature completed, reloading features...');
|
||||
loadFeatures();
|
||||
// Play ding sound when feature is done (unless muted)
|
||||
const { muteDoneSound } = useAppStore.getState();
|
||||
if (!muteDoneSound) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
|
||||
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
const logger = createLogger('EnhanceWithAI');
|
||||
|
||||
@@ -56,6 +57,9 @@ export function EnhanceWithAI({
|
||||
const [enhancementMode, setEnhancementMode] = useState<EnhancementMode>('improve');
|
||||
const [enhanceOpen, setEnhanceOpen] = useState(false);
|
||||
|
||||
// Get current project path for per-project Claude API profile
|
||||
const currentProjectPath = useAppStore((state) => state.currentProject?.path);
|
||||
|
||||
// Enhancement model override
|
||||
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
|
||||
|
||||
@@ -69,7 +73,8 @@ export function EnhanceWithAI({
|
||||
value,
|
||||
enhancementMode,
|
||||
enhancementOverride.effectiveModel,
|
||||
enhancementOverride.effectiveModelEntry.thinkingLevel
|
||||
enhancementOverride.effectiveModelEntry.thinkingLevel,
|
||||
currentProjectPath
|
||||
);
|
||||
|
||||
if (result?.success && result.enhancedText) {
|
||||
|
||||
@@ -132,11 +132,12 @@ export function DevServerLogsPanel({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent
|
||||
className="w-[70vw] max-w-[900px] max-h-[85vh] flex flex-col gap-0 p-0 overflow-hidden"
|
||||
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden"
|
||||
data-testid="dev-server-logs-panel"
|
||||
compact
|
||||
>
|
||||
{/* Compact Header */}
|
||||
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50">
|
||||
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<Terminal className="w-4 h-4 text-primary" />
|
||||
|
||||
@@ -25,10 +25,13 @@ import {
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
Eye,
|
||||
ScrollText,
|
||||
Terminal,
|
||||
SquarePlus,
|
||||
SplitSquareHorizontal,
|
||||
Zap,
|
||||
Undo2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -56,12 +59,16 @@ interface WorktreeActionsDropdownProps {
|
||||
gitRepoStatus: GitRepoStatus;
|
||||
/** When true, renders as a standalone button (not attached to another element) */
|
||||
standalone?: boolean;
|
||||
/** Whether auto mode is running for this worktree */
|
||||
isAutoModeRunning?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||
onViewChanges: (worktree: WorktreeInfo) => void;
|
||||
onDiscardChanges: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
@@ -73,6 +80,7 @@ interface WorktreeActionsDropdownProps {
|
||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
}
|
||||
|
||||
@@ -88,12 +96,15 @@ export function WorktreeActionsDropdown({
|
||||
devServerInfo,
|
||||
gitRepoStatus,
|
||||
standalone = false,
|
||||
isAutoModeRunning = false,
|
||||
onOpenChange,
|
||||
onPull,
|
||||
onPush,
|
||||
onOpenInEditor,
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
onViewChanges,
|
||||
onDiscardChanges,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
@@ -105,6 +116,7 @@ export function WorktreeActionsDropdown({
|
||||
onOpenDevServerUrl,
|
||||
onViewDevServerLogs,
|
||||
onRunInitScript,
|
||||
onToggleAutoMode,
|
||||
hasInitScript,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
// Get available editors for the "Open In" submenu
|
||||
@@ -214,6 +226,26 @@ export function WorktreeActionsDropdown({
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{/* Auto Mode toggle */}
|
||||
{onToggleAutoMode && (
|
||||
<>
|
||||
{isAutoModeRunning ? (
|
||||
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
|
||||
<span className="flex items-center mr-2">
|
||||
<Zap className="w-3.5 h-3.5 text-yellow-500" />
|
||||
<span className="ml-0.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
</span>
|
||||
Stop Auto Mode
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
|
||||
<Zap className="w-3.5 h-3.5 mr-2" />
|
||||
Start Auto Mode
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => canPerformGitOps && onPull(worktree)}
|
||||
@@ -408,6 +440,13 @@ export function WorktreeActionsDropdown({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{worktree.hasChanges && (
|
||||
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs">
|
||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||
View Changes
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{worktree.hasChanges && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!gitRepoStatus.isGitRepo}
|
||||
@@ -483,9 +522,30 @@ export function WorktreeActionsDropdown({
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{worktree.hasChanges && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!gitRepoStatus.isGitRepo}
|
||||
tooltipContent="Not a git repository"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => gitRepoStatus.isGitRepo && onDiscardChanges(worktree)}
|
||||
disabled={!gitRepoStatus.isGitRepo}
|
||||
className={cn(
|
||||
'text-xs text-destructive focus:text-destructive',
|
||||
!gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Undo2 className="w-3.5 h-3.5 mr-2" />
|
||||
Discard Changes
|
||||
{!gitRepoStatus.isGitRepo && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
{!worktree.isMain && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDeleteWorktree(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
|
||||
@@ -29,6 +29,8 @@ interface WorktreeTabProps {
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
gitRepoStatus: GitRepoStatus;
|
||||
/** Whether auto mode is running for this worktree */
|
||||
isAutoModeRunning?: boolean;
|
||||
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||
onBranchDropdownOpenChange: (open: boolean) => void;
|
||||
onActionsDropdownOpenChange: (open: boolean) => void;
|
||||
@@ -40,6 +42,8 @@ interface WorktreeTabProps {
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||
onViewChanges: (worktree: WorktreeInfo) => void;
|
||||
onDiscardChanges: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
@@ -51,6 +55,7 @@ interface WorktreeTabProps {
|
||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
}
|
||||
|
||||
@@ -75,6 +80,7 @@ export function WorktreeTab({
|
||||
aheadCount,
|
||||
behindCount,
|
||||
gitRepoStatus,
|
||||
isAutoModeRunning = false,
|
||||
onSelectWorktree,
|
||||
onBranchDropdownOpenChange,
|
||||
onActionsDropdownOpenChange,
|
||||
@@ -86,6 +92,8 @@ export function WorktreeTab({
|
||||
onOpenInEditor,
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
onViewChanges,
|
||||
onDiscardChanges,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
@@ -97,6 +105,7 @@ export function WorktreeTab({
|
||||
onOpenDevServerUrl,
|
||||
onViewDevServerLogs,
|
||||
onRunInitScript,
|
||||
onToggleAutoMode,
|
||||
hasInitScript,
|
||||
}: WorktreeTabProps) {
|
||||
let prBadge: JSX.Element | null = null;
|
||||
@@ -332,6 +341,26 @@ export function WorktreeTab({
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{isAutoModeRunning && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center justify-center h-7 px-1.5 rounded-none border-r-0',
|
||||
isSelected ? 'bg-primary text-primary-foreground' : 'bg-secondary/50'
|
||||
)}
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Auto Mode Running</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<WorktreeActionsDropdown
|
||||
worktree={worktree}
|
||||
isSelected={isSelected}
|
||||
@@ -343,12 +372,15 @@ export function WorktreeTab({
|
||||
isDevServerRunning={isDevServerRunning}
|
||||
devServerInfo={devServerInfo}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunning}
|
||||
onOpenChange={onActionsDropdownOpenChange}
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
||||
onViewChanges={onViewChanges}
|
||||
onDiscardChanges={onDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
@@ -360,6 +392,7 @@ export function WorktreeTab({
|
||||
onOpenDevServerUrl={onOpenDevServerUrl}
|
||||
onViewDevServerLogs={onViewDevServerLogs}
|
||||
onRunInitScript={onRunInitScript}
|
||||
onToggleAutoMode={onToggleAutoMode}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn, pathsEqual } from '@/lib/utils';
|
||||
import { pathsEqual } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
WorktreeActionsDropdown,
|
||||
BranchSwitchDropdown,
|
||||
} from './components';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { ViewWorktreeChangesDialog } from '../dialogs';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { Undo2 } from 'lucide-react';
|
||||
|
||||
export function WorktreePanel({
|
||||
projectPath,
|
||||
@@ -51,7 +55,6 @@ export function WorktreePanel({
|
||||
|
||||
const {
|
||||
isStartingDevServer,
|
||||
getWorktreeKey,
|
||||
isDevServerRunning,
|
||||
getDevServerInfo,
|
||||
handleStartDevServer,
|
||||
@@ -90,10 +93,79 @@ export function WorktreePanel({
|
||||
features,
|
||||
});
|
||||
|
||||
// Auto-mode state management using the store
|
||||
// Use separate selectors to avoid creating new object references on each render
|
||||
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
|
||||
const currentProject = useAppStore((state) => state.currentProject);
|
||||
|
||||
// Helper to generate worktree key for auto-mode (inlined to avoid selector issues)
|
||||
const getAutoModeWorktreeKey = useCallback(
|
||||
(projectId: string, branchName: string | null): string => {
|
||||
return `${projectId}::${branchName ?? '__main__'}`;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Helper to check if auto-mode is running for a specific worktree
|
||||
const isAutoModeRunningForWorktree = useCallback(
|
||||
(worktree: WorktreeInfo): boolean => {
|
||||
if (!currentProject) return false;
|
||||
const branchName = worktree.isMain ? null : worktree.branch;
|
||||
const key = getAutoModeWorktreeKey(currentProject.id, branchName);
|
||||
return autoModeByWorktree[key]?.isRunning ?? false;
|
||||
},
|
||||
[currentProject, autoModeByWorktree, getAutoModeWorktreeKey]
|
||||
);
|
||||
|
||||
// Handler to toggle auto-mode for a worktree
|
||||
const handleToggleAutoMode = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
// Import the useAutoMode to get start/stop functions
|
||||
// Since useAutoMode is a hook, we'll use the API client directly
|
||||
const api = getHttpApiClient();
|
||||
const branchName = worktree.isMain ? null : worktree.branch;
|
||||
const isRunning = isAutoModeRunningForWorktree(worktree);
|
||||
|
||||
try {
|
||||
if (isRunning) {
|
||||
const result = await api.autoMode.stop(projectPath, branchName);
|
||||
if (result.success) {
|
||||
const desc = branchName ? `worktree ${branchName}` : 'main branch';
|
||||
toast.success(`Auto Mode stopped for ${desc}`);
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to stop Auto Mode');
|
||||
}
|
||||
} else {
|
||||
const result = await api.autoMode.start(projectPath, branchName);
|
||||
if (result.success) {
|
||||
const desc = branchName ? `worktree ${branchName}` : 'main branch';
|
||||
toast.success(`Auto Mode started for ${desc}`);
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to start Auto Mode');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Error toggling Auto Mode');
|
||||
console.error('Auto mode toggle error:', error);
|
||||
}
|
||||
},
|
||||
[currentProject, projectPath, isAutoModeRunningForWorktree]
|
||||
);
|
||||
|
||||
// Check if init script exists for the project using React Query
|
||||
const { data: initScriptData } = useWorktreeInitScript(projectPath);
|
||||
const hasInitScript = initScriptData?.exists ?? false;
|
||||
|
||||
// View changes dialog state
|
||||
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
|
||||
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
// Discard changes confirmation dialog state
|
||||
const [discardChangesDialogOpen, setDiscardChangesDialogOpen] = useState(false);
|
||||
const [discardChangesWorktree, setDiscardChangesWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
// Log panel state management
|
||||
const [logPanelOpen, setLogPanelOpen] = useState(false);
|
||||
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
|
||||
@@ -161,6 +233,41 @@ export function WorktreePanel({
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
const handleViewChanges = useCallback((worktree: WorktreeInfo) => {
|
||||
setViewChangesWorktree(worktree);
|
||||
setViewChangesDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDiscardChanges = useCallback((worktree: WorktreeInfo) => {
|
||||
setDiscardChangesWorktree(worktree);
|
||||
setDiscardChangesDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDiscardChanges = useCallback(async () => {
|
||||
if (!discardChangesWorktree) return;
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.discardChanges(discardChangesWorktree.path);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Changes discarded', {
|
||||
description: `Discarded changes in ${discardChangesWorktree.branch}`,
|
||||
});
|
||||
// Refresh worktrees to update the changes status
|
||||
fetchWorktrees({ silent: true });
|
||||
} else {
|
||||
toast.error('Failed to discard changes', {
|
||||
description: result.error || 'Unknown error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to discard changes', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}, [discardChangesWorktree, fetchWorktrees]);
|
||||
|
||||
// Handle opening the log panel for a specific worktree
|
||||
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
|
||||
setLogPanelWorktree(worktree);
|
||||
@@ -224,12 +331,15 @@ export function WorktreePanel({
|
||||
isDevServerRunning={isDevServerRunning(selectedWorktree)}
|
||||
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
|
||||
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
@@ -241,6 +351,7 @@ export function WorktreePanel({
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
)}
|
||||
@@ -274,6 +385,36 @@ export function WorktreePanel({
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* View Changes Dialog */}
|
||||
<ViewWorktreeChangesDialog
|
||||
open={viewChangesDialogOpen}
|
||||
onOpenChange={setViewChangesDialogOpen}
|
||||
worktree={viewChangesWorktree}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
|
||||
{/* Discard Changes Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
open={discardChangesDialogOpen}
|
||||
onOpenChange={setDiscardChangesDialogOpen}
|
||||
onConfirm={handleConfirmDiscardChanges}
|
||||
title="Discard Changes"
|
||||
description={`Are you sure you want to discard all changes in "${discardChangesWorktree?.branch}"? This will reset staged changes, discard modifications to tracked files, and remove untracked files. This action cannot be undone.`}
|
||||
icon={Undo2}
|
||||
iconClassName="text-destructive"
|
||||
confirmText="Discard Changes"
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
|
||||
{/* Dev Server Logs Panel */}
|
||||
<DevServerLogsPanel
|
||||
open={logPanelOpen}
|
||||
onClose={handleCloseLogPanel}
|
||||
worktree={logPanelWorktree}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -308,6 +449,7 @@ export function WorktreePanel({
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||
@@ -319,6 +461,8 @@ export function WorktreePanel({
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
@@ -330,6 +474,7 @@ export function WorktreePanel({
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
)}
|
||||
@@ -368,6 +513,7 @@ export function WorktreePanel({
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
@@ -379,6 +525,8 @@ export function WorktreePanel({
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
@@ -390,6 +538,7 @@ export function WorktreePanel({
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
);
|
||||
@@ -424,6 +573,27 @@ export function WorktreePanel({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* View Changes Dialog */}
|
||||
<ViewWorktreeChangesDialog
|
||||
open={viewChangesDialogOpen}
|
||||
onOpenChange={setViewChangesDialogOpen}
|
||||
worktree={viewChangesWorktree}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
|
||||
{/* Discard Changes Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
open={discardChangesDialogOpen}
|
||||
onOpenChange={setDiscardChangesDialogOpen}
|
||||
onConfirm={handleConfirmDiscardChanges}
|
||||
title="Discard Changes"
|
||||
description={`Are you sure you want to discard all changes in "${discardChangesWorktree?.branch}"? This will reset staged changes, discard modifications to tracked files, and remove untracked files. This action cannot be undone.`}
|
||||
icon={Undo2}
|
||||
iconClassName="text-destructive"
|
||||
confirmText="Discard Changes"
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
|
||||
{/* Dev Server Logs Panel */}
|
||||
<DevServerLogsPanel
|
||||
open={logPanelOpen}
|
||||
|
||||
@@ -432,6 +432,7 @@ export function GraphViewPage() {
|
||||
featureId={outputFeature?.id || ''}
|
||||
featureStatus={outputFeature?.status}
|
||||
onNumberKeyPress={handleOutputModalNumberKeyPress}
|
||||
branchName={outputFeature?.branchName}
|
||||
/>
|
||||
|
||||
{/* Backlog Plan Dialog */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { User, GitBranch, Palette, AlertTriangle } from 'lucide-react';
|
||||
import { User, GitBranch, Palette, AlertTriangle, Bot } from 'lucide-react';
|
||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||
|
||||
export interface ProjectNavigationItem {
|
||||
@@ -12,5 +12,6 @@ export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
||||
{ id: 'identity', label: 'Identity', icon: User },
|
||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||
{ id: 'claude', label: 'Claude', icon: Bot },
|
||||
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'danger';
|
||||
export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'claude' | 'danger';
|
||||
|
||||
interface UseProjectSettingsViewOptions {
|
||||
initialView?: ProjectSettingsViewId;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Bot, Cloud, Server, Globe } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface ProjectClaudeSectionProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export function ProjectClaudeSection({ project }: ProjectClaudeSectionProps) {
|
||||
const {
|
||||
claudeApiProfiles,
|
||||
activeClaudeApiProfileId: globalActiveProfileId,
|
||||
disabledProviders,
|
||||
setProjectClaudeApiProfile,
|
||||
} = useAppStore();
|
||||
const { claudeAuthStatus } = useSetupStore();
|
||||
|
||||
// Get project-level override from project
|
||||
const projectActiveProfileId = project.activeClaudeApiProfileId;
|
||||
|
||||
// Determine effective value for display
|
||||
// undefined = use global, null = explicit direct, string = specific profile
|
||||
const selectValue =
|
||||
projectActiveProfileId === undefined
|
||||
? 'global'
|
||||
: projectActiveProfileId === null
|
||||
? 'direct'
|
||||
: projectActiveProfileId;
|
||||
|
||||
// Check if Claude is available
|
||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||
const hasProfiles = claudeApiProfiles.length > 0;
|
||||
const isClaudeAuthenticated = claudeAuthStatus?.authenticated;
|
||||
|
||||
// Get global profile name for display
|
||||
const globalProfile = globalActiveProfileId
|
||||
? claudeApiProfiles.find((p) => p.id === globalActiveProfileId)
|
||||
: null;
|
||||
const globalProfileName = globalProfile?.name || 'Direct Anthropic API';
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
// 'global' -> undefined (use global)
|
||||
// 'direct' -> null (explicit direct)
|
||||
// profile id -> string (specific profile)
|
||||
const newValue = value === 'global' ? undefined : value === 'direct' ? null : value;
|
||||
setProjectClaudeApiProfile(project.id, newValue);
|
||||
};
|
||||
|
||||
// Don't render if Claude is disabled or not available
|
||||
if (isClaudeDisabled || (!hasProfiles && !isClaudeAuthenticated)) {
|
||||
return (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Bot className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">Claude not configured</p>
|
||||
<p className="text-xs mt-1">
|
||||
Enable Claude and configure API profiles in global settings to use per-project profiles.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get the display text for current selection
|
||||
const getDisplayText = () => {
|
||||
if (selectValue === 'global') {
|
||||
return `Using global setting: ${globalProfileName}`;
|
||||
}
|
||||
if (selectValue === 'direct') {
|
||||
return 'Using direct Anthropic API (API key or Claude Max plan)';
|
||||
}
|
||||
const selectedProfile = claudeApiProfiles.find((p) => p.id === selectValue);
|
||||
return `Using ${selectedProfile?.name || 'custom'} endpoint`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Bot className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Claude API Profile
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Override the Claude API profile for this project only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Active Profile for This Project</Label>
|
||||
<Select value={selectValue} onValueChange={handleChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select profile" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||
<span>Use Global Setting</span>
|
||||
<span className="text-xs text-muted-foreground ml-1">({globalProfileName})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="direct">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="w-4 h-4 text-brand-500" />
|
||||
<span>Direct Anthropic API</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{claudeApiProfiles.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-muted-foreground" />
|
||||
<span>{profile.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{getDisplayText()}</p>
|
||||
</div>
|
||||
|
||||
{/* Info about what this affects */}
|
||||
<div className="text-xs text-muted-foreground/70 pt-2 border-t border-border/30">
|
||||
<p>This setting affects all Claude operations for this project including:</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-0.5">
|
||||
<li>Agent chat and feature implementation</li>
|
||||
<li>Code analysis and suggestions</li>
|
||||
<li>Commit message generation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { ProjectIdentitySection } from './project-identity-section';
|
||||
import { ProjectThemeSection } from './project-theme-section';
|
||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
import { ProjectClaudeSection } from './project-claude-section';
|
||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
||||
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||
@@ -84,6 +85,8 @@ export function ProjectSettingsView() {
|
||||
return <ProjectThemeSection project={currentProject} />;
|
||||
case 'worktrees':
|
||||
return <WorktreePreferencesSection project={currentProject} />;
|
||||
case 'claude':
|
||||
return <ProjectClaudeSection project={currentProject} />;
|
||||
case 'danger':
|
||||
return (
|
||||
<DangerZoneSection
|
||||
|
||||
@@ -223,6 +223,7 @@ export function RunningAgentsView() {
|
||||
}
|
||||
featureId={selectedAgent.featureId}
|
||||
featureStatus="running"
|
||||
branchName={selectedAgent.branchName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Key, CheckCircle2, Trash2 } from 'lucide-react';
|
||||
import { Key, CheckCircle2, Trash2, Info } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { ApiKeyField } from './api-key-field';
|
||||
import { buildProviderConfigs } from '@/config/api-providers';
|
||||
@@ -101,9 +101,38 @@ export function ApiKeysSection() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* API Key Fields */}
|
||||
{/* API Key Fields with contextual info */}
|
||||
{providerConfigs.map((provider) => (
|
||||
<ApiKeyField key={provider.key} config={provider} />
|
||||
<div key={provider.key}>
|
||||
<ApiKeyField config={provider} />
|
||||
{/* Anthropic-specific profile info */}
|
||||
{provider.key === 'anthropic' && (
|
||||
<div className="mt-3 p-3 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
||||
<div className="flex gap-2">
|
||||
<Info className="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>
|
||||
<span className="font-medium text-foreground/80">
|
||||
Using Claude API Profiles?
|
||||
</span>{' '}
|
||||
Create a profile in{' '}
|
||||
<span className="text-blue-500">AI Providers → Claude</span> with{' '}
|
||||
<span className="font-mono text-[10px] bg-muted/50 px-1 rounded">
|
||||
credentials
|
||||
</span>{' '}
|
||||
as the API key source to use this key.
|
||||
</p>
|
||||
<p>
|
||||
For alternative providers (z.AI GLM, MiniMax, OpenRouter), create a profile
|
||||
with{' '}
|
||||
<span className="font-mono text-[10px] bg-muted/50 px-1 rounded">inline</span>{' '}
|
||||
key source and enter the provider's API key directly in the profile.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Security Notice */}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ClaudeMdSettings } from '../claude/claude-md-settings';
|
||||
import { ClaudeUsageSection } from '../api-keys/claude-usage-section';
|
||||
import { SkillsSection } from './claude-settings-tab/skills-section';
|
||||
import { SubagentsSection } from './claude-settings-tab/subagents-section';
|
||||
import { ApiProfilesSection } from './claude-settings-tab/api-profiles-section';
|
||||
import { ProviderToggle } from './provider-toggle';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
@@ -45,6 +46,10 @@ export function ClaudeSettingsTab() {
|
||||
isChecking={isCheckingClaudeCli}
|
||||
onRefresh={handleRefreshClaudeCli}
|
||||
/>
|
||||
|
||||
{/* API Profiles for Claude-compatible endpoints */}
|
||||
<ApiProfilesSection />
|
||||
|
||||
<ClaudeMdSettings
|
||||
autoLoadClaudeMd={autoLoadClaudeMd}
|
||||
onAutoLoadClaudeMdChange={setAutoLoadClaudeMd}
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Cloud,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Server,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { ClaudeApiProfile, ApiKeySource } from '@automaker/types';
|
||||
import { CLAUDE_API_PROFILE_TEMPLATES } from '@automaker/types';
|
||||
|
||||
// Generate unique ID for profiles
|
||||
function generateProfileId(): string {
|
||||
return `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Mask API key for display (show first 4 + last 4 chars)
|
||||
function maskApiKey(key?: string): string {
|
||||
if (!key || key.length <= 8) return '••••••••';
|
||||
return `${key.substring(0, 4)}••••${key.substring(key.length - 4)}`;
|
||||
}
|
||||
|
||||
interface ProfileFormData {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKeySource: ApiKeySource;
|
||||
apiKey: string;
|
||||
useAuthToken: boolean;
|
||||
timeoutMs: string; // String for input, convert to number
|
||||
modelMappings: {
|
||||
haiku: string;
|
||||
sonnet: string;
|
||||
opus: string;
|
||||
};
|
||||
disableNonessentialTraffic: boolean;
|
||||
}
|
||||
|
||||
const emptyFormData: ProfileFormData = {
|
||||
name: '',
|
||||
baseUrl: '',
|
||||
apiKeySource: 'inline',
|
||||
apiKey: '',
|
||||
useAuthToken: false,
|
||||
timeoutMs: '',
|
||||
modelMappings: {
|
||||
haiku: '',
|
||||
sonnet: '',
|
||||
opus: '',
|
||||
},
|
||||
disableNonessentialTraffic: false,
|
||||
};
|
||||
|
||||
export function ApiProfilesSection() {
|
||||
const {
|
||||
claudeApiProfiles,
|
||||
activeClaudeApiProfileId,
|
||||
addClaudeApiProfile,
|
||||
updateClaudeApiProfile,
|
||||
deleteClaudeApiProfile,
|
||||
setActiveClaudeApiProfile,
|
||||
} = useAppStore();
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingProfileId, setEditingProfileId] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<ProfileFormData>(emptyFormData);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [currentTemplate, setCurrentTemplate] = useState<
|
||||
(typeof CLAUDE_API_PROFILE_TEMPLATES)[0] | null
|
||||
>(null);
|
||||
|
||||
const handleOpenAddDialog = (templateName?: string) => {
|
||||
const template = templateName
|
||||
? CLAUDE_API_PROFILE_TEMPLATES.find((t) => t.name === templateName)
|
||||
: undefined;
|
||||
|
||||
if (template) {
|
||||
setFormData({
|
||||
name: template.name,
|
||||
baseUrl: template.baseUrl,
|
||||
apiKeySource: template.defaultApiKeySource ?? 'inline',
|
||||
apiKey: '',
|
||||
useAuthToken: template.useAuthToken,
|
||||
timeoutMs: template.timeoutMs?.toString() ?? '',
|
||||
modelMappings: {
|
||||
haiku: template.modelMappings?.haiku ?? '',
|
||||
sonnet: template.modelMappings?.sonnet ?? '',
|
||||
opus: template.modelMappings?.opus ?? '',
|
||||
},
|
||||
disableNonessentialTraffic: template.disableNonessentialTraffic ?? false,
|
||||
});
|
||||
setCurrentTemplate(template);
|
||||
} else {
|
||||
setFormData(emptyFormData);
|
||||
setCurrentTemplate(null);
|
||||
}
|
||||
|
||||
setEditingProfileId(null);
|
||||
setShowApiKey(false);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEditDialog = (profile: ClaudeApiProfile) => {
|
||||
// Find matching template by base URL
|
||||
const template = CLAUDE_API_PROFILE_TEMPLATES.find((t) => t.baseUrl === profile.baseUrl);
|
||||
|
||||
setFormData({
|
||||
name: profile.name,
|
||||
baseUrl: profile.baseUrl,
|
||||
apiKeySource: profile.apiKeySource ?? 'inline',
|
||||
apiKey: profile.apiKey ?? '',
|
||||
useAuthToken: profile.useAuthToken ?? false,
|
||||
timeoutMs: profile.timeoutMs?.toString() ?? '',
|
||||
modelMappings: {
|
||||
haiku: profile.modelMappings?.haiku ?? '',
|
||||
sonnet: profile.modelMappings?.sonnet ?? '',
|
||||
opus: profile.modelMappings?.opus ?? '',
|
||||
},
|
||||
disableNonessentialTraffic: profile.disableNonessentialTraffic ?? false,
|
||||
});
|
||||
setEditingProfileId(profile.id);
|
||||
setCurrentTemplate(template ?? null);
|
||||
setShowApiKey(false);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const profileData: ClaudeApiProfile = {
|
||||
id: editingProfileId ?? generateProfileId(),
|
||||
name: formData.name.trim(),
|
||||
baseUrl: formData.baseUrl.trim(),
|
||||
apiKeySource: formData.apiKeySource,
|
||||
// Only include apiKey when source is 'inline'
|
||||
apiKey: formData.apiKeySource === 'inline' ? formData.apiKey : undefined,
|
||||
useAuthToken: formData.useAuthToken,
|
||||
timeoutMs: (() => {
|
||||
const parsed = Number(formData.timeoutMs);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
})(),
|
||||
modelMappings:
|
||||
formData.modelMappings.haiku || formData.modelMappings.sonnet || formData.modelMappings.opus
|
||||
? {
|
||||
...(formData.modelMappings.haiku && { haiku: formData.modelMappings.haiku }),
|
||||
...(formData.modelMappings.sonnet && { sonnet: formData.modelMappings.sonnet }),
|
||||
...(formData.modelMappings.opus && { opus: formData.modelMappings.opus }),
|
||||
}
|
||||
: undefined,
|
||||
disableNonessentialTraffic: formData.disableNonessentialTraffic || undefined,
|
||||
};
|
||||
|
||||
if (editingProfileId) {
|
||||
updateClaudeApiProfile(editingProfileId, profileData);
|
||||
} else {
|
||||
addClaudeApiProfile(profileData);
|
||||
}
|
||||
|
||||
setIsDialogOpen(false);
|
||||
setFormData(emptyFormData);
|
||||
setEditingProfileId(null);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
deleteClaudeApiProfile(id);
|
||||
setDeleteConfirmId(null);
|
||||
};
|
||||
|
||||
// Check for duplicate profile name (case-insensitive, excluding current profile when editing)
|
||||
const isDuplicateName = claudeApiProfiles.some(
|
||||
(p) => p.name.toLowerCase() === formData.name.trim().toLowerCase() && p.id !== editingProfileId
|
||||
);
|
||||
|
||||
// API key is only required when source is 'inline'
|
||||
const isFormValid =
|
||||
formData.name.trim().length > 0 &&
|
||||
formData.baseUrl.trim().length > 0 &&
|
||||
(formData.apiKeySource !== 'inline' || formData.apiKey.length > 0) &&
|
||||
!isDuplicateName;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-linear-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-brand-500/10">
|
||||
<Server className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">API Profiles</h3>
|
||||
<p className="text-xs text-muted-foreground">Manage Claude-compatible API endpoints</p>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Profile
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleOpenAddDialog()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Custom Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{CLAUDE_API_PROFILE_TEMPLATES.map((template) => (
|
||||
<DropdownMenuItem
|
||||
key={template.name}
|
||||
onClick={() => handleOpenAddDialog(template.name)}
|
||||
>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
{template.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Active Profile Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Active Profile</Label>
|
||||
<Select
|
||||
value={activeClaudeApiProfileId ?? 'none'}
|
||||
onValueChange={(value) => setActiveClaudeApiProfile(value === 'none' ? null : value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select active profile" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="w-4 h-4 text-brand-500" />
|
||||
Direct Anthropic API
|
||||
</div>
|
||||
</SelectItem>
|
||||
{claudeApiProfiles.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-muted-foreground" />
|
||||
{profile.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{activeClaudeApiProfileId
|
||||
? 'Using custom API endpoint'
|
||||
: 'Using direct Anthropic API (API key or Claude Max plan)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Profile List */}
|
||||
{claudeApiProfiles.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground border border-dashed border-border/50 rounded-lg">
|
||||
<Server className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">No API profiles configured</p>
|
||||
<p className="text-xs mt-1">
|
||||
Add a profile to use alternative Claude-compatible endpoints
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{claudeApiProfiles.map((profile) => (
|
||||
<ProfileCard
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
isActive={profile.id === activeClaudeApiProfileId}
|
||||
onEdit={() => handleOpenEditDialog(profile)}
|
||||
onDelete={() => setDeleteConfirmId(profile.id)}
|
||||
onSetActive={() => setActiveClaudeApiProfile(profile.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingProfileId ? 'Edit API Profile' : 'Add API Profile'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure a Claude-compatible API endpoint. API keys are stored locally.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="e.g., z.AI GLM"
|
||||
className={isDuplicateName ? 'border-destructive' : ''}
|
||||
/>
|
||||
{isDuplicateName && (
|
||||
<p className="text-xs text-destructive">A profile with this name already exists</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-base-url">API Base URL</Label>
|
||||
<Input
|
||||
id="profile-base-url"
|
||||
value={formData.baseUrl}
|
||||
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Key Source */}
|
||||
<div className="space-y-2">
|
||||
<Label>API Key Source</Label>
|
||||
<Select
|
||||
value={formData.apiKeySource}
|
||||
onValueChange={(value: ApiKeySource) =>
|
||||
setFormData({ ...formData, apiKeySource: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select API key source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="credentials">
|
||||
Use saved API key (from Settings → API Keys)
|
||||
</SelectItem>
|
||||
<SelectItem value="env">Use environment variable (ANTHROPIC_API_KEY)</SelectItem>
|
||||
<SelectItem value="inline">Enter key for this profile only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formData.apiKeySource === 'credentials' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Will use the Anthropic key from Settings → API Keys
|
||||
</p>
|
||||
)}
|
||||
{formData.apiKeySource === 'env' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Will use ANTHROPIC_API_KEY environment variable
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API Key (only shown for inline source) */}
|
||||
{formData.apiKeySource === 'inline' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="profile-api-key"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={formData.apiKey}
|
||||
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||
placeholder="Enter API key"
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground hover:bg-transparent"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{currentTemplate?.apiKeyUrl && (
|
||||
<a
|
||||
href={currentTemplate.apiKeyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-brand-500 hover:text-brand-400"
|
||||
>
|
||||
Get API Key from {currentTemplate.name} <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Use Auth Token */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<Label htmlFor="use-auth-token" className="font-medium">
|
||||
Use Auth Token
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="use-auth-token"
|
||||
checked={formData.useAuthToken}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, useAuthToken: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Timeout */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-timeout">Timeout (ms)</Label>
|
||||
<Input
|
||||
id="profile-timeout"
|
||||
type="number"
|
||||
value={formData.timeoutMs}
|
||||
onChange={(e) => setFormData({ ...formData, timeoutMs: e.target.value })}
|
||||
placeholder="Optional, e.g., 3000000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model Mappings */}
|
||||
<div className="space-y-3">
|
||||
<Label className="font-medium">Model Mappings (Optional)</Label>
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Map Claude model aliases to provider-specific model names
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="model-haiku" className="text-xs">
|
||||
Haiku
|
||||
</Label>
|
||||
<Input
|
||||
id="model-haiku"
|
||||
value={formData.modelMappings.haiku}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
modelMappings: { ...formData.modelMappings, haiku: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="e.g., GLM-4.5-Flash"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="model-sonnet" className="text-xs">
|
||||
Sonnet
|
||||
</Label>
|
||||
<Input
|
||||
id="model-sonnet"
|
||||
value={formData.modelMappings.sonnet}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
modelMappings: { ...formData.modelMappings, sonnet: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="e.g., glm-4.7"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="model-opus" className="text-xs">
|
||||
Opus
|
||||
</Label>
|
||||
<Input
|
||||
id="model-opus"
|
||||
value={formData.modelMappings.opus}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
modelMappings: { ...formData.modelMappings, opus: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="e.g., glm-4.7"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disable Non-essential Traffic */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<Label htmlFor="disable-traffic" className="font-medium">
|
||||
Disable Non-essential Traffic
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sets CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="disable-traffic"
|
||||
checked={formData.disableNonessentialTraffic}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, disableNonessentialTraffic: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!isFormValid}>
|
||||
{editingProfileId ? 'Save Changes' : 'Add Profile'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={!!deleteConfirmId} onOpenChange={(open) => !open && setDeleteConfirmId(null)}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Profile?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently delete the API profile. If this profile is currently active, you
|
||||
will be switched to direct Anthropic API.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirmId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteConfirmId && handleDelete(deleteConfirmId)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProfileCardProps {
|
||||
profile: ClaudeApiProfile;
|
||||
isActive: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onSetActive: () => void;
|
||||
}
|
||||
|
||||
function ProfileCard({ profile, isActive, onEdit, onDelete, onSetActive }: ProfileCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-4 transition-colors',
|
||||
isActive
|
||||
? 'border-brand-500/50 bg-brand-500/5'
|
||||
: 'border-border/50 bg-card/50 hover:border-border'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium text-foreground truncate">{profile.name}</h4>
|
||||
{isActive && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-brand-500/20 text-brand-500">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate mt-1">{profile.baseUrl}</p>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2 text-xs text-muted-foreground">
|
||||
<span>Key: {maskApiKey(profile.apiKey)}</span>
|
||||
{profile.useAuthToken && <span>Auth Token</span>}
|
||||
{profile.timeoutMs && <span>Timeout: {(profile.timeoutMs / 1000).toFixed(0)}s</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="shrink-0">
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!isActive && (
|
||||
<DropdownMenuItem onClick={onSetActive}>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Set Active
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={onDelete}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,24 @@
|
||||
import { useEffect, useCallback, useMemo } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import type { WorktreeInfo } from '@/components/views/board-view/worktree-panel/types';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByProjectPath';
|
||||
const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByWorktreeKey';
|
||||
|
||||
/**
|
||||
* Generate a worktree key for session storage
|
||||
* @param projectPath - The project path
|
||||
* @param branchName - The branch name, or null for main worktree
|
||||
*/
|
||||
function getWorktreeSessionKey(projectPath: string, branchName: string | null): string {
|
||||
return `${projectPath}::${branchName ?? '__main__'}`;
|
||||
}
|
||||
|
||||
function readAutoModeSession(): Record<string, boolean> {
|
||||
try {
|
||||
@@ -31,9 +42,14 @@ function writeAutoModeSession(next: Record<string, boolean>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function setAutoModeSessionForProjectPath(projectPath: string, running: boolean): void {
|
||||
function setAutoModeSessionForWorktree(
|
||||
projectPath: string,
|
||||
branchName: string | null,
|
||||
running: boolean
|
||||
): void {
|
||||
const worktreeKey = getWorktreeSessionKey(projectPath, branchName);
|
||||
const current = readAutoModeSession();
|
||||
const next = { ...current, [projectPath]: running };
|
||||
const next = { ...current, [worktreeKey]: running };
|
||||
writeAutoModeSession(next);
|
||||
}
|
||||
|
||||
@@ -45,33 +61,44 @@ function isPlanApprovalEvent(
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing auto mode (scoped per project)
|
||||
* Hook for managing auto mode (scoped per worktree)
|
||||
* @param worktree - Optional worktree info. If not provided, uses main worktree (branchName = null)
|
||||
*/
|
||||
export function useAutoMode() {
|
||||
export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
const {
|
||||
autoModeByProject,
|
||||
autoModeByWorktree,
|
||||
setAutoModeRunning,
|
||||
addRunningTask,
|
||||
removeRunningTask,
|
||||
currentProject,
|
||||
addAutoModeActivity,
|
||||
maxConcurrency,
|
||||
projects,
|
||||
setPendingPlanApproval,
|
||||
getWorktreeKey,
|
||||
getMaxConcurrencyForWorktree,
|
||||
setMaxConcurrencyForWorktree,
|
||||
} = useAppStore(
|
||||
useShallow((state) => ({
|
||||
autoModeByProject: state.autoModeByProject,
|
||||
autoModeByWorktree: state.autoModeByWorktree,
|
||||
setAutoModeRunning: state.setAutoModeRunning,
|
||||
addRunningTask: state.addRunningTask,
|
||||
removeRunningTask: state.removeRunningTask,
|
||||
currentProject: state.currentProject,
|
||||
addAutoModeActivity: state.addAutoModeActivity,
|
||||
maxConcurrency: state.maxConcurrency,
|
||||
projects: state.projects,
|
||||
setPendingPlanApproval: state.setPendingPlanApproval,
|
||||
getWorktreeKey: state.getWorktreeKey,
|
||||
getMaxConcurrencyForWorktree: state.getMaxConcurrencyForWorktree,
|
||||
setMaxConcurrencyForWorktree: state.setMaxConcurrencyForWorktree,
|
||||
}))
|
||||
);
|
||||
|
||||
// Derive branchName from worktree: main worktree uses null, feature worktrees use their branch
|
||||
const branchName = useMemo(() => {
|
||||
if (!worktree) return null;
|
||||
return worktree.isMain ? null : worktree.branch;
|
||||
}, [worktree]);
|
||||
|
||||
// Helper to look up project ID from path
|
||||
const getProjectIdFromPath = useCallback(
|
||||
(path: string): string | undefined => {
|
||||
@@ -81,15 +108,30 @@ export function useAutoMode() {
|
||||
[projects]
|
||||
);
|
||||
|
||||
// Get project-specific auto mode state
|
||||
// Get worktree-specific auto mode state
|
||||
const projectId = currentProject?.id;
|
||||
const projectAutoModeState = useMemo(() => {
|
||||
if (!projectId) return { isRunning: false, runningTasks: [] };
|
||||
return autoModeByProject[projectId] || { isRunning: false, runningTasks: [] };
|
||||
}, [autoModeByProject, projectId]);
|
||||
const worktreeAutoModeState = useMemo(() => {
|
||||
if (!projectId)
|
||||
return {
|
||||
isRunning: false,
|
||||
runningTasks: [],
|
||||
branchName: null,
|
||||
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
||||
};
|
||||
const key = getWorktreeKey(projectId, branchName);
|
||||
return (
|
||||
autoModeByWorktree[key] || {
|
||||
isRunning: false,
|
||||
runningTasks: [],
|
||||
branchName,
|
||||
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
||||
}
|
||||
);
|
||||
}, [autoModeByWorktree, projectId, branchName, getWorktreeKey]);
|
||||
|
||||
const isAutoModeRunning = projectAutoModeState.isRunning;
|
||||
const runningAutoTasks = projectAutoModeState.runningTasks;
|
||||
const isAutoModeRunning = worktreeAutoModeState.isRunning;
|
||||
const runningAutoTasks = worktreeAutoModeState.runningTasks;
|
||||
const maxConcurrency = worktreeAutoModeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
|
||||
|
||||
// Check if we can start a new task based on concurrency limit
|
||||
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
|
||||
@@ -104,15 +146,17 @@ export function useAutoMode() {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.status) return;
|
||||
|
||||
const result = await api.autoMode.status(currentProject.path);
|
||||
const result = await api.autoMode.status(currentProject.path, branchName);
|
||||
if (result.success && result.isAutoLoopRunning !== undefined) {
|
||||
const backendIsRunning = result.isAutoLoopRunning;
|
||||
|
||||
if (backendIsRunning !== isAutoModeRunning) {
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
logger.info(
|
||||
`[AutoMode] Syncing UI state with backend for ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
|
||||
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
|
||||
);
|
||||
setAutoModeRunning(currentProject.id, backendIsRunning);
|
||||
setAutoModeSessionForProjectPath(currentProject.path, backendIsRunning);
|
||||
setAutoModeRunning(currentProject.id, branchName, backendIsRunning);
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -121,9 +165,9 @@ export function useAutoMode() {
|
||||
};
|
||||
|
||||
syncWithBackend();
|
||||
}, [currentProject, isAutoModeRunning, setAutoModeRunning]);
|
||||
}, [currentProject, branchName, isAutoModeRunning, setAutoModeRunning]);
|
||||
|
||||
// Handle auto mode events - listen globally for all projects
|
||||
// Handle auto mode events - listen globally for all projects/worktrees
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
@@ -131,8 +175,8 @@ export function useAutoMode() {
|
||||
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
|
||||
logger.info('Event:', event);
|
||||
|
||||
// Events include projectPath from backend - use it to look up project ID
|
||||
// Fall back to current projectId if not provided in event
|
||||
// Events include projectPath and branchName from backend
|
||||
// Use them to look up project ID and determine the worktree
|
||||
let eventProjectId: string | undefined;
|
||||
if ('projectPath' in event && event.projectPath) {
|
||||
eventProjectId = getProjectIdFromPath(event.projectPath);
|
||||
@@ -144,6 +188,10 @@ export function useAutoMode() {
|
||||
eventProjectId = projectId;
|
||||
}
|
||||
|
||||
// Extract branchName from event, defaulting to null (main worktree)
|
||||
const eventBranchName: string | null =
|
||||
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
|
||||
|
||||
// Skip event if we couldn't determine the project
|
||||
if (!eventProjectId) {
|
||||
logger.warn('Could not determine project for event:', event);
|
||||
@@ -153,23 +201,34 @@ export function useAutoMode() {
|
||||
switch (event.type) {
|
||||
case 'auto_mode_started':
|
||||
// Backend started auto loop - update UI state
|
||||
logger.info('[AutoMode] Backend started auto loop for project');
|
||||
if (eventProjectId) {
|
||||
setAutoModeRunning(eventProjectId, true);
|
||||
{
|
||||
const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree';
|
||||
logger.info(`[AutoMode] Backend started auto loop for ${worktreeDesc}`);
|
||||
if (eventProjectId) {
|
||||
// Extract maxConcurrency from event if available, otherwise use current or default
|
||||
const eventMaxConcurrency =
|
||||
'maxConcurrency' in event && typeof event.maxConcurrency === 'number'
|
||||
? event.maxConcurrency
|
||||
: getMaxConcurrencyForWorktree(eventProjectId, eventBranchName);
|
||||
setAutoModeRunning(eventProjectId, eventBranchName, true, eventMaxConcurrency);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auto_mode_stopped':
|
||||
// Backend stopped auto loop - update UI state
|
||||
logger.info('[AutoMode] Backend stopped auto loop for project');
|
||||
if (eventProjectId) {
|
||||
setAutoModeRunning(eventProjectId, false);
|
||||
{
|
||||
const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree';
|
||||
logger.info(`[AutoMode] Backend stopped auto loop for ${worktreeDesc}`);
|
||||
if (eventProjectId) {
|
||||
setAutoModeRunning(eventProjectId, eventBranchName, false);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auto_mode_feature_start':
|
||||
if (event.featureId) {
|
||||
addRunningTask(eventProjectId, event.featureId);
|
||||
addRunningTask(eventProjectId, eventBranchName, event.featureId);
|
||||
addAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: 'start',
|
||||
@@ -182,7 +241,7 @@ export function useAutoMode() {
|
||||
// Feature completed - remove from running tasks and UI will reload features on its own
|
||||
if (event.featureId) {
|
||||
logger.info('Feature completed:', event.featureId, 'passes:', event.passes);
|
||||
removeRunningTask(eventProjectId, event.featureId);
|
||||
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
|
||||
addAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: 'complete',
|
||||
@@ -202,7 +261,7 @@ export function useAutoMode() {
|
||||
logger.info('Feature cancelled/aborted:', event.error);
|
||||
// Remove from running tasks
|
||||
if (eventProjectId) {
|
||||
removeRunningTask(eventProjectId, event.featureId);
|
||||
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -229,7 +288,7 @@ export function useAutoMode() {
|
||||
|
||||
// Remove the task from running since it failed
|
||||
if (eventProjectId) {
|
||||
removeRunningTask(eventProjectId, event.featureId);
|
||||
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -404,9 +463,11 @@ export function useAutoMode() {
|
||||
setPendingPlanApproval,
|
||||
setAutoModeRunning,
|
||||
currentProject?.path,
|
||||
getMaxConcurrencyForWorktree,
|
||||
setMaxConcurrencyForWorktree,
|
||||
]);
|
||||
|
||||
// Start auto mode - calls backend to start the auto loop
|
||||
// Start auto mode - calls backend to start the auto loop for this worktree
|
||||
const start = useCallback(async () => {
|
||||
if (!currentProject) {
|
||||
logger.error('No project selected');
|
||||
@@ -419,36 +480,35 @@ export function useAutoMode() {
|
||||
throw new Error('Start auto mode API not available');
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[AutoMode] Starting auto loop for ${currentProject.path} with maxConcurrency: ${maxConcurrency}`
|
||||
);
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
logger.info(`[AutoMode] Starting auto loop for ${worktreeDesc} in ${currentProject.path}`);
|
||||
|
||||
// Optimistically update UI state (backend will confirm via event)
|
||||
setAutoModeSessionForProjectPath(currentProject.path, true);
|
||||
setAutoModeRunning(currentProject.id, true);
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, true);
|
||||
setAutoModeRunning(currentProject.id, branchName, true);
|
||||
|
||||
// Call backend to start the auto loop
|
||||
const result = await api.autoMode.start(currentProject.path, maxConcurrency);
|
||||
// Call backend to start the auto loop (backend uses stored concurrency)
|
||||
const result = await api.autoMode.start(currentProject.path, branchName);
|
||||
|
||||
if (!result.success) {
|
||||
// Revert UI state on failure
|
||||
setAutoModeSessionForProjectPath(currentProject.path, false);
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
|
||||
setAutoModeRunning(currentProject.id, branchName, false);
|
||||
logger.error('Failed to start auto mode:', result.error);
|
||||
throw new Error(result.error || 'Failed to start auto mode');
|
||||
}
|
||||
|
||||
logger.debug(`[AutoMode] Started successfully`);
|
||||
logger.debug(`[AutoMode] Started successfully for ${worktreeDesc}`);
|
||||
} catch (error) {
|
||||
// Revert UI state on error
|
||||
setAutoModeSessionForProjectPath(currentProject.path, false);
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
|
||||
setAutoModeRunning(currentProject.id, branchName, false);
|
||||
logger.error('Error starting auto mode:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [currentProject, setAutoModeRunning, maxConcurrency]);
|
||||
}, [currentProject, branchName, setAutoModeRunning]);
|
||||
|
||||
// Stop auto mode - calls backend to stop the auto loop
|
||||
// Stop auto mode - calls backend to stop the auto loop for this worktree
|
||||
const stop = useCallback(async () => {
|
||||
if (!currentProject) {
|
||||
logger.error('No project selected');
|
||||
@@ -461,34 +521,35 @@ export function useAutoMode() {
|
||||
throw new Error('Stop auto mode API not available');
|
||||
}
|
||||
|
||||
logger.info(`[AutoMode] Stopping auto loop for ${currentProject.path}`);
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
logger.info(`[AutoMode] Stopping auto loop for ${worktreeDesc} in ${currentProject.path}`);
|
||||
|
||||
// Optimistically update UI state (backend will confirm via event)
|
||||
setAutoModeSessionForProjectPath(currentProject.path, false);
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
|
||||
setAutoModeRunning(currentProject.id, branchName, false);
|
||||
|
||||
// Call backend to stop the auto loop
|
||||
const result = await api.autoMode.stop(currentProject.path);
|
||||
const result = await api.autoMode.stop(currentProject.path, branchName);
|
||||
|
||||
if (!result.success) {
|
||||
// Revert UI state on failure
|
||||
setAutoModeSessionForProjectPath(currentProject.path, true);
|
||||
setAutoModeRunning(currentProject.id, true);
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, true);
|
||||
setAutoModeRunning(currentProject.id, branchName, true);
|
||||
logger.error('Failed to stop auto mode:', result.error);
|
||||
throw new Error(result.error || 'Failed to stop auto mode');
|
||||
}
|
||||
|
||||
// NOTE: Running tasks will continue until natural completion.
|
||||
// The backend stops picking up new features but doesn't abort running ones.
|
||||
logger.info('Stopped - running tasks will continue');
|
||||
logger.info(`Stopped ${worktreeDesc} - running tasks will continue`);
|
||||
} catch (error) {
|
||||
// Revert UI state on error
|
||||
setAutoModeSessionForProjectPath(currentProject.path, true);
|
||||
setAutoModeRunning(currentProject.id, true);
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, true);
|
||||
setAutoModeRunning(currentProject.id, branchName, true);
|
||||
logger.error('Error stopping auto mode:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [currentProject, setAutoModeRunning]);
|
||||
}, [currentProject, branchName, setAutoModeRunning]);
|
||||
|
||||
// Stop a specific feature
|
||||
const stopFeature = useCallback(
|
||||
@@ -507,7 +568,7 @@ export function useAutoMode() {
|
||||
const result = await api.autoMode.stopFeature(featureId);
|
||||
|
||||
if (result.success) {
|
||||
removeRunningTask(currentProject.id, featureId);
|
||||
removeRunningTask(currentProject.id, branchName, featureId);
|
||||
logger.info('Feature stopped successfully:', featureId);
|
||||
addAutoModeActivity({
|
||||
featureId,
|
||||
@@ -524,7 +585,7 @@ export function useAutoMode() {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[currentProject, removeRunningTask, addAutoModeActivity]
|
||||
[currentProject, branchName, removeRunningTask, addAutoModeActivity]
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -532,6 +593,7 @@ export function useAutoMode() {
|
||||
runningTasks: runningAutoTasks,
|
||||
maxConcurrency,
|
||||
canStartNewTask,
|
||||
branchName,
|
||||
start,
|
||||
stop,
|
||||
stopFeature,
|
||||
|
||||
@@ -25,6 +25,7 @@ export function useProjectSettingsLoader() {
|
||||
const setAutoDismissInitScriptIndicator = useAppStore(
|
||||
(state) => state.setAutoDismissInitScriptIndicator
|
||||
);
|
||||
const setCurrentProject = useAppStore((state) => state.setCurrentProject);
|
||||
|
||||
const appliedProjectRef = useRef<string | null>(null);
|
||||
|
||||
@@ -90,6 +91,21 @@ export function useProjectSettingsLoader() {
|
||||
if (settings.autoDismissInitScriptIndicator !== undefined) {
|
||||
setAutoDismissInitScriptIndicator(projectPath, settings.autoDismissInitScriptIndicator);
|
||||
}
|
||||
|
||||
// Apply activeClaudeApiProfileId if present
|
||||
if (settings.activeClaudeApiProfileId !== undefined) {
|
||||
const updatedProject = useAppStore.getState().currentProject;
|
||||
if (
|
||||
updatedProject &&
|
||||
updatedProject.path === projectPath &&
|
||||
updatedProject.activeClaudeApiProfileId !== settings.activeClaudeApiProfileId
|
||||
) {
|
||||
setCurrentProject({
|
||||
...updatedProject,
|
||||
activeClaudeApiProfileId: settings.activeClaudeApiProfileId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentProject?.path,
|
||||
settings,
|
||||
@@ -105,5 +121,6 @@ export function useProjectSettingsLoader() {
|
||||
setShowInitScriptIndicator,
|
||||
setDefaultDeleteBranch,
|
||||
setAutoDismissInitScriptIndicator,
|
||||
setCurrentProject,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import {
|
||||
DEFAULT_OPENCODE_MODEL,
|
||||
DEFAULT_MAX_CONCURRENCY,
|
||||
getAllOpencodeModelIds,
|
||||
getAllCursorModelIds,
|
||||
migrateCursorModelIds,
|
||||
@@ -194,6 +195,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
||||
keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'],
|
||||
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
|
||||
promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'],
|
||||
eventHooks: state.eventHooks as GlobalSettings['eventHooks'],
|
||||
projects: state.projects as GlobalSettings['projects'],
|
||||
trashedProjects: state.trashedProjects as GlobalSettings['trashedProjects'],
|
||||
currentProjectId: (state.currentProject as { id?: string } | null)?.id ?? null,
|
||||
@@ -206,6 +208,10 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
||||
worktreePanelCollapsed === 'true' || (state.worktreePanelCollapsed as boolean),
|
||||
lastProjectDir: lastProjectDir || (state.lastProjectDir as string),
|
||||
recentFolders: recentFolders ? JSON.parse(recentFolders) : (state.recentFolders as string[]),
|
||||
// Claude API Profiles
|
||||
claudeApiProfiles: (state.claudeApiProfiles as GlobalSettings['claudeApiProfiles']) ?? [],
|
||||
activeClaudeApiProfileId:
|
||||
(state.activeClaudeApiProfileId as GlobalSettings['activeClaudeApiProfileId']) ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse localStorage settings:', error);
|
||||
@@ -326,6 +332,20 @@ export function mergeSettings(
|
||||
merged.currentProjectId = localSettings.currentProjectId;
|
||||
}
|
||||
|
||||
// Claude API Profiles - preserve from localStorage if server is empty
|
||||
if (
|
||||
(!serverSettings.claudeApiProfiles || serverSettings.claudeApiProfiles.length === 0) &&
|
||||
localSettings.claudeApiProfiles &&
|
||||
localSettings.claudeApiProfiles.length > 0
|
||||
) {
|
||||
merged.claudeApiProfiles = localSettings.claudeApiProfiles;
|
||||
}
|
||||
|
||||
// Active Claude API Profile ID - preserve from localStorage if server doesn't have one
|
||||
if (!serverSettings.activeClaudeApiProfileId && localSettings.activeClaudeApiProfileId) {
|
||||
merged.activeClaudeApiProfileId = localSettings.activeClaudeApiProfileId;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -635,13 +655,39 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
setItem(THEME_STORAGE_KEY, storedTheme);
|
||||
}
|
||||
|
||||
// Restore autoModeByWorktree settings (only maxConcurrency is persisted, runtime state is reset)
|
||||
const restoredAutoModeByWorktree: Record<
|
||||
string,
|
||||
{
|
||||
isRunning: boolean;
|
||||
runningTasks: string[];
|
||||
branchName: string | null;
|
||||
maxConcurrency: number;
|
||||
}
|
||||
> = {};
|
||||
if ((settings as Record<string, unknown>).autoModeByWorktree) {
|
||||
const persistedSettings = (settings as Record<string, unknown>).autoModeByWorktree as Record<
|
||||
string,
|
||||
{ maxConcurrency?: number; branchName?: string | null }
|
||||
>;
|
||||
for (const [key, value] of Object.entries(persistedSettings)) {
|
||||
restoredAutoModeByWorktree[key] = {
|
||||
isRunning: false, // Always start with auto mode off
|
||||
runningTasks: [], // No running tasks on startup
|
||||
branchName: value.branchName ?? null,
|
||||
maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
useAppStore.setState({
|
||||
theme: settings.theme as unknown as import('@/store/app-store').ThemeMode,
|
||||
fontFamilySans: settings.fontFamilySans ?? null,
|
||||
fontFamilyMono: settings.fontFamilyMono ?? null,
|
||||
sidebarOpen: settings.sidebarOpen ?? true,
|
||||
chatHistoryOpen: settings.chatHistoryOpen ?? false,
|
||||
maxConcurrency: settings.maxConcurrency ?? 3,
|
||||
maxConcurrency: settings.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
||||
autoModeByWorktree: restoredAutoModeByWorktree,
|
||||
defaultSkipTests: settings.defaultSkipTests ?? true,
|
||||
enableDependencyBlocking: settings.enableDependencyBlocking ?? true,
|
||||
skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false,
|
||||
@@ -671,6 +717,9 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
},
|
||||
mcpServers: settings.mcpServers ?? [],
|
||||
promptCustomization: settings.promptCustomization ?? {},
|
||||
eventHooks: settings.eventHooks ?? [],
|
||||
claudeApiProfiles: settings.claudeApiProfiles ?? [],
|
||||
activeClaudeApiProfileId: settings.activeClaudeApiProfileId ?? null,
|
||||
projects,
|
||||
currentProject,
|
||||
trashedProjects: settings.trashedProjects ?? [],
|
||||
@@ -705,6 +754,19 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
const state = useAppStore.getState();
|
||||
const setupState = useSetupStore.getState();
|
||||
|
||||
// Only persist settings (maxConcurrency), not runtime state (isRunning, runningTasks)
|
||||
const persistedAutoModeByWorktree: Record<
|
||||
string,
|
||||
{ maxConcurrency: number; branchName: string | null }
|
||||
> = {};
|
||||
for (const [key, value] of Object.entries(state.autoModeByWorktree)) {
|
||||
persistedAutoModeByWorktree[key] = {
|
||||
maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
||||
branchName: value.branchName,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
setupComplete: setupState.setupComplete,
|
||||
isFirstRun: setupState.isFirstRun,
|
||||
@@ -713,6 +775,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
chatHistoryOpen: state.chatHistoryOpen,
|
||||
maxConcurrency: state.maxConcurrency,
|
||||
autoModeByWorktree: persistedAutoModeByWorktree,
|
||||
defaultSkipTests: state.defaultSkipTests,
|
||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||
skipVerificationInAutoMode: state.skipVerificationInAutoMode,
|
||||
@@ -732,6 +795,9 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
keyboardShortcuts: state.keyboardShortcuts,
|
||||
mcpServers: state.mcpServers,
|
||||
promptCustomization: state.promptCustomization,
|
||||
eventHooks: state.eventHooks,
|
||||
claudeApiProfiles: state.claudeApiProfiles,
|
||||
activeClaudeApiProfileId: state.activeClaudeApiProfileId,
|
||||
projects: state.projects,
|
||||
trashedProjects: state.trashedProjects,
|
||||
currentProjectId: state.currentProject?.id ?? null,
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useAuthStore } from '@/store/auth-store';
|
||||
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
|
||||
import {
|
||||
DEFAULT_OPENCODE_MODEL,
|
||||
DEFAULT_MAX_CONCURRENCY,
|
||||
getAllOpencodeModelIds,
|
||||
getAllCursorModelIds,
|
||||
migrateCursorModelIds,
|
||||
@@ -46,6 +47,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'sidebarOpen',
|
||||
'chatHistoryOpen',
|
||||
'maxConcurrency',
|
||||
'autoModeByWorktree', // Per-worktree auto mode settings (only maxConcurrency is persisted)
|
||||
'defaultSkipTests',
|
||||
'enableDependencyBlocking',
|
||||
'skipVerificationInAutoMode',
|
||||
@@ -72,6 +74,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'defaultTerminalId',
|
||||
'promptCustomization',
|
||||
'eventHooks',
|
||||
'claudeApiProfiles',
|
||||
'activeClaudeApiProfileId',
|
||||
'projects',
|
||||
'trashedProjects',
|
||||
'currentProjectId', // ID of currently open project
|
||||
@@ -112,6 +116,19 @@ function getSettingsFieldValue(
|
||||
if (field === 'openTerminalMode') {
|
||||
return appState.terminalState.openTerminalMode;
|
||||
}
|
||||
if (field === 'autoModeByWorktree') {
|
||||
// Only persist settings (maxConcurrency), not runtime state (isRunning, runningTasks)
|
||||
const autoModeByWorktree = appState.autoModeByWorktree;
|
||||
const persistedSettings: Record<string, { maxConcurrency: number; branchName: string | null }> =
|
||||
{};
|
||||
for (const [key, value] of Object.entries(autoModeByWorktree)) {
|
||||
persistedSettings[key] = {
|
||||
maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
||||
branchName: value.branchName,
|
||||
};
|
||||
}
|
||||
return persistedSettings;
|
||||
}
|
||||
return appState[field as keyof typeof appState];
|
||||
}
|
||||
|
||||
@@ -591,11 +608,37 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
setItem(THEME_STORAGE_KEY, serverSettings.theme);
|
||||
}
|
||||
|
||||
// Restore autoModeByWorktree settings (only maxConcurrency is persisted, runtime state is reset)
|
||||
const restoredAutoModeByWorktree: Record<
|
||||
string,
|
||||
{
|
||||
isRunning: boolean;
|
||||
runningTasks: string[];
|
||||
branchName: string | null;
|
||||
maxConcurrency: number;
|
||||
}
|
||||
> = {};
|
||||
if (serverSettings.autoModeByWorktree) {
|
||||
const persistedSettings = serverSettings.autoModeByWorktree as Record<
|
||||
string,
|
||||
{ maxConcurrency?: number; branchName?: string | null }
|
||||
>;
|
||||
for (const [key, value] of Object.entries(persistedSettings)) {
|
||||
restoredAutoModeByWorktree[key] = {
|
||||
isRunning: false, // Always start with auto mode off
|
||||
runningTasks: [], // No running tasks on startup
|
||||
branchName: value.branchName ?? null,
|
||||
maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
useAppStore.setState({
|
||||
theme: serverSettings.theme as unknown as ThemeMode,
|
||||
sidebarOpen: serverSettings.sidebarOpen,
|
||||
chatHistoryOpen: serverSettings.chatHistoryOpen,
|
||||
maxConcurrency: serverSettings.maxConcurrency,
|
||||
autoModeByWorktree: restoredAutoModeByWorktree,
|
||||
defaultSkipTests: serverSettings.defaultSkipTests,
|
||||
enableDependencyBlocking: serverSettings.enableDependencyBlocking,
|
||||
skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode,
|
||||
@@ -628,6 +671,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
defaultEditorCommand: serverSettings.defaultEditorCommand ?? null,
|
||||
defaultTerminalId: serverSettings.defaultTerminalId ?? null,
|
||||
promptCustomization: serverSettings.promptCustomization ?? {},
|
||||
claudeApiProfiles: serverSettings.claudeApiProfiles ?? [],
|
||||
activeClaudeApiProfileId: serverSettings.activeClaudeApiProfileId ?? null,
|
||||
projects: serverSettings.projects,
|
||||
trashedProjects: serverSettings.trashedProjects,
|
||||
projectHistory: serverSettings.projectHistory,
|
||||
@@ -637,6 +682,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false,
|
||||
lastProjectDir: serverSettings.lastProjectDir ?? '',
|
||||
recentFolders: serverSettings.recentFolders ?? [],
|
||||
// Event hooks
|
||||
eventHooks: serverSettings.eventHooks ?? [],
|
||||
// Terminal settings (nested in terminalState)
|
||||
...((serverSettings.terminalFontFamily || serverSettings.openTerminalMode) && {
|
||||
terminalState: {
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
UpdateIdeaInput,
|
||||
ConvertToFeatureOptions,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
|
||||
import { getJSON, setJSON, removeItem } from './storage';
|
||||
|
||||
// Re-export issue validation types for use in components
|
||||
@@ -479,20 +480,26 @@ export interface FeaturesAPI {
|
||||
featureId: string
|
||||
) => Promise<{ success: boolean; content?: string | null; error?: string }>;
|
||||
generateTitle: (
|
||||
description: string
|
||||
description: string,
|
||||
projectPath?: string
|
||||
) => Promise<{ success: boolean; title?: string; error?: string }>;
|
||||
}
|
||||
|
||||
export interface AutoModeAPI {
|
||||
start: (
|
||||
projectPath: string,
|
||||
branchName?: string | null,
|
||||
maxConcurrency?: number
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
stop: (
|
||||
projectPath: string
|
||||
projectPath: string,
|
||||
branchName?: string | null
|
||||
) => Promise<{ success: boolean; error?: string; runningFeatures?: number }>;
|
||||
stopFeature: (featureId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
status: (projectPath?: string) => Promise<{
|
||||
status: (
|
||||
projectPath?: string,
|
||||
branchName?: string | null
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
isRunning?: boolean;
|
||||
isAutoLoopRunning?: boolean;
|
||||
@@ -706,7 +713,8 @@ export interface ElectronAPI {
|
||||
originalText: string,
|
||||
enhancementMode: string,
|
||||
model?: string,
|
||||
thinkingLevel?: string
|
||||
thinkingLevel?: string,
|
||||
projectPath?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
enhancedText?: string;
|
||||
@@ -2016,6 +2024,20 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
console.log('[Mock] Unsubscribing from init script events');
|
||||
};
|
||||
},
|
||||
|
||||
discardChanges: async (worktreePath: string) => {
|
||||
console.log('[Mock] Discarding changes:', { worktreePath });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
discarded: true,
|
||||
filesDiscarded: 0,
|
||||
filesRemaining: 0,
|
||||
branch: 'main',
|
||||
message: 'Mock: Changes discarded successfully',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2060,7 +2082,9 @@ function createMockAutoModeAPI(): AutoModeAPI {
|
||||
}
|
||||
|
||||
mockAutoModeRunning = true;
|
||||
console.log(`[Mock] Auto mode started with maxConcurrency: ${maxConcurrency || 3}`);
|
||||
console.log(
|
||||
`[Mock] Auto mode started with maxConcurrency: ${maxConcurrency || DEFAULT_MAX_CONCURRENCY}`
|
||||
);
|
||||
const featureId = 'auto-mode-0';
|
||||
mockRunningFeatures.add(featureId);
|
||||
|
||||
@@ -3173,7 +3197,7 @@ function createMockFeaturesAPI(): FeaturesAPI {
|
||||
return { success: true, content: content || null };
|
||||
},
|
||||
|
||||
generateTitle: async (description: string) => {
|
||||
generateTitle: async (description: string, _projectPath?: string) => {
|
||||
console.log('[Mock] Generating title for:', description.substring(0, 50));
|
||||
// Mock title generation - just take first few words
|
||||
const words = description.split(/\s+/).slice(0, 6).join(' ');
|
||||
@@ -3349,6 +3373,13 @@ export interface Project {
|
||||
isFavorite?: boolean; // Pin project to top of dashboard
|
||||
icon?: string; // Lucide icon name for project identification
|
||||
customIconPath?: string; // Path to custom uploaded icon image in .automaker/images/
|
||||
/**
|
||||
* Override the active Claude API profile for this project.
|
||||
* - undefined: Use global setting (activeClaudeApiProfileId)
|
||||
* - null: Explicitly use Direct Anthropic API (no profile)
|
||||
* - string: Use specific profile by ID
|
||||
*/
|
||||
activeClaudeApiProfileId?: string | null;
|
||||
}
|
||||
|
||||
export interface TrashedProject extends Project {
|
||||
|
||||
@@ -1657,8 +1657,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.post('/api/features/delete', { projectPath, featureId }),
|
||||
getAgentOutput: (projectPath: string, featureId: string) =>
|
||||
this.post('/api/features/agent-output', { projectPath, featureId }),
|
||||
generateTitle: (description: string) =>
|
||||
this.post('/api/features/generate-title', { description }),
|
||||
generateTitle: (description: string, projectPath?: string) =>
|
||||
this.post('/api/features/generate-title', { description, projectPath }),
|
||||
bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial<Feature>) =>
|
||||
this.post('/api/features/bulk-update', { projectPath, featureIds, updates }),
|
||||
bulkDelete: (projectPath: string, featureIds: string[]) =>
|
||||
@@ -1667,11 +1667,13 @@ export class HttpApiClient implements ElectronAPI {
|
||||
|
||||
// Auto Mode API
|
||||
autoMode: AutoModeAPI = {
|
||||
start: (projectPath: string, maxConcurrency?: number) =>
|
||||
this.post('/api/auto-mode/start', { projectPath, maxConcurrency }),
|
||||
stop: (projectPath: string) => this.post('/api/auto-mode/stop', { projectPath }),
|
||||
start: (projectPath: string, branchName?: string | null, maxConcurrency?: number) =>
|
||||
this.post('/api/auto-mode/start', { projectPath, branchName, maxConcurrency }),
|
||||
stop: (projectPath: string, branchName?: string | null) =>
|
||||
this.post('/api/auto-mode/stop', { projectPath, branchName }),
|
||||
stopFeature: (featureId: string) => this.post('/api/auto-mode/stop-feature', { featureId }),
|
||||
status: (projectPath?: string) => this.post('/api/auto-mode/status', { projectPath }),
|
||||
status: (projectPath?: string, branchName?: string | null) =>
|
||||
this.post('/api/auto-mode/status', { projectPath, branchName }),
|
||||
runFeature: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
@@ -1743,13 +1745,15 @@ export class HttpApiClient implements ElectronAPI {
|
||||
originalText: string,
|
||||
enhancementMode: string,
|
||||
model?: string,
|
||||
thinkingLevel?: string
|
||||
thinkingLevel?: string,
|
||||
projectPath?: string
|
||||
): Promise<EnhancePromptResult> =>
|
||||
this.post('/api/enhance-prompt', {
|
||||
originalText,
|
||||
enhancementMode,
|
||||
model,
|
||||
thinkingLevel,
|
||||
projectPath,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1847,6 +1851,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.httpDelete('/api/worktree/init-script', { projectPath }),
|
||||
runInitScript: (projectPath: string, worktreePath: string, branch: string) =>
|
||||
this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }),
|
||||
discardChanges: (worktreePath: string) =>
|
||||
this.post('/api/worktree/discard-changes', { worktreePath }),
|
||||
onInitScriptEvent: (
|
||||
callback: (event: {
|
||||
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
||||
// Note: persist middleware removed - settings now sync via API (use-settings-sync.ts)
|
||||
import type { Project, TrashedProject } from '@/lib/electron';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { setItem, getItem } from '@/lib/storage';
|
||||
import {
|
||||
@@ -31,6 +32,7 @@ import type {
|
||||
ModelDefinition,
|
||||
ServerLogLevel,
|
||||
EventHook,
|
||||
ClaudeApiProfile,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
getAllCursorModelIds,
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
getAllOpencodeModelIds,
|
||||
DEFAULT_PHASE_MODELS,
|
||||
DEFAULT_OPENCODE_MODEL,
|
||||
DEFAULT_MAX_CONCURRENCY,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('AppStore');
|
||||
@@ -626,16 +629,18 @@ export interface AppState {
|
||||
currentChatSession: ChatSession | null;
|
||||
chatHistoryOpen: boolean;
|
||||
|
||||
// Auto Mode (per-project state, keyed by project ID)
|
||||
autoModeByProject: Record<
|
||||
// Auto Mode (per-worktree state, keyed by "${projectId}::${branchName ?? '__main__'}")
|
||||
autoModeByWorktree: Record<
|
||||
string,
|
||||
{
|
||||
isRunning: boolean;
|
||||
runningTasks: string[]; // Feature IDs being worked on
|
||||
branchName: string | null; // null = main worktree
|
||||
maxConcurrency?: number; // Maximum concurrent features for this worktree (defaults to 3)
|
||||
}
|
||||
>;
|
||||
autoModeActivityLog: AutoModeActivity[];
|
||||
maxConcurrency: number; // Maximum number of concurrent agent tasks
|
||||
maxConcurrency: number; // Legacy: Maximum number of concurrent agent tasks (deprecated, use per-worktree maxConcurrency)
|
||||
|
||||
// Kanban Card Display Settings
|
||||
boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view
|
||||
@@ -747,6 +752,10 @@ export interface AppState {
|
||||
// Event Hooks
|
||||
eventHooks: EventHook[]; // Event hooks for custom commands or webhooks
|
||||
|
||||
// Claude API Profiles
|
||||
claudeApiProfiles: ClaudeApiProfile[]; // Claude-compatible API endpoint profiles
|
||||
activeClaudeApiProfileId: string | null; // Active profile ID (null = use direct Anthropic API)
|
||||
|
||||
// Project Analysis
|
||||
projectAnalysis: ProjectAnalysis | null;
|
||||
isAnalyzing: boolean;
|
||||
@@ -1030,6 +1039,9 @@ export interface AppActions {
|
||||
getEffectiveFontSans: () => string | null; // Get effective UI font (project override -> global -> null for default)
|
||||
getEffectiveFontMono: () => string | null; // Get effective code font (project override -> global -> null for default)
|
||||
|
||||
// Claude API Profile actions (per-project override)
|
||||
setProjectClaudeApiProfile: (projectId: string, profileId: string | null | undefined) => void; // Set per-project Claude API profile (undefined = use global, null = direct API, string = specific profile)
|
||||
|
||||
// Feature actions
|
||||
setFeatures: (features: Feature[]) => void;
|
||||
updateFeature: (id: string, updates: Partial<Feature>) => void;
|
||||
@@ -1057,18 +1069,36 @@ export interface AppActions {
|
||||
setChatHistoryOpen: (open: boolean) => void;
|
||||
toggleChatHistory: () => void;
|
||||
|
||||
// Auto Mode actions (per-project)
|
||||
setAutoModeRunning: (projectId: string, running: boolean) => void;
|
||||
addRunningTask: (projectId: string, taskId: string) => void;
|
||||
removeRunningTask: (projectId: string, taskId: string) => void;
|
||||
clearRunningTasks: (projectId: string) => void;
|
||||
getAutoModeState: (projectId: string) => {
|
||||
// Auto Mode actions (per-worktree)
|
||||
setAutoModeRunning: (
|
||||
projectId: string,
|
||||
branchName: string | null,
|
||||
running: boolean,
|
||||
maxConcurrency?: number
|
||||
) => void;
|
||||
addRunningTask: (projectId: string, branchName: string | null, taskId: string) => void;
|
||||
removeRunningTask: (projectId: string, branchName: string | null, taskId: string) => void;
|
||||
clearRunningTasks: (projectId: string, branchName: string | null) => void;
|
||||
getAutoModeState: (
|
||||
projectId: string,
|
||||
branchName: string | null
|
||||
) => {
|
||||
isRunning: boolean;
|
||||
runningTasks: string[];
|
||||
branchName: string | null;
|
||||
maxConcurrency?: number;
|
||||
};
|
||||
/** Helper to generate worktree key from projectId and branchName */
|
||||
getWorktreeKey: (projectId: string, branchName: string | null) => string;
|
||||
addAutoModeActivity: (activity: Omit<AutoModeActivity, 'id' | 'timestamp'>) => void;
|
||||
clearAutoModeActivity: () => void;
|
||||
setMaxConcurrency: (max: number) => void;
|
||||
setMaxConcurrency: (max: number) => void; // Legacy: kept for backward compatibility
|
||||
getMaxConcurrencyForWorktree: (projectId: string, branchName: string | null) => number;
|
||||
setMaxConcurrencyForWorktree: (
|
||||
projectId: string,
|
||||
branchName: string | null,
|
||||
maxConcurrency: number
|
||||
) => void;
|
||||
|
||||
// Kanban Card Settings actions
|
||||
setBoardViewMode: (mode: BoardViewMode) => void;
|
||||
@@ -1180,6 +1210,13 @@ export interface AppActions {
|
||||
// Event Hook actions
|
||||
setEventHooks: (hooks: EventHook[]) => void;
|
||||
|
||||
// Claude API Profile actions
|
||||
addClaudeApiProfile: (profile: ClaudeApiProfile) => Promise<void>;
|
||||
updateClaudeApiProfile: (id: string, updates: Partial<ClaudeApiProfile>) => Promise<void>;
|
||||
deleteClaudeApiProfile: (id: string) => Promise<void>;
|
||||
setActiveClaudeApiProfile: (id: string | null) => Promise<void>;
|
||||
setClaudeApiProfiles: (profiles: ClaudeApiProfile[]) => Promise<void>;
|
||||
|
||||
// MCP Server actions
|
||||
addMCPServer: (server: Omit<MCPServerConfig, 'id'>) => void;
|
||||
updateMCPServer: (id: string, updates: Partial<MCPServerConfig>) => void;
|
||||
@@ -1387,9 +1424,9 @@ const initialState: AppState = {
|
||||
chatSessions: [],
|
||||
currentChatSession: null,
|
||||
chatHistoryOpen: false,
|
||||
autoModeByProject: {},
|
||||
autoModeByWorktree: {},
|
||||
autoModeActivityLog: [],
|
||||
maxConcurrency: 3, // Default to 3 concurrent agents
|
||||
maxConcurrency: DEFAULT_MAX_CONCURRENCY, // Default concurrent agents
|
||||
boardViewMode: 'kanban', // Default to kanban view
|
||||
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
||||
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
||||
@@ -1438,6 +1475,8 @@ const initialState: AppState = {
|
||||
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
|
||||
promptCustomization: {}, // Empty by default - all prompts use built-in defaults
|
||||
eventHooks: [], // No event hooks configured by default
|
||||
claudeApiProfiles: [], // No Claude API profiles configured by default
|
||||
activeClaudeApiProfileId: null, // Use direct Anthropic API by default
|
||||
projectAnalysis: null,
|
||||
isAnalyzing: false,
|
||||
boardBackgroundByProject: {},
|
||||
@@ -1936,6 +1975,47 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
return getEffectiveFont(currentProject?.fontFamilyMono, fontFamilyMono, UI_MONO_FONT_OPTIONS);
|
||||
},
|
||||
|
||||
// Claude API Profile actions (per-project override)
|
||||
setProjectClaudeApiProfile: (projectId, profileId) => {
|
||||
// Find the project to get its path for server sync
|
||||
const project = get().projects.find((p) => p.id === projectId);
|
||||
if (!project) {
|
||||
console.error('Cannot set Claude API profile: project not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the project's activeClaudeApiProfileId property
|
||||
// undefined means "use global", null means "explicit direct API", string means specific profile
|
||||
const projects = get().projects.map((p) =>
|
||||
p.id === projectId ? { ...p, activeClaudeApiProfileId: profileId } : p
|
||||
);
|
||||
set({ projects });
|
||||
|
||||
// Also update currentProject if it's the same project
|
||||
const currentProject = get().currentProject;
|
||||
if (currentProject?.id === projectId) {
|
||||
set({
|
||||
currentProject: {
|
||||
...currentProject,
|
||||
activeClaudeApiProfileId: profileId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Persist to server
|
||||
// Note: undefined means "use global" but JSON doesn't serialize undefined,
|
||||
// so we use a special marker string "__USE_GLOBAL__" to signal deletion
|
||||
const httpClient = getHttpApiClient();
|
||||
const serverValue = profileId === undefined ? '__USE_GLOBAL__' : profileId;
|
||||
httpClient.settings
|
||||
.updateProject(project.path, {
|
||||
activeClaudeApiProfileId: serverValue,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to persist activeClaudeApiProfileId:', error);
|
||||
});
|
||||
},
|
||||
|
||||
// Feature actions
|
||||
setFeatures: (features) => set({ features }),
|
||||
|
||||
@@ -2073,74 +2153,125 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
|
||||
toggleChatHistory: () => set({ chatHistoryOpen: !get().chatHistoryOpen }),
|
||||
|
||||
// Auto Mode actions (per-project)
|
||||
setAutoModeRunning: (projectId, running) => {
|
||||
const current = get().autoModeByProject;
|
||||
const projectState = current[projectId] || {
|
||||
// Auto Mode actions (per-worktree)
|
||||
getWorktreeKey: (projectId, branchName) => {
|
||||
return `${projectId}::${branchName ?? '__main__'}`;
|
||||
},
|
||||
|
||||
setAutoModeRunning: (projectId, branchName, running, maxConcurrency?: number) => {
|
||||
const worktreeKey = get().getWorktreeKey(projectId, branchName);
|
||||
const current = get().autoModeByWorktree;
|
||||
const worktreeState = current[worktreeKey] || {
|
||||
isRunning: false,
|
||||
runningTasks: [],
|
||||
branchName,
|
||||
maxConcurrency: maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
||||
};
|
||||
set({
|
||||
autoModeByProject: {
|
||||
autoModeByWorktree: {
|
||||
...current,
|
||||
[projectId]: { ...projectState, isRunning: running },
|
||||
[worktreeKey]: {
|
||||
...worktreeState,
|
||||
isRunning: running,
|
||||
branchName,
|
||||
maxConcurrency: maxConcurrency ?? worktreeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
addRunningTask: (projectId, taskId) => {
|
||||
const current = get().autoModeByProject;
|
||||
const projectState = current[projectId] || {
|
||||
addRunningTask: (projectId, branchName, taskId) => {
|
||||
const worktreeKey = get().getWorktreeKey(projectId, branchName);
|
||||
const current = get().autoModeByWorktree;
|
||||
const worktreeState = current[worktreeKey] || {
|
||||
isRunning: false,
|
||||
runningTasks: [],
|
||||
branchName,
|
||||
};
|
||||
if (!projectState.runningTasks.includes(taskId)) {
|
||||
if (!worktreeState.runningTasks.includes(taskId)) {
|
||||
set({
|
||||
autoModeByProject: {
|
||||
autoModeByWorktree: {
|
||||
...current,
|
||||
[projectId]: {
|
||||
...projectState,
|
||||
runningTasks: [...projectState.runningTasks, taskId],
|
||||
[worktreeKey]: {
|
||||
...worktreeState,
|
||||
runningTasks: [...worktreeState.runningTasks, taskId],
|
||||
branchName,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
removeRunningTask: (projectId, taskId) => {
|
||||
const current = get().autoModeByProject;
|
||||
const projectState = current[projectId] || {
|
||||
removeRunningTask: (projectId, branchName, taskId) => {
|
||||
const worktreeKey = get().getWorktreeKey(projectId, branchName);
|
||||
const current = get().autoModeByWorktree;
|
||||
const worktreeState = current[worktreeKey] || {
|
||||
isRunning: false,
|
||||
runningTasks: [],
|
||||
branchName,
|
||||
};
|
||||
set({
|
||||
autoModeByProject: {
|
||||
autoModeByWorktree: {
|
||||
...current,
|
||||
[projectId]: {
|
||||
...projectState,
|
||||
runningTasks: projectState.runningTasks.filter((id) => id !== taskId),
|
||||
[worktreeKey]: {
|
||||
...worktreeState,
|
||||
runningTasks: worktreeState.runningTasks.filter((id) => id !== taskId),
|
||||
branchName,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
clearRunningTasks: (projectId) => {
|
||||
const current = get().autoModeByProject;
|
||||
const projectState = current[projectId] || {
|
||||
clearRunningTasks: (projectId, branchName) => {
|
||||
const worktreeKey = get().getWorktreeKey(projectId, branchName);
|
||||
const current = get().autoModeByWorktree;
|
||||
const worktreeState = current[worktreeKey] || {
|
||||
isRunning: false,
|
||||
runningTasks: [],
|
||||
branchName,
|
||||
};
|
||||
set({
|
||||
autoModeByProject: {
|
||||
autoModeByWorktree: {
|
||||
...current,
|
||||
[projectId]: { ...projectState, runningTasks: [] },
|
||||
[worktreeKey]: { ...worktreeState, runningTasks: [], branchName },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
getAutoModeState: (projectId) => {
|
||||
const projectState = get().autoModeByProject[projectId];
|
||||
return projectState || { isRunning: false, runningTasks: [] };
|
||||
getAutoModeState: (projectId, branchName) => {
|
||||
const worktreeKey = get().getWorktreeKey(projectId, branchName);
|
||||
const worktreeState = get().autoModeByWorktree[worktreeKey];
|
||||
return (
|
||||
worktreeState || {
|
||||
isRunning: false,
|
||||
runningTasks: [],
|
||||
branchName,
|
||||
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
getMaxConcurrencyForWorktree: (projectId, branchName) => {
|
||||
const worktreeKey = get().getWorktreeKey(projectId, branchName);
|
||||
const worktreeState = get().autoModeByWorktree[worktreeKey];
|
||||
return worktreeState?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
|
||||
},
|
||||
|
||||
setMaxConcurrencyForWorktree: (projectId, branchName, maxConcurrency) => {
|
||||
const worktreeKey = get().getWorktreeKey(projectId, branchName);
|
||||
const current = get().autoModeByWorktree;
|
||||
const worktreeState = current[worktreeKey] || {
|
||||
isRunning: false,
|
||||
runningTasks: [],
|
||||
branchName,
|
||||
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
||||
};
|
||||
set({
|
||||
autoModeByWorktree: {
|
||||
...current,
|
||||
[worktreeKey]: { ...worktreeState, maxConcurrency, branchName },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
addAutoModeActivity: (activity) => {
|
||||
@@ -2459,6 +2590,82 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Event Hook actions
|
||||
setEventHooks: (hooks) => set({ eventHooks: hooks }),
|
||||
|
||||
// Claude API Profile actions
|
||||
addClaudeApiProfile: async (profile) => {
|
||||
set({ claudeApiProfiles: [...get().claudeApiProfiles, profile] });
|
||||
// Sync immediately to persist profile
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
|
||||
updateClaudeApiProfile: async (id, updates) => {
|
||||
set({
|
||||
claudeApiProfiles: get().claudeApiProfiles.map((p) =>
|
||||
p.id === id ? { ...p, ...updates } : p
|
||||
),
|
||||
});
|
||||
// Sync immediately to persist changes
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
|
||||
deleteClaudeApiProfile: async (id) => {
|
||||
const currentActiveId = get().activeClaudeApiProfileId;
|
||||
const projects = get().projects;
|
||||
|
||||
// Find projects that have per-project override referencing the deleted profile
|
||||
const affectedProjects = projects.filter((p) => p.activeClaudeApiProfileId === id);
|
||||
|
||||
// Update state: remove profile and clear references
|
||||
set({
|
||||
claudeApiProfiles: get().claudeApiProfiles.filter((p) => p.id !== id),
|
||||
// Clear global active if the deleted profile was active
|
||||
activeClaudeApiProfileId: currentActiveId === id ? null : currentActiveId,
|
||||
// Clear per-project overrides that reference the deleted profile
|
||||
projects: projects.map((p) =>
|
||||
p.activeClaudeApiProfileId === id ? { ...p, activeClaudeApiProfileId: undefined } : p
|
||||
),
|
||||
});
|
||||
|
||||
// Also update currentProject if it was using the deleted profile
|
||||
const currentProject = get().currentProject;
|
||||
if (currentProject?.activeClaudeApiProfileId === id) {
|
||||
set({
|
||||
currentProject: { ...currentProject, activeClaudeApiProfileId: undefined },
|
||||
});
|
||||
}
|
||||
|
||||
// Persist per-project changes to server (use __USE_GLOBAL__ marker)
|
||||
const httpClient = getHttpApiClient();
|
||||
await Promise.all(
|
||||
affectedProjects.map((project) =>
|
||||
httpClient.settings
|
||||
.updateProject(project.path, { activeClaudeApiProfileId: '__USE_GLOBAL__' })
|
||||
.catch((error) => {
|
||||
console.error(`Failed to clear profile override for project ${project.name}:`, error);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Sync global settings to persist deletion
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
|
||||
setActiveClaudeApiProfile: async (id) => {
|
||||
set({ activeClaudeApiProfileId: id });
|
||||
// Sync immediately to persist active profile change
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
|
||||
setClaudeApiProfiles: async (profiles) => {
|
||||
set({ claudeApiProfiles: profiles });
|
||||
// Sync immediately to persist profiles
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
|
||||
// MCP Server actions
|
||||
addMCPServer: (server) => {
|
||||
const id = `mcp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
68
apps/ui/src/types/electron.d.ts
vendored
68
apps/ui/src/types/electron.d.ts
vendored
@@ -163,11 +163,30 @@ export interface SessionsAPI {
|
||||
}
|
||||
|
||||
export type AutoModeEvent =
|
||||
| {
|
||||
type: 'auto_mode_started';
|
||||
message: string;
|
||||
projectPath?: string;
|
||||
branchName?: string | null;
|
||||
}
|
||||
| {
|
||||
type: 'auto_mode_stopped';
|
||||
message: string;
|
||||
projectPath?: string;
|
||||
branchName?: string | null;
|
||||
}
|
||||
| {
|
||||
type: 'auto_mode_idle';
|
||||
message: string;
|
||||
projectPath?: string;
|
||||
branchName?: string | null;
|
||||
}
|
||||
| {
|
||||
type: 'auto_mode_feature_start';
|
||||
featureId: string;
|
||||
projectId?: string;
|
||||
projectPath?: string;
|
||||
branchName?: string | null;
|
||||
feature: unknown;
|
||||
}
|
||||
| {
|
||||
@@ -175,6 +194,7 @@ export type AutoModeEvent =
|
||||
featureId: string;
|
||||
projectId?: string;
|
||||
projectPath?: string;
|
||||
branchName?: string | null;
|
||||
content: string;
|
||||
}
|
||||
| {
|
||||
@@ -182,6 +202,7 @@ export type AutoModeEvent =
|
||||
featureId: string;
|
||||
projectId?: string;
|
||||
projectPath?: string;
|
||||
branchName?: string | null;
|
||||
tool: string;
|
||||
input: unknown;
|
||||
}
|
||||
@@ -190,6 +211,7 @@ export type AutoModeEvent =
|
||||
featureId: string;
|
||||
projectId?: string;
|
||||
projectPath?: string;
|
||||
branchName?: string | null;
|
||||
passes: boolean;
|
||||
message: string;
|
||||
}
|
||||
@@ -218,6 +240,7 @@ export type AutoModeEvent =
|
||||
featureId?: string;
|
||||
projectId?: string;
|
||||
projectPath?: string;
|
||||
branchName?: string | null;
|
||||
}
|
||||
| {
|
||||
type: 'auto_mode_phase';
|
||||
@@ -389,18 +412,48 @@ export interface SpecRegenerationAPI {
|
||||
}
|
||||
|
||||
export interface AutoModeAPI {
|
||||
start: (
|
||||
projectPath: string,
|
||||
branchName?: string | null,
|
||||
maxConcurrency?: number
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
alreadyRunning?: boolean;
|
||||
branchName?: string | null;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
stop: (
|
||||
projectPath: string,
|
||||
branchName?: string | null
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
wasRunning?: boolean;
|
||||
runningFeaturesCount?: number;
|
||||
branchName?: string | null;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
stopFeature: (featureId: string) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
status: (projectPath?: string) => Promise<{
|
||||
status: (
|
||||
projectPath?: string,
|
||||
branchName?: string | null
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
isRunning?: boolean;
|
||||
isAutoLoopRunning?: boolean;
|
||||
currentFeatureId?: string | null;
|
||||
runningFeatures?: string[];
|
||||
runningProjects?: string[];
|
||||
runningCount?: number;
|
||||
maxConcurrency?: number;
|
||||
branchName?: string | null;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
@@ -1165,6 +1218,19 @@ export interface WorktreeAPI {
|
||||
payload: unknown;
|
||||
}) => void
|
||||
) => () => void;
|
||||
|
||||
// Discard changes for a worktree
|
||||
discardChanges: (worktreePath: string) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
discarded: boolean;
|
||||
filesDiscarded: number;
|
||||
filesRemaining: number;
|
||||
branch: string;
|
||||
message: string;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GitAPI {
|
||||
|
||||
Reference in New Issue
Block a user