mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
Merge remote-tracking branch 'origin/main' into feature/v0.13.0rc-1768936017583-e6ni
# Conflicts: # apps/ui/src/components/views/board-view.tsx
This commit is contained in:
@@ -45,6 +45,8 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
setCardBorderOpacity,
|
||||
setHideScrollbar,
|
||||
clearBoardBackground,
|
||||
persistSettings,
|
||||
getCurrentSettings,
|
||||
} = useBoardBackgroundSettings();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
@@ -55,12 +57,31 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
const backgroundSettings =
|
||||
(currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings;
|
||||
|
||||
const cardOpacity = backgroundSettings.cardOpacity;
|
||||
const columnOpacity = backgroundSettings.columnOpacity;
|
||||
// Local state for sliders during dragging (avoids store updates during drag)
|
||||
const [localCardOpacity, setLocalCardOpacity] = useState(backgroundSettings.cardOpacity);
|
||||
const [localColumnOpacity, setLocalColumnOpacity] = useState(backgroundSettings.columnOpacity);
|
||||
const [localCardBorderOpacity, setLocalCardBorderOpacity] = useState(
|
||||
backgroundSettings.cardBorderOpacity
|
||||
);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Sync local state with store when not dragging (e.g., on modal open or external changes)
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
setLocalCardOpacity(backgroundSettings.cardOpacity);
|
||||
setLocalColumnOpacity(backgroundSettings.columnOpacity);
|
||||
setLocalCardBorderOpacity(backgroundSettings.cardBorderOpacity);
|
||||
}
|
||||
}, [
|
||||
isDragging,
|
||||
backgroundSettings.cardOpacity,
|
||||
backgroundSettings.columnOpacity,
|
||||
backgroundSettings.cardBorderOpacity,
|
||||
]);
|
||||
|
||||
const columnBorderEnabled = backgroundSettings.columnBorderEnabled;
|
||||
const cardGlassmorphism = backgroundSettings.cardGlassmorphism;
|
||||
const cardBorderEnabled = backgroundSettings.cardBorderEnabled;
|
||||
const cardBorderOpacity = backgroundSettings.cardBorderOpacity;
|
||||
const hideScrollbar = backgroundSettings.hideScrollbar;
|
||||
const imageVersion = backgroundSettings.imageVersion;
|
||||
|
||||
@@ -198,21 +219,40 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
}
|
||||
}, [currentProject, clearBoardBackground]);
|
||||
|
||||
// Live update opacity when sliders change (with persistence)
|
||||
const handleCardOpacityChange = useCallback(
|
||||
async (value: number[]) => {
|
||||
// Live update local state during drag (modal-only, no store update)
|
||||
const handleCardOpacityChange = useCallback((value: number[]) => {
|
||||
setIsDragging(true);
|
||||
setLocalCardOpacity(value[0]);
|
||||
}, []);
|
||||
|
||||
// Update store and persist when slider is released
|
||||
const handleCardOpacityCommit = useCallback(
|
||||
(value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
await setCardOpacity(currentProject.path, value[0]);
|
||||
setIsDragging(false);
|
||||
setCardOpacity(currentProject.path, value[0]);
|
||||
const current = getCurrentSettings(currentProject.path);
|
||||
persistSettings(currentProject.path, { ...current, cardOpacity: value[0] });
|
||||
},
|
||||
[currentProject, setCardOpacity]
|
||||
[currentProject, setCardOpacity, getCurrentSettings, persistSettings]
|
||||
);
|
||||
|
||||
const handleColumnOpacityChange = useCallback(
|
||||
async (value: number[]) => {
|
||||
// Live update local state during drag (modal-only, no store update)
|
||||
const handleColumnOpacityChange = useCallback((value: number[]) => {
|
||||
setIsDragging(true);
|
||||
setLocalColumnOpacity(value[0]);
|
||||
}, []);
|
||||
|
||||
// Update store and persist when slider is released
|
||||
const handleColumnOpacityCommit = useCallback(
|
||||
(value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
await setColumnOpacity(currentProject.path, value[0]);
|
||||
setIsDragging(false);
|
||||
setColumnOpacity(currentProject.path, value[0]);
|
||||
const current = getCurrentSettings(currentProject.path);
|
||||
persistSettings(currentProject.path, { ...current, columnOpacity: value[0] });
|
||||
},
|
||||
[currentProject, setColumnOpacity]
|
||||
[currentProject, setColumnOpacity, getCurrentSettings, persistSettings]
|
||||
);
|
||||
|
||||
const handleColumnBorderToggle = useCallback(
|
||||
@@ -239,12 +279,22 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
[currentProject, setCardBorderEnabled]
|
||||
);
|
||||
|
||||
const handleCardBorderOpacityChange = useCallback(
|
||||
async (value: number[]) => {
|
||||
// Live update local state during drag (modal-only, no store update)
|
||||
const handleCardBorderOpacityChange = useCallback((value: number[]) => {
|
||||
setIsDragging(true);
|
||||
setLocalCardBorderOpacity(value[0]);
|
||||
}, []);
|
||||
|
||||
// Update store and persist when slider is released
|
||||
const handleCardBorderOpacityCommit = useCallback(
|
||||
(value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
await setCardBorderOpacity(currentProject.path, value[0]);
|
||||
setIsDragging(false);
|
||||
setCardBorderOpacity(currentProject.path, value[0]);
|
||||
const current = getCurrentSettings(currentProject.path);
|
||||
persistSettings(currentProject.path, { ...current, cardBorderOpacity: value[0] });
|
||||
},
|
||||
[currentProject, setCardBorderOpacity]
|
||||
[currentProject, setCardBorderOpacity, getCurrentSettings, persistSettings]
|
||||
);
|
||||
|
||||
const handleHideScrollbarToggle = useCallback(
|
||||
@@ -378,11 +428,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Card Opacity</Label>
|
||||
<span className="text-sm text-muted-foreground">{cardOpacity}%</span>
|
||||
<span className="text-sm text-muted-foreground">{localCardOpacity}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cardOpacity]}
|
||||
value={[localCardOpacity]}
|
||||
onValueChange={handleCardOpacityChange}
|
||||
onValueCommit={handleCardOpacityCommit}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
@@ -393,11 +444,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Column Opacity</Label>
|
||||
<span className="text-sm text-muted-foreground">{columnOpacity}%</span>
|
||||
<span className="text-sm text-muted-foreground">{localColumnOpacity}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[columnOpacity]}
|
||||
value={[localColumnOpacity]}
|
||||
onValueChange={handleColumnOpacityChange}
|
||||
onValueCommit={handleColumnOpacityCommit}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
@@ -446,11 +498,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Card Border Opacity</Label>
|
||||
<span className="text-sm text-muted-foreground">{cardBorderOpacity}%</span>
|
||||
<span className="text-sm text-muted-foreground">{localCardBorderOpacity}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cardBorderOpacity]}
|
||||
value={[localCardBorderOpacity]}
|
||||
onValueChange={handleCardBorderOpacityChange}
|
||||
onValueCommit={handleCardBorderOpacityCommit}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
|
||||
@@ -523,6 +523,15 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for ClaudeCompatibleProvider model patterns (GLM, MiniMax, etc.)
|
||||
// These are model IDs like "GLM-4.5-Air", "GLM-4.7", "MiniMax-M2.1"
|
||||
if (modelStr.includes('glm')) {
|
||||
return 'glm';
|
||||
}
|
||||
if (modelStr.includes('minimax')) {
|
||||
return 'minimax';
|
||||
}
|
||||
|
||||
// Check for Cursor-specific models with underlying providers
|
||||
if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) {
|
||||
return 'anthropic';
|
||||
|
||||
@@ -636,11 +636,7 @@ export function BoardView() {
|
||||
const result = await api.features.bulkUpdate(currentProject.path, featureIds, finalUpdates);
|
||||
|
||||
if (result.success) {
|
||||
// Update local Zustand state
|
||||
featureIds.forEach((featureId) => {
|
||||
updateFeature(featureId, finalUpdates);
|
||||
});
|
||||
// Invalidate React Query cache to ensure features are refetched with updated data
|
||||
// Invalidate React Query cache to refetch features with server-updated values
|
||||
loadFeatures();
|
||||
toast.success(`Updated ${result.updatedCount} features`);
|
||||
exitSelectionMode();
|
||||
@@ -657,13 +653,12 @@ export function BoardView() {
|
||||
[
|
||||
currentProject,
|
||||
selectedFeatureIds,
|
||||
updateFeature,
|
||||
loadFeatures,
|
||||
exitSelectionMode,
|
||||
getPrimaryWorktreeBranch,
|
||||
addAndSelectWorktree,
|
||||
currentWorktreeBranch,
|
||||
setWorktreeRefreshKey,
|
||||
loadFeatures,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -786,10 +781,8 @@ export function BoardView() {
|
||||
const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates);
|
||||
|
||||
if (result.success) {
|
||||
// Update local state for all features
|
||||
featureIds.forEach((featureId) => {
|
||||
updateFeature(featureId, updates);
|
||||
});
|
||||
// Invalidate React Query cache to refetch features with server-updated values
|
||||
loadFeatures();
|
||||
toast.success(`Verified ${result.updatedCount} features`);
|
||||
exitSelectionMode();
|
||||
} else {
|
||||
@@ -801,7 +794,7 @@ export function BoardView() {
|
||||
logger.error('Bulk verify failed:', error);
|
||||
toast.error('Failed to verify features');
|
||||
}
|
||||
}, [currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]);
|
||||
}, [currentProject, selectedFeatureIds, loadFeatures, exitSelectionMode]);
|
||||
|
||||
// Handler for addressing PR comments - creates a feature and starts it automatically
|
||||
const handleAddressPRComments = useCallback(
|
||||
|
||||
@@ -142,7 +142,8 @@ export function BoardHeader({
|
||||
onConcurrencyChange={onConcurrencyChange}
|
||||
isAutoModeRunning={isAutoModeRunning}
|
||||
onAutoModeToggle={onAutoModeToggle}
|
||||
onOpenAutoModeSettings={() => {}}
|
||||
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
||||
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
||||
onOpenPlanDialog={onOpenPlanDialog}
|
||||
showClaudeUsage={showClaudeUsage}
|
||||
showCodexUsage={showCodexUsage}
|
||||
|
||||
@@ -180,8 +180,10 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
'kanban-card-content h-full relative',
|
||||
reduceEffects ? 'shadow-none' : 'shadow-sm',
|
||||
'transition-all duration-200 ease-out',
|
||||
// Disable hover translate for in-progress cards to prevent gap showing gradient
|
||||
isInteractive &&
|
||||
!reduceEffects &&
|
||||
!isCurrentAutoTask &&
|
||||
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||
!isCurrentAutoTask &&
|
||||
|
||||
@@ -35,10 +35,10 @@ export const LIST_COLUMNS: ColumnDef[] = [
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
label: '',
|
||||
label: 'Priority',
|
||||
sortable: true,
|
||||
width: 'w-18',
|
||||
minWidth: 'min-w-[16px]',
|
||||
width: 'w-20',
|
||||
minWidth: 'min-w-[60px]',
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -142,6 +142,7 @@ export function MassEditDialog({
|
||||
// Field values
|
||||
const [model, setModel] = useState<ModelAlias>('claude-sonnet');
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
|
||||
const [providerId, setProviderId] = useState<string | undefined>(undefined);
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||
const [priority, setPriority] = useState(2);
|
||||
@@ -182,6 +183,7 @@ export function MassEditDialog({
|
||||
});
|
||||
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
||||
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
||||
setProviderId(undefined); // Features don't store providerId, but we track it after selection
|
||||
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
|
||||
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
|
||||
setPriority(getInitialValue(selectedFeatures, 'priority', 2));
|
||||
@@ -254,10 +256,11 @@ export function MassEditDialog({
|
||||
Select a specific model configuration
|
||||
</p>
|
||||
<PhaseModelSelector
|
||||
value={{ model, thinkingLevel }}
|
||||
value={{ model, thinkingLevel, providerId }}
|
||||
onChange={(entry: PhaseModelEntry) => {
|
||||
setModel(entry.model as ModelAlias);
|
||||
setThinkingLevel(entry.thinkingLevel || 'none');
|
||||
setProviderId(entry.providerId);
|
||||
// Auto-enable model and thinking level for apply state
|
||||
setApplyState((prev) => ({
|
||||
...prev,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
HeaderActionsPanel,
|
||||
HeaderActionsPanelTrigger,
|
||||
} from '@/components/ui/header-actions-panel';
|
||||
import { Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
|
||||
import { Bot, Wand2, GitBranch, Zap, FastForward } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MobileUsageBar } from './mobile-usage-bar';
|
||||
|
||||
@@ -23,7 +23,8 @@ interface HeaderMobileMenuProps {
|
||||
// Auto mode
|
||||
isAutoModeRunning: boolean;
|
||||
onAutoModeToggle: (enabled: boolean) => void;
|
||||
onOpenAutoModeSettings: () => void;
|
||||
skipVerificationInAutoMode: boolean;
|
||||
onSkipVerificationChange: (value: boolean) => void;
|
||||
// Plan button
|
||||
onOpenPlanDialog: () => void;
|
||||
// Usage bar visibility
|
||||
@@ -41,7 +42,8 @@ export function HeaderMobileMenu({
|
||||
onConcurrencyChange,
|
||||
isAutoModeRunning,
|
||||
onAutoModeToggle,
|
||||
onOpenAutoModeSettings,
|
||||
skipVerificationInAutoMode,
|
||||
onSkipVerificationChange,
|
||||
onOpenPlanDialog,
|
||||
showClaudeUsage,
|
||||
showCodexUsage,
|
||||
@@ -66,29 +68,23 @@ export function HeaderMobileMenu({
|
||||
Controls
|
||||
</span>
|
||||
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
|
||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||
data-testid="mobile-auto-mode-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<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">
|
||||
{/* Auto Mode Section */}
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||
data-testid="mobile-auto-mode-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Auto Mode</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="mobile-auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
@@ -96,17 +92,51 @@ export function HeaderMobileMenu({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-auto-mode-toggle"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenAutoModeSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="mobile-auto-mode-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Skip Verification Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 pl-9 cursor-pointer hover:bg-accent/50 border-t border-border/30 transition-colors"
|
||||
onClick={() => onSkipVerificationChange(!skipVerificationInAutoMode)}
|
||||
data-testid="mobile-skip-verification-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FastForward className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Skip Verification</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="mobile-skip-verification-toggle"
|
||||
checked={skipVerificationInAutoMode}
|
||||
onCheckedChange={onSkipVerificationChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-skip-verification-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Concurrency Control */}
|
||||
<div
|
||||
className="p-3 pl-9 border-t border-border/30"
|
||||
data-testid="mobile-concurrency-control"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -129,32 +159,6 @@ export function HeaderMobileMenu({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Concurrency Control */}
|
||||
<div
|
||||
className="p-3 rounded-lg border border-border/50"
|
||||
data-testid="mobile-concurrency-control"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Plan Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -487,7 +487,15 @@ export function useBoardActions({
|
||||
const handleStartImplementation = useCallback(
|
||||
async (feature: Feature) => {
|
||||
// Check capacity for the feature's specific worktree, not the current view
|
||||
const featureBranchName = feature.branchName ?? null;
|
||||
// Normalize the branch name: if the feature's branch is the primary worktree branch,
|
||||
// treat it as null (main worktree) to match how running tasks are stored
|
||||
const rawBranchName = feature.branchName ?? null;
|
||||
const featureBranchName =
|
||||
currentProject?.path &&
|
||||
rawBranchName &&
|
||||
isPrimaryWorktreeBranch(currentProject.path, rawBranchName)
|
||||
? null
|
||||
: rawBranchName;
|
||||
const featureWorktreeState = currentProject
|
||||
? getAutoModeState(currentProject.id, featureBranchName)
|
||||
: null;
|
||||
@@ -567,6 +575,7 @@ export function useBoardActions({
|
||||
handleRunFeature,
|
||||
currentProject,
|
||||
getAutoModeState,
|
||||
isPrimaryWorktreeBranch,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -128,15 +128,22 @@ export function useBoardDragDrop({
|
||||
const targetBranch = worktreeData.branch;
|
||||
const currentBranch = draggedFeature.branchName;
|
||||
|
||||
// For main worktree, set branchName to null to indicate it should use main
|
||||
// (must use null not undefined so it serializes to JSON for the API call)
|
||||
// For other worktrees, set branchName to the target branch
|
||||
const newBranchName = worktreeData.isMain ? null : targetBranch;
|
||||
|
||||
// If already on the same branch, nothing to do
|
||||
if (currentBranch === targetBranch) {
|
||||
// For main worktree: feature with null/undefined branchName is already on main
|
||||
// For other worktrees: compare branch names directly
|
||||
const isAlreadyOnTarget = worktreeData.isMain
|
||||
? !currentBranch // null or undefined means already on main
|
||||
: currentBranch === targetBranch;
|
||||
|
||||
if (isAlreadyOnTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For main worktree, set branchName to undefined/null to indicate it should use main
|
||||
// For other worktrees, set branchName to the target branch
|
||||
const newBranchName = worktreeData.isMain ? undefined : targetBranch;
|
||||
|
||||
// Update feature's branchName
|
||||
updateFeature(featureId, { branchName: newBranchName });
|
||||
await persistFeatureUpdate(featureId, { branchName: newBranchName });
|
||||
|
||||
@@ -17,11 +17,8 @@ export function useRunningFeatures({ runningFeatureIds, features }: UseRunningFe
|
||||
|
||||
// Match by branchName only (worktreePath is no longer stored)
|
||||
if (feature.branchName) {
|
||||
// Special case: if feature is on 'main' branch, it belongs to main worktree
|
||||
// irrespective of whether the branch name matches exactly (it should, but strict equality might fail if refs differ)
|
||||
if (worktree.isMain && feature.branchName === 'main') {
|
||||
return true;
|
||||
}
|
||||
// Check if branch names match - this handles both main worktree (any primary branch name)
|
||||
// and feature worktrees
|
||||
return worktree.branch === feature.branchName;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { User, GitBranch, Palette, AlertTriangle, Bot } from 'lucide-react';
|
||||
import { User, GitBranch, Palette, AlertTriangle, Workflow } from 'lucide-react';
|
||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||
|
||||
export interface ProjectNavigationItem {
|
||||
@@ -12,6 +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: 'claude', label: 'Models', icon: Workflow },
|
||||
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ArrowRight, Cloud, Server, Check, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import type {
|
||||
PhaseModelKey,
|
||||
PhaseModelEntry,
|
||||
ClaudeCompatibleProvider,
|
||||
ClaudeModelAlias,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
|
||||
interface ProjectBulkReplaceDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project: Project;
|
||||
}
|
||||
|
||||
// Phase display names for preview
|
||||
const PHASE_LABELS: Record<PhaseModelKey, string> = {
|
||||
enhancementModel: 'Feature Enhancement',
|
||||
fileDescriptionModel: 'File Descriptions',
|
||||
imageDescriptionModel: 'Image Descriptions',
|
||||
commitMessageModel: 'Commit Messages',
|
||||
validationModel: 'GitHub Issue Validation',
|
||||
specGenerationModel: 'App Specification',
|
||||
featureGenerationModel: 'Feature Generation',
|
||||
backlogPlanningModel: 'Backlog Planning',
|
||||
projectAnalysisModel: 'Project Analysis',
|
||||
suggestionsModel: 'AI Suggestions',
|
||||
memoryExtractionModel: 'Memory Extraction',
|
||||
};
|
||||
|
||||
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
||||
|
||||
// Claude model display names
|
||||
const CLAUDE_MODEL_DISPLAY: Record<ClaudeModelAlias, string> = {
|
||||
haiku: 'Claude Haiku',
|
||||
sonnet: 'Claude Sonnet',
|
||||
opus: 'Claude Opus',
|
||||
};
|
||||
|
||||
export function ProjectBulkReplaceDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
project,
|
||||
}: ProjectBulkReplaceDialogProps) {
|
||||
const { phaseModels, setProjectPhaseModelOverride, claudeCompatibleProviders } = useAppStore();
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>('anthropic');
|
||||
|
||||
// Get project-level overrides
|
||||
const projectOverrides = project.phaseModelOverrides || {};
|
||||
|
||||
// Get enabled providers
|
||||
const enabledProviders = useMemo(() => {
|
||||
return (claudeCompatibleProviders || []).filter((p) => p.enabled !== false);
|
||||
}, [claudeCompatibleProviders]);
|
||||
|
||||
// Build provider options for the dropdown
|
||||
const providerOptions = useMemo(() => {
|
||||
const options: Array<{ id: string; name: string; isNative: boolean }> = [
|
||||
{ id: 'anthropic', name: 'Anthropic Direct', isNative: true },
|
||||
];
|
||||
|
||||
enabledProviders.forEach((provider) => {
|
||||
options.push({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
isNative: false,
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
}, [enabledProviders]);
|
||||
|
||||
// Get the selected provider config (if custom)
|
||||
const selectedProviderConfig = useMemo(() => {
|
||||
if (selectedProvider === 'anthropic') return null;
|
||||
return enabledProviders.find((p) => p.id === selectedProvider);
|
||||
}, [selectedProvider, enabledProviders]);
|
||||
|
||||
// Get the Claude model alias from a PhaseModelEntry
|
||||
const getClaudeModelAlias = (entry: PhaseModelEntry): ClaudeModelAlias => {
|
||||
// Check if model string directly matches a Claude alias
|
||||
if (entry.model === 'haiku' || entry.model === 'claude-haiku') return 'haiku';
|
||||
if (entry.model === 'sonnet' || entry.model === 'claude-sonnet') return 'sonnet';
|
||||
if (entry.model === 'opus' || entry.model === 'claude-opus') return 'opus';
|
||||
|
||||
// If it's a provider model, look up the mapping
|
||||
if (entry.providerId) {
|
||||
const provider = enabledProviders.find((p) => p.id === entry.providerId);
|
||||
if (provider) {
|
||||
const model = provider.models?.find((m) => m.id === entry.model);
|
||||
if (model?.mapsToClaudeModel) {
|
||||
return model.mapsToClaudeModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to sonnet
|
||||
return 'sonnet';
|
||||
};
|
||||
|
||||
// Find the model from provider that maps to a specific Claude model
|
||||
const findModelForClaudeAlias = (
|
||||
provider: ClaudeCompatibleProvider | null,
|
||||
claudeAlias: ClaudeModelAlias,
|
||||
phase: PhaseModelKey
|
||||
): PhaseModelEntry => {
|
||||
if (!provider) {
|
||||
// Anthropic Direct - reset to default phase model (includes correct thinking levels)
|
||||
return DEFAULT_PHASE_MODELS[phase];
|
||||
}
|
||||
|
||||
// Find model that maps to this Claude alias
|
||||
const models = provider.models || [];
|
||||
const match = models.find((m) => m.mapsToClaudeModel === claudeAlias);
|
||||
|
||||
if (match) {
|
||||
return { providerId: provider.id, model: match.id };
|
||||
}
|
||||
|
||||
// Fallback: use first model if no match
|
||||
if (models.length > 0) {
|
||||
return { providerId: provider.id, model: models[0].id };
|
||||
}
|
||||
|
||||
// Ultimate fallback to native Claude model
|
||||
return { model: claudeAlias };
|
||||
};
|
||||
|
||||
// Generate preview of changes
|
||||
const preview = useMemo(() => {
|
||||
return ALL_PHASES.map((phase) => {
|
||||
// Current effective value (project override or global)
|
||||
const globalEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase];
|
||||
const currentEntry = projectOverrides[phase] || globalEntry;
|
||||
const claudeAlias = getClaudeModelAlias(currentEntry);
|
||||
const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, phase);
|
||||
|
||||
// Get display names
|
||||
const getCurrentDisplay = (): string => {
|
||||
if (currentEntry.providerId) {
|
||||
const provider = enabledProviders.find((p) => p.id === currentEntry.providerId);
|
||||
if (provider) {
|
||||
const model = provider.models?.find((m) => m.id === currentEntry.model);
|
||||
return model?.displayName || currentEntry.model;
|
||||
}
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model;
|
||||
};
|
||||
|
||||
const getNewDisplay = (): string => {
|
||||
if (newEntry.providerId && selectedProviderConfig) {
|
||||
const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model);
|
||||
return model?.displayName || newEntry.model;
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model;
|
||||
};
|
||||
|
||||
const isChanged =
|
||||
currentEntry.model !== newEntry.model ||
|
||||
currentEntry.providerId !== newEntry.providerId ||
|
||||
currentEntry.thinkingLevel !== newEntry.thinkingLevel;
|
||||
|
||||
return {
|
||||
phase,
|
||||
label: PHASE_LABELS[phase],
|
||||
claudeAlias,
|
||||
currentDisplay: getCurrentDisplay(),
|
||||
newDisplay: getNewDisplay(),
|
||||
newEntry,
|
||||
isChanged,
|
||||
};
|
||||
});
|
||||
}, [phaseModels, projectOverrides, selectedProviderConfig, enabledProviders]);
|
||||
|
||||
// Count how many will change
|
||||
const changeCount = preview.filter((p) => p.isChanged).length;
|
||||
|
||||
// Apply the bulk replace as project overrides
|
||||
const handleApply = () => {
|
||||
preview.forEach(({ phase, newEntry, isChanged }) => {
|
||||
if (isChanged) {
|
||||
setProjectPhaseModelOverride(project.id, phase, newEntry);
|
||||
}
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// Check if provider has all 3 Claude model mappings
|
||||
const providerModelCoverage = useMemo(() => {
|
||||
if (selectedProvider === 'anthropic') {
|
||||
return { hasHaiku: true, hasSonnet: true, hasOpus: true, complete: true };
|
||||
}
|
||||
if (!selectedProviderConfig) {
|
||||
return { hasHaiku: false, hasSonnet: false, hasOpus: false, complete: false };
|
||||
}
|
||||
const models = selectedProviderConfig.models || [];
|
||||
const hasHaiku = models.some((m) => m.mapsToClaudeModel === 'haiku');
|
||||
const hasSonnet = models.some((m) => m.mapsToClaudeModel === 'sonnet');
|
||||
const hasOpus = models.some((m) => m.mapsToClaudeModel === 'opus');
|
||||
return { hasHaiku, hasSonnet, hasOpus, complete: hasHaiku && hasSonnet && hasOpus };
|
||||
}, [selectedProvider, selectedProviderConfig]);
|
||||
|
||||
const providerHasModels =
|
||||
selectedProvider === 'anthropic' ||
|
||||
(selectedProviderConfig && selectedProviderConfig.models?.length > 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bulk Replace Models (Project Override)</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set project-level overrides for all phases to use models from a specific provider. This
|
||||
only affects this project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Provider selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Target Provider</label>
|
||||
<Select value={selectedProvider} onValueChange={setSelectedProvider}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerOptions.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.isNative ? (
|
||||
<Cloud className="w-4 h-4 text-brand-500" />
|
||||
) : (
|
||||
<Server className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<span>{option.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Warning if provider has no models */}
|
||||
{!providerHasModels && (
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-sm">
|
||||
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>This provider has no models configured.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning if provider doesn't have all 3 mappings */}
|
||||
{providerHasModels && !providerModelCoverage.complete && (
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-sm">
|
||||
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>
|
||||
This provider is missing mappings for:{' '}
|
||||
{[
|
||||
!providerModelCoverage.hasHaiku && 'Haiku',
|
||||
!providerModelCoverage.hasSonnet && 'Sonnet',
|
||||
!providerModelCoverage.hasOpus && 'Opus',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview of changes */}
|
||||
{providerHasModels && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Preview Changes</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{changeCount} of {ALL_PHASES.length} will be overridden
|
||||
</span>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left p-2 font-medium text-muted-foreground">Phase</th>
|
||||
<th className="text-left p-2 font-medium text-muted-foreground">Current</th>
|
||||
<th className="p-2"></th>
|
||||
<th className="text-left p-2 font-medium text-muted-foreground">
|
||||
New Override
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => (
|
||||
<tr
|
||||
key={phase}
|
||||
className={cn(
|
||||
'border-t border-border/50',
|
||||
isChanged ? 'bg-brand-500/5' : 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<td className="p-2 font-medium">{label}</td>
|
||||
<td className="p-2 text-muted-foreground">{currentDisplay}</td>
|
||||
<td className="p-2 text-center">
|
||||
{isChanged ? (
|
||||
<ArrowRight className="w-4 h-4 text-brand-500 inline" />
|
||||
) : (
|
||||
<Check className="w-4 h-4 text-green-500 inline" />
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className={cn(isChanged && 'text-brand-500 font-medium')}>
|
||||
{newDisplay}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleApply} disabled={!providerHasModels || changeCount === 0}>
|
||||
Apply Overrides ({changeCount})
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export function ProjectClaudeSection({ project }: ProjectClaudeSectionProps) {
|
||||
<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.
|
||||
Enable Claude and configure providers in global settings to use per-project overrides.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -95,21 +95,19 @@ export function ProjectClaudeSection({ project }: ProjectClaudeSectionProps) {
|
||||
<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>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Claude Provider</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Override the Claude API profile for this project only.
|
||||
Override the Claude provider 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>
|
||||
<Label className="text-sm font-medium">Active Provider for This Project</Label>
|
||||
<Select value={selectValue} onValueChange={handleChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select profile" />
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Workflow, RotateCcw, Globe, Check, Replace } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||
import { ProjectBulkReplaceDialog } from './project-bulk-replace-dialog';
|
||||
import type { PhaseModelKey, PhaseModelEntry } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
|
||||
interface ProjectModelsSectionProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
interface PhaseConfig {
|
||||
key: PhaseModelKey;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const QUICK_TASKS: PhaseConfig[] = [
|
||||
{
|
||||
key: 'enhancementModel',
|
||||
label: 'Feature Enhancement',
|
||||
description: 'Improves feature names and descriptions',
|
||||
},
|
||||
{
|
||||
key: 'fileDescriptionModel',
|
||||
label: 'File Descriptions',
|
||||
description: 'Generates descriptions for context files',
|
||||
},
|
||||
{
|
||||
key: 'imageDescriptionModel',
|
||||
label: 'Image Descriptions',
|
||||
description: 'Analyzes and describes context images',
|
||||
},
|
||||
{
|
||||
key: 'commitMessageModel',
|
||||
label: 'Commit Messages',
|
||||
description: 'Generates git commit messages from diffs',
|
||||
},
|
||||
];
|
||||
|
||||
const VALIDATION_TASKS: PhaseConfig[] = [
|
||||
{
|
||||
key: 'validationModel',
|
||||
label: 'GitHub Issue Validation',
|
||||
description: 'Validates and improves GitHub issues',
|
||||
},
|
||||
];
|
||||
|
||||
const GENERATION_TASKS: PhaseConfig[] = [
|
||||
{
|
||||
key: 'specGenerationModel',
|
||||
label: 'App Specification',
|
||||
description: 'Generates full application specifications',
|
||||
},
|
||||
{
|
||||
key: 'featureGenerationModel',
|
||||
label: 'Feature Generation',
|
||||
description: 'Creates features from specifications',
|
||||
},
|
||||
{
|
||||
key: 'backlogPlanningModel',
|
||||
label: 'Backlog Planning',
|
||||
description: 'Reorganizes and prioritizes backlog',
|
||||
},
|
||||
{
|
||||
key: 'projectAnalysisModel',
|
||||
label: 'Project Analysis',
|
||||
description: 'Analyzes project structure for suggestions',
|
||||
},
|
||||
{
|
||||
key: 'suggestionsModel',
|
||||
label: 'AI Suggestions',
|
||||
description: 'Model for feature, refactoring, security, and performance suggestions',
|
||||
},
|
||||
];
|
||||
|
||||
const MEMORY_TASKS: PhaseConfig[] = [
|
||||
{
|
||||
key: 'memoryExtractionModel',
|
||||
label: 'Memory Extraction',
|
||||
description: 'Extracts learnings from completed agent sessions',
|
||||
},
|
||||
];
|
||||
|
||||
const ALL_PHASES = [...QUICK_TASKS, ...VALIDATION_TASKS, ...GENERATION_TASKS, ...MEMORY_TASKS];
|
||||
|
||||
function PhaseOverrideItem({
|
||||
phase,
|
||||
project,
|
||||
globalValue,
|
||||
projectOverride,
|
||||
}: {
|
||||
phase: PhaseConfig;
|
||||
project: Project;
|
||||
globalValue: PhaseModelEntry;
|
||||
projectOverride?: PhaseModelEntry;
|
||||
}) {
|
||||
const { setProjectPhaseModelOverride, claudeCompatibleProviders } = useAppStore();
|
||||
|
||||
const hasOverride = !!projectOverride;
|
||||
const effectiveValue = projectOverride || globalValue;
|
||||
|
||||
// Get display name for a model
|
||||
const getModelDisplayName = (entry: PhaseModelEntry): string => {
|
||||
if (entry.providerId) {
|
||||
const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId);
|
||||
if (provider) {
|
||||
const model = provider.models?.find((m) => m.id === entry.model);
|
||||
if (model) {
|
||||
return `${model.displayName} (${provider.name})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Default to model ID for built-in models (both short aliases and canonical IDs)
|
||||
const modelMap: Record<string, string> = {
|
||||
haiku: 'Claude Haiku',
|
||||
sonnet: 'Claude Sonnet',
|
||||
opus: 'Claude Opus',
|
||||
'claude-haiku': 'Claude Haiku',
|
||||
'claude-sonnet': 'Claude Sonnet',
|
||||
'claude-opus': 'Claude Opus',
|
||||
};
|
||||
return modelMap[entry.model] || entry.model;
|
||||
};
|
||||
|
||||
const handleClearOverride = () => {
|
||||
setProjectPhaseModelOverride(project.id, phase.key, null);
|
||||
};
|
||||
|
||||
const handleSetOverride = (entry: PhaseModelEntry) => {
|
||||
setProjectPhaseModelOverride(project.id, phase.key, entry);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between p-4 rounded-xl',
|
||||
'bg-accent/20 border',
|
||||
hasOverride ? 'border-brand-500/30 bg-brand-500/5' : 'border-border/30',
|
||||
'hover:bg-accent/30 transition-colors'
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-foreground">{phase.label}</h4>
|
||||
{hasOverride ? (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-brand-500/20 text-brand-500">
|
||||
Override
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
|
||||
<Globe className="w-3 h-3" />
|
||||
Global
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{phase.description}</p>
|
||||
{hasOverride && (
|
||||
<p className="text-xs text-brand-500 mt-1">
|
||||
Using: {getModelDisplayName(effectiveValue)}
|
||||
</p>
|
||||
)}
|
||||
{!hasOverride && (
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Using global: {getModelDisplayName(globalValue)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearOverride}
|
||||
className="h-8 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
<PhaseModelSelector
|
||||
compact
|
||||
value={effectiveValue}
|
||||
onChange={handleSetOverride}
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhaseGroup({
|
||||
title,
|
||||
subtitle,
|
||||
phases,
|
||||
project,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
phases: PhaseConfig[];
|
||||
project: Project;
|
||||
}) {
|
||||
const { phaseModels } = useAppStore();
|
||||
const projectOverrides = project.phaseModelOverrides || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">{title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{phases.map((phase) => (
|
||||
<PhaseOverrideItem
|
||||
key={phase.key}
|
||||
phase={phase}
|
||||
project={project}
|
||||
globalValue={phaseModels[phase.key] ?? DEFAULT_PHASE_MODELS[phase.key]}
|
||||
projectOverride={projectOverrides[phase.key]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
|
||||
const { clearAllProjectPhaseModelOverrides, disabledProviders, claudeCompatibleProviders } =
|
||||
useAppStore();
|
||||
const [showBulkReplace, setShowBulkReplace] = useState(false);
|
||||
|
||||
// Count how many overrides are set
|
||||
const overrideCount = Object.keys(project.phaseModelOverrides || {}).length;
|
||||
|
||||
// Check if Claude is available
|
||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||
|
||||
// Check if there are any enabled ClaudeCompatibleProviders
|
||||
const hasEnabledProviders =
|
||||
claudeCompatibleProviders && claudeCompatibleProviders.some((p) => p.enabled !== false);
|
||||
|
||||
if (isClaudeDisabled) {
|
||||
return (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Workflow 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 in global settings to configure per-project model overrides.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
clearAllProjectPhaseModelOverrides(project.id);
|
||||
};
|
||||
|
||||
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'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<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 justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<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">
|
||||
<Workflow className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Model Overrides
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground/80">
|
||||
Override AI models for this project only
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasEnabledProviders && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowBulkReplace(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Replace className="w-3.5 h-3.5" />
|
||||
Bulk Replace
|
||||
</Button>
|
||||
)}
|
||||
{overrideCount > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleClearAll} className="gap-2">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reset All ({overrideCount})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Replace Dialog */}
|
||||
<ProjectBulkReplaceDialog
|
||||
open={showBulkReplace}
|
||||
onOpenChange={setShowBulkReplace}
|
||||
project={project}
|
||||
/>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="px-6 pt-6">
|
||||
<div className="p-3 rounded-lg bg-brand-500/5 border border-brand-500/20 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Check className="w-4 h-4 text-brand-500" />
|
||||
<span className="font-medium text-foreground">Per-Phase Overrides</span>
|
||||
</div>
|
||||
Override specific phases to use different models for this project. Phases without
|
||||
overrides use the global settings.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-8">
|
||||
{/* Quick Tasks */}
|
||||
<PhaseGroup
|
||||
title="Quick Tasks"
|
||||
subtitle="Fast models recommended for speed and cost savings"
|
||||
phases={QUICK_TASKS}
|
||||
project={project}
|
||||
/>
|
||||
|
||||
{/* Validation Tasks */}
|
||||
<PhaseGroup
|
||||
title="Validation Tasks"
|
||||
subtitle="Smart models recommended for accuracy"
|
||||
phases={VALIDATION_TASKS}
|
||||
project={project}
|
||||
/>
|
||||
|
||||
{/* Generation Tasks */}
|
||||
<PhaseGroup
|
||||
title="Generation Tasks"
|
||||
subtitle="Powerful models recommended for quality output"
|
||||
phases={GENERATION_TASKS}
|
||||
project={project}
|
||||
/>
|
||||
|
||||
{/* Memory Tasks */}
|
||||
<PhaseGroup
|
||||
title="Memory Tasks"
|
||||
subtitle="Fast models recommended for learning extraction"
|
||||
phases={MEMORY_TASKS}
|
||||
project={project}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +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 { ProjectModelsSection } from './project-models-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';
|
||||
@@ -86,7 +86,7 @@ export function ProjectSettingsView() {
|
||||
case 'worktrees':
|
||||
return <WorktreePreferencesSection project={currentProject} />;
|
||||
case 'claude':
|
||||
return <ProjectClaudeSection project={currentProject} />;
|
||||
return <ProjectModelsSection project={currentProject} />;
|
||||
case 'danger':
|
||||
return (
|
||||
<DangerZoneSection
|
||||
|
||||
@@ -105,7 +105,7 @@ export function ApiKeysSection() {
|
||||
{providerConfigs.map((provider) => (
|
||||
<div key={provider.key}>
|
||||
<ApiKeyField config={provider} />
|
||||
{/* Anthropic-specific profile info */}
|
||||
{/* Anthropic-specific provider 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">
|
||||
@@ -113,20 +113,19 @@ export function ApiKeysSection() {
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>
|
||||
<span className="font-medium text-foreground/80">
|
||||
Using Claude API Profiles?
|
||||
Using Claude Compatible Providers?
|
||||
</span>{' '}
|
||||
Create a profile in{' '}
|
||||
<span className="text-blue-500">AI Providers → Claude</span> with{' '}
|
||||
Add a provider 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{' '}
|
||||
For alternative providers (z.AI GLM, MiniMax, OpenRouter), add a provider 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.
|
||||
key source and enter the provider's API key directly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ArrowRight, Cloud, Server, Check, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
PhaseModelKey,
|
||||
PhaseModelEntry,
|
||||
ClaudeCompatibleProvider,
|
||||
ClaudeModelAlias,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
|
||||
interface BulkReplaceDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
// Phase display names for preview
|
||||
const PHASE_LABELS: Record<PhaseModelKey, string> = {
|
||||
enhancementModel: 'Feature Enhancement',
|
||||
fileDescriptionModel: 'File Descriptions',
|
||||
imageDescriptionModel: 'Image Descriptions',
|
||||
commitMessageModel: 'Commit Messages',
|
||||
validationModel: 'GitHub Issue Validation',
|
||||
specGenerationModel: 'App Specification',
|
||||
featureGenerationModel: 'Feature Generation',
|
||||
backlogPlanningModel: 'Backlog Planning',
|
||||
projectAnalysisModel: 'Project Analysis',
|
||||
suggestionsModel: 'AI Suggestions',
|
||||
memoryExtractionModel: 'Memory Extraction',
|
||||
};
|
||||
|
||||
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
||||
|
||||
// Claude model display names
|
||||
const CLAUDE_MODEL_DISPLAY: Record<ClaudeModelAlias, string> = {
|
||||
haiku: 'Claude Haiku',
|
||||
sonnet: 'Claude Sonnet',
|
||||
opus: 'Claude Opus',
|
||||
};
|
||||
|
||||
export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps) {
|
||||
const { phaseModels, setPhaseModel, claudeCompatibleProviders } = useAppStore();
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>('anthropic');
|
||||
|
||||
// Get enabled providers
|
||||
const enabledProviders = useMemo(() => {
|
||||
return (claudeCompatibleProviders || []).filter((p) => p.enabled !== false);
|
||||
}, [claudeCompatibleProviders]);
|
||||
|
||||
// Build provider options for the dropdown
|
||||
const providerOptions = useMemo(() => {
|
||||
const options: Array<{ id: string; name: string; isNative: boolean }> = [
|
||||
{ id: 'anthropic', name: 'Anthropic Direct', isNative: true },
|
||||
];
|
||||
|
||||
enabledProviders.forEach((provider) => {
|
||||
options.push({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
isNative: false,
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
}, [enabledProviders]);
|
||||
|
||||
// Get the selected provider config (if custom)
|
||||
const selectedProviderConfig = useMemo(() => {
|
||||
if (selectedProvider === 'anthropic') return null;
|
||||
return enabledProviders.find((p) => p.id === selectedProvider);
|
||||
}, [selectedProvider, enabledProviders]);
|
||||
|
||||
// Get the Claude model alias from a PhaseModelEntry
|
||||
const getClaudeModelAlias = (entry: PhaseModelEntry): ClaudeModelAlias => {
|
||||
// Check if model string directly matches a Claude alias
|
||||
if (entry.model === 'haiku' || entry.model === 'claude-haiku') return 'haiku';
|
||||
if (entry.model === 'sonnet' || entry.model === 'claude-sonnet') return 'sonnet';
|
||||
if (entry.model === 'opus' || entry.model === 'claude-opus') return 'opus';
|
||||
|
||||
// If it's a provider model, look up the mapping
|
||||
if (entry.providerId) {
|
||||
const provider = enabledProviders.find((p) => p.id === entry.providerId);
|
||||
if (provider) {
|
||||
const model = provider.models?.find((m) => m.id === entry.model);
|
||||
if (model?.mapsToClaudeModel) {
|
||||
return model.mapsToClaudeModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to sonnet
|
||||
return 'sonnet';
|
||||
};
|
||||
|
||||
// Find the model from provider that maps to a specific Claude model
|
||||
const findModelForClaudeAlias = (
|
||||
provider: ClaudeCompatibleProvider | null,
|
||||
claudeAlias: ClaudeModelAlias,
|
||||
phase: PhaseModelKey
|
||||
): PhaseModelEntry => {
|
||||
if (!provider) {
|
||||
// Anthropic Direct - reset to default phase model (includes correct thinking levels)
|
||||
return DEFAULT_PHASE_MODELS[phase];
|
||||
}
|
||||
|
||||
// Find model that maps to this Claude alias
|
||||
const models = provider.models || [];
|
||||
const match = models.find((m) => m.mapsToClaudeModel === claudeAlias);
|
||||
|
||||
if (match) {
|
||||
return { providerId: provider.id, model: match.id };
|
||||
}
|
||||
|
||||
// Fallback: use first model if no match
|
||||
if (models.length > 0) {
|
||||
return { providerId: provider.id, model: models[0].id };
|
||||
}
|
||||
|
||||
// Ultimate fallback to native Claude model
|
||||
return { model: claudeAlias };
|
||||
};
|
||||
|
||||
// Generate preview of changes
|
||||
const preview = useMemo(() => {
|
||||
return ALL_PHASES.map((phase) => {
|
||||
const currentEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase];
|
||||
const claudeAlias = getClaudeModelAlias(currentEntry);
|
||||
const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, phase);
|
||||
|
||||
// Get display names
|
||||
const getCurrentDisplay = (): string => {
|
||||
if (currentEntry.providerId) {
|
||||
const provider = enabledProviders.find((p) => p.id === currentEntry.providerId);
|
||||
if (provider) {
|
||||
const model = provider.models?.find((m) => m.id === currentEntry.model);
|
||||
return model?.displayName || currentEntry.model;
|
||||
}
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model;
|
||||
};
|
||||
|
||||
const getNewDisplay = (): string => {
|
||||
if (newEntry.providerId && selectedProviderConfig) {
|
||||
const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model);
|
||||
return model?.displayName || newEntry.model;
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model;
|
||||
};
|
||||
|
||||
const isChanged =
|
||||
currentEntry.model !== newEntry.model ||
|
||||
currentEntry.providerId !== newEntry.providerId ||
|
||||
currentEntry.thinkingLevel !== newEntry.thinkingLevel;
|
||||
|
||||
return {
|
||||
phase,
|
||||
label: PHASE_LABELS[phase],
|
||||
claudeAlias,
|
||||
currentDisplay: getCurrentDisplay(),
|
||||
newDisplay: getNewDisplay(),
|
||||
newEntry,
|
||||
isChanged,
|
||||
};
|
||||
});
|
||||
}, [phaseModels, selectedProviderConfig, enabledProviders]);
|
||||
|
||||
// Count how many will change
|
||||
const changeCount = preview.filter((p) => p.isChanged).length;
|
||||
|
||||
// Apply the bulk replace
|
||||
const handleApply = () => {
|
||||
preview.forEach(({ phase, newEntry, isChanged }) => {
|
||||
if (isChanged) {
|
||||
setPhaseModel(phase, newEntry);
|
||||
}
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// Check if provider has all 3 Claude model mappings
|
||||
const providerModelCoverage = useMemo(() => {
|
||||
if (selectedProvider === 'anthropic') {
|
||||
return { hasHaiku: true, hasSonnet: true, hasOpus: true, complete: true };
|
||||
}
|
||||
if (!selectedProviderConfig) {
|
||||
return { hasHaiku: false, hasSonnet: false, hasOpus: false, complete: false };
|
||||
}
|
||||
const models = selectedProviderConfig.models || [];
|
||||
const hasHaiku = models.some((m) => m.mapsToClaudeModel === 'haiku');
|
||||
const hasSonnet = models.some((m) => m.mapsToClaudeModel === 'sonnet');
|
||||
const hasOpus = models.some((m) => m.mapsToClaudeModel === 'opus');
|
||||
return { hasHaiku, hasSonnet, hasOpus, complete: hasHaiku && hasSonnet && hasOpus };
|
||||
}, [selectedProvider, selectedProviderConfig]);
|
||||
|
||||
const providerHasModels =
|
||||
selectedProvider === 'anthropic' ||
|
||||
(selectedProviderConfig && selectedProviderConfig.models?.length > 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bulk Replace Models</DialogTitle>
|
||||
<DialogDescription>
|
||||
Switch all phase models to equivalents from a specific provider. Models are matched by
|
||||
their Claude model mapping (Haiku, Sonnet, Opus).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Provider selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Target Provider</label>
|
||||
<Select value={selectedProvider} onValueChange={setSelectedProvider}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerOptions.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.isNative ? (
|
||||
<Cloud className="w-4 h-4 text-brand-500" />
|
||||
) : (
|
||||
<Server className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<span>{option.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Warning if provider has no models */}
|
||||
{!providerHasModels && (
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-sm">
|
||||
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>This provider has no models configured.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning if provider doesn't have all 3 mappings */}
|
||||
{providerHasModels && !providerModelCoverage.complete && (
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-sm">
|
||||
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>
|
||||
This provider is missing mappings for:{' '}
|
||||
{[
|
||||
!providerModelCoverage.hasHaiku && 'Haiku',
|
||||
!providerModelCoverage.hasSonnet && 'Sonnet',
|
||||
!providerModelCoverage.hasOpus && 'Opus',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview of changes */}
|
||||
{providerHasModels && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Preview Changes</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{changeCount} of {ALL_PHASES.length} will change
|
||||
</span>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left p-2 font-medium text-muted-foreground">Phase</th>
|
||||
<th className="text-left p-2 font-medium text-muted-foreground">Current</th>
|
||||
<th className="p-2"></th>
|
||||
<th className="text-left p-2 font-medium text-muted-foreground">New</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => (
|
||||
<tr
|
||||
key={phase}
|
||||
className={cn(
|
||||
'border-t border-border/50',
|
||||
isChanged ? 'bg-brand-500/5' : 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<td className="p-2 font-medium">{label}</td>
|
||||
<td className="p-2 text-muted-foreground">{currentDisplay}</td>
|
||||
<td className="p-2 text-center">
|
||||
{isChanged ? (
|
||||
<ArrowRight className="w-4 h-4 text-brand-500 inline" />
|
||||
) : (
|
||||
<Check className="w-4 h-4 text-green-500 inline" />
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className={cn(isChanged && 'text-brand-500 font-medium')}>
|
||||
{newDisplay}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleApply} disabled={!providerHasModels || changeCount === 0}>
|
||||
Apply Changes ({changeCount})
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Workflow, RotateCcw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Workflow, RotateCcw, Replace } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PhaseModelSelector } from './phase-model-selector';
|
||||
import { BulkReplaceDialog } from './bulk-replace-dialog';
|
||||
import type { PhaseModelKey } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
|
||||
@@ -112,7 +114,12 @@ function PhaseGroup({
|
||||
}
|
||||
|
||||
export function ModelDefaultsSection() {
|
||||
const { resetPhaseModels } = useAppStore();
|
||||
const { resetPhaseModels, claudeCompatibleProviders } = useAppStore();
|
||||
const [showBulkReplace, setShowBulkReplace] = useState(false);
|
||||
|
||||
// Check if there are any enabled ClaudeCompatibleProviders
|
||||
const hasEnabledProviders =
|
||||
claudeCompatibleProviders && claudeCompatibleProviders.some((p) => p.enabled !== false);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -139,13 +146,29 @@ export function ModelDefaultsSection() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={resetPhaseModels} className="gap-2">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasEnabledProviders && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowBulkReplace(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Replace className="w-3.5 h-3.5" />
|
||||
Bulk Replace
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={resetPhaseModels} className="gap-2">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Replace Dialog */}
|
||||
<BulkReplaceDialog open={showBulkReplace} onOpenChange={setShowBulkReplace} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-8">
|
||||
{/* Quick Tasks */}
|
||||
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
OpencodeModelId,
|
||||
GroupedModel,
|
||||
PhaseModelEntry,
|
||||
ClaudeCompatibleProvider,
|
||||
ProviderModel,
|
||||
ClaudeModelAlias,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
stripProviderPrefix,
|
||||
@@ -33,6 +36,9 @@ import {
|
||||
AnthropicIcon,
|
||||
CursorIcon,
|
||||
OpenAIIcon,
|
||||
OpenRouterIcon,
|
||||
GlmIcon,
|
||||
MiniMaxIcon,
|
||||
getProviderIconForModel,
|
||||
} from '@/components/ui/provider-icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -154,10 +160,12 @@ export function PhaseModelSelector({
|
||||
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
|
||||
const [expandedClaudeModel, setExpandedClaudeModel] = useState<ModelAlias | null>(null);
|
||||
const [expandedCodexModel, setExpandedCodexModel] = useState<CodexModelId | null>(null);
|
||||
const [expandedProviderModel, setExpandedProviderModel] = useState<string | null>(null); // Format: providerId:modelId
|
||||
const commandListRef = useRef<HTMLDivElement>(null);
|
||||
const expandedTriggerRef = useRef<HTMLDivElement>(null);
|
||||
const expandedClaudeTriggerRef = useRef<HTMLDivElement>(null);
|
||||
const expandedCodexTriggerRef = useRef<HTMLDivElement>(null);
|
||||
const expandedProviderTriggerRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
enabledCursorModels,
|
||||
favoriteModels,
|
||||
@@ -170,16 +178,23 @@ export function PhaseModelSelector({
|
||||
opencodeModelsLoading,
|
||||
fetchOpencodeModels,
|
||||
disabledProviders,
|
||||
claudeCompatibleProviders,
|
||||
} = useAppStore();
|
||||
|
||||
// Detect mobile devices to use inline expansion instead of nested popovers
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Extract model and thinking/reasoning levels from value
|
||||
// Extract model, provider, and thinking/reasoning levels from value
|
||||
const selectedModel = value.model;
|
||||
const selectedProviderId = value.providerId;
|
||||
const selectedThinkingLevel = value.thinkingLevel || 'none';
|
||||
const selectedReasoningEffort = value.reasoningEffort || 'none';
|
||||
|
||||
// Get enabled providers and their models
|
||||
const enabledProviders = useMemo(() => {
|
||||
return (claudeCompatibleProviders || []).filter((p) => p.enabled !== false);
|
||||
}, [claudeCompatibleProviders]);
|
||||
|
||||
// Fetch Codex models on mount
|
||||
useEffect(() => {
|
||||
if (codexModels.length === 0 && !codexModelsLoading) {
|
||||
@@ -267,6 +282,29 @@ export function PhaseModelSelector({
|
||||
return () => observer.disconnect();
|
||||
}, [expandedCodexModel]);
|
||||
|
||||
// Close expanded provider model popover when trigger scrolls out of view
|
||||
useEffect(() => {
|
||||
const triggerElement = expandedProviderTriggerRef.current;
|
||||
const listElement = commandListRef.current;
|
||||
if (!triggerElement || !listElement || !expandedProviderModel) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry.isIntersecting) {
|
||||
setExpandedProviderModel(null);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: listElement,
|
||||
threshold: 0.1,
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(triggerElement);
|
||||
return () => observer.disconnect();
|
||||
}, [expandedProviderModel]);
|
||||
|
||||
// Transform dynamic Codex models from store to component format
|
||||
const transformedCodexModels = useMemo(() => {
|
||||
return codexModels.map((model) => ({
|
||||
@@ -337,13 +375,93 @@ export function PhaseModelSelector({
|
||||
};
|
||||
}
|
||||
|
||||
// Check ClaudeCompatibleProvider models (when providerId is set)
|
||||
if (selectedProviderId) {
|
||||
const provider = enabledProviders.find((p) => p.id === selectedProviderId);
|
||||
if (provider) {
|
||||
const providerModel = provider.models?.find((m) => m.id === selectedModel);
|
||||
if (providerModel) {
|
||||
// Count providers of same type to determine if we need provider name suffix
|
||||
const sameTypeCount = enabledProviders.filter(
|
||||
(p) => p.providerType === provider.providerType
|
||||
).length;
|
||||
const suffix = sameTypeCount > 1 ? ` (${provider.name})` : '';
|
||||
// Add thinking level to label if not 'none'
|
||||
const thinkingLabel =
|
||||
selectedThinkingLevel !== 'none'
|
||||
? ` (${THINKING_LEVEL_LABELS[selectedThinkingLevel]} Thinking)`
|
||||
: '';
|
||||
// Get icon based on provider type
|
||||
const getIconForProviderType = () => {
|
||||
switch (provider.providerType) {
|
||||
case 'glm':
|
||||
return GlmIcon;
|
||||
case 'minimax':
|
||||
return MiniMaxIcon;
|
||||
case 'openrouter':
|
||||
return OpenRouterIcon;
|
||||
default:
|
||||
return getProviderIconForModel(providerModel.id) || OpenRouterIcon;
|
||||
}
|
||||
};
|
||||
return {
|
||||
id: selectedModel,
|
||||
label: `${providerModel.displayName}${suffix}${thinkingLabel}`,
|
||||
description: provider.name,
|
||||
provider: 'claude-compatible' as const,
|
||||
icon: getIconForProviderType(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Check ClaudeCompatibleProvider models by model ID only (when providerId is not set)
|
||||
// This handles cases where features store model ID but not providerId
|
||||
for (const provider of enabledProviders) {
|
||||
const providerModel = provider.models?.find((m) => m.id === selectedModel);
|
||||
if (providerModel) {
|
||||
// Count providers of same type to determine if we need provider name suffix
|
||||
const sameTypeCount = enabledProviders.filter(
|
||||
(p) => p.providerType === provider.providerType
|
||||
).length;
|
||||
const suffix = sameTypeCount > 1 ? ` (${provider.name})` : '';
|
||||
// Add thinking level to label if not 'none'
|
||||
const thinkingLabel =
|
||||
selectedThinkingLevel !== 'none'
|
||||
? ` (${THINKING_LEVEL_LABELS[selectedThinkingLevel]} Thinking)`
|
||||
: '';
|
||||
// Get icon based on provider type
|
||||
const getIconForProviderType = () => {
|
||||
switch (provider.providerType) {
|
||||
case 'glm':
|
||||
return GlmIcon;
|
||||
case 'minimax':
|
||||
return MiniMaxIcon;
|
||||
case 'openrouter':
|
||||
return OpenRouterIcon;
|
||||
default:
|
||||
return getProviderIconForModel(providerModel.id) || OpenRouterIcon;
|
||||
}
|
||||
};
|
||||
return {
|
||||
id: selectedModel,
|
||||
label: `${providerModel.displayName}${suffix}${thinkingLabel}`,
|
||||
description: provider.name,
|
||||
provider: 'claude-compatible' as const,
|
||||
icon: getIconForProviderType(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
selectedModel,
|
||||
selectedProviderId,
|
||||
selectedThinkingLevel,
|
||||
availableCursorModels,
|
||||
transformedCodexModels,
|
||||
dynamicOpencodeModels,
|
||||
enabledProviders,
|
||||
]);
|
||||
|
||||
// Compute grouped vs standalone Cursor models
|
||||
@@ -907,6 +1025,245 @@ export function PhaseModelSelector({
|
||||
);
|
||||
};
|
||||
|
||||
// Render ClaudeCompatibleProvider model item with thinking level support
|
||||
const renderProviderModelItem = (
|
||||
provider: ClaudeCompatibleProvider,
|
||||
model: ProviderModel,
|
||||
showProviderSuffix: boolean,
|
||||
allMappedModels: ClaudeModelAlias[] = []
|
||||
) => {
|
||||
const isSelected = selectedModel === model.id && selectedProviderId === provider.id;
|
||||
const expandKey = `${provider.id}:${model.id}`;
|
||||
const isExpanded = expandedProviderModel === expandKey;
|
||||
const currentThinking = isSelected ? selectedThinkingLevel : 'none';
|
||||
const displayName = showProviderSuffix
|
||||
? `${model.displayName} (${provider.name})`
|
||||
: model.displayName;
|
||||
|
||||
// Build description showing all mapped Claude models
|
||||
const modelLabelMap: Record<ClaudeModelAlias, string> = {
|
||||
haiku: 'Haiku',
|
||||
sonnet: 'Sonnet',
|
||||
opus: 'Opus',
|
||||
};
|
||||
// Sort in order: haiku, sonnet, opus for consistent display
|
||||
const sortOrder: ClaudeModelAlias[] = ['haiku', 'sonnet', 'opus'];
|
||||
const sortedMappedModels = [...allMappedModels].sort(
|
||||
(a, b) => sortOrder.indexOf(a) - sortOrder.indexOf(b)
|
||||
);
|
||||
const mappedModelLabel =
|
||||
sortedMappedModels.length > 0
|
||||
? sortedMappedModels.map((m) => modelLabelMap[m]).join(', ')
|
||||
: 'Claude';
|
||||
|
||||
// Get icon based on provider type, falling back to model-based detection
|
||||
const getProviderTypeIcon = () => {
|
||||
switch (provider.providerType) {
|
||||
case 'glm':
|
||||
return GlmIcon;
|
||||
case 'minimax':
|
||||
return MiniMaxIcon;
|
||||
case 'openrouter':
|
||||
return OpenRouterIcon;
|
||||
default:
|
||||
// For generic/unknown providers, use OpenRouter as a generic "cloud API" icon
|
||||
// unless the model ID has a recognizable pattern
|
||||
return getProviderIconForModel(model.id) || OpenRouterIcon;
|
||||
}
|
||||
};
|
||||
const ProviderIcon = getProviderTypeIcon();
|
||||
|
||||
// On mobile, render inline expansion instead of nested popover
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div key={`${provider.id}-${model.id}`}>
|
||||
<CommandItem
|
||||
value={`${provider.name} ${model.displayName}`}
|
||||
onSelect={() => setExpandedProviderModel(isExpanded ? null : expandKey)}
|
||||
className="group flex items-center justify-between py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<ProviderIcon
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col truncate">
|
||||
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{isSelected && currentThinking !== 'none'
|
||||
? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
|
||||
: `Maps to ${mappedModelLabel}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{isSelected && !isExpanded && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
||||
{/* Inline thinking level options on mobile */}
|
||||
{isExpanded && (
|
||||
<div className="pl-6 pr-2 pb-2 space-y-1">
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
Thinking Level
|
||||
</div>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => {
|
||||
onChange({
|
||||
providerId: provider.id,
|
||||
model: model.id,
|
||||
thinkingLevel: level,
|
||||
});
|
||||
setExpandedProviderModel(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
|
||||
'hover:bg-accent cursor-pointer transition-colors',
|
||||
isSelected && currentThinking === level && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium text-xs">{THINKING_LEVEL_LABELS[level]}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{level === 'none' && 'No extended thinking'}
|
||||
{level === 'low' && 'Light reasoning (1k tokens)'}
|
||||
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
|
||||
{level === 'high' && 'Deep reasoning (16k tokens)'}
|
||||
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && currentThinking === level && (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: Use nested popover
|
||||
return (
|
||||
<CommandItem
|
||||
key={`${provider.id}-${model.id}`}
|
||||
value={`${provider.name} ${model.displayName}`}
|
||||
onSelect={() => setExpandedProviderModel(isExpanded ? null : expandKey)}
|
||||
className="p-0 data-[selected=true]:bg-transparent"
|
||||
>
|
||||
<Popover
|
||||
open={isExpanded}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setExpandedProviderModel(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
ref={isExpanded ? expandedProviderTriggerRef : undefined}
|
||||
className={cn(
|
||||
'w-full group flex items-center justify-between py-2 px-2 rounded-sm cursor-pointer',
|
||||
'hover:bg-accent',
|
||||
isExpanded && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<ProviderIcon
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col truncate">
|
||||
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{isSelected && currentThinking !== 'none'
|
||||
? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
|
||||
: `Maps to ${mappedModelLabel}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="w-[220px] p-1"
|
||||
sideOffset={8}
|
||||
collisionPadding={16}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border/50 mb-1">
|
||||
Thinking Level
|
||||
</div>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => {
|
||||
onChange({
|
||||
providerId: provider.id,
|
||||
model: model.id,
|
||||
thinkingLevel: level,
|
||||
});
|
||||
setExpandedProviderModel(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
|
||||
'hover:bg-accent cursor-pointer transition-colors',
|
||||
isSelected && currentThinking === level && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{THINKING_LEVEL_LABELS[level]}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{level === 'none' && 'No extended thinking'}
|
||||
{level === 'low' && 'Light reasoning (1k tokens)'}
|
||||
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
|
||||
{level === 'high' && 'Deep reasoning (16k tokens)'}
|
||||
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && currentThinking === level && (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
// Render Cursor model item (no thinking level needed)
|
||||
const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => {
|
||||
// With canonical IDs, store the full prefixed ID
|
||||
@@ -1499,6 +1856,50 @@ export function PhaseModelSelector({
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* ClaudeCompatibleProvider Models - each provider as separate group */}
|
||||
{enabledProviders.map((provider) => {
|
||||
if (!provider.models || provider.models.length === 0) return null;
|
||||
|
||||
// Check if we need provider suffix (multiple providers of same type)
|
||||
const sameTypeCount = enabledProviders.filter(
|
||||
(p) => p.providerType === provider.providerType
|
||||
).length;
|
||||
const showSuffix = sameTypeCount > 1;
|
||||
|
||||
// Group models by ID and collect all mapped Claude models for each
|
||||
const modelsByIdMap = new Map<
|
||||
string,
|
||||
{ model: ProviderModel; mappedModels: ClaudeModelAlias[] }
|
||||
>();
|
||||
for (const model of provider.models) {
|
||||
const existing = modelsByIdMap.get(model.id);
|
||||
if (existing) {
|
||||
// Add this mapped model if not already present
|
||||
if (
|
||||
model.mapsToClaudeModel &&
|
||||
!existing.mappedModels.includes(model.mapsToClaudeModel)
|
||||
) {
|
||||
existing.mappedModels.push(model.mapsToClaudeModel);
|
||||
}
|
||||
} else {
|
||||
// First occurrence of this model ID
|
||||
modelsByIdMap.set(model.id, {
|
||||
model,
|
||||
mappedModels: model.mapsToClaudeModel ? [model.mapsToClaudeModel] : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
const uniqueModelsWithMappings = Array.from(modelsByIdMap.values());
|
||||
|
||||
return (
|
||||
<CommandGroup key={provider.id} heading={`${provider.name} (via Claude)`}>
|
||||
{uniqueModelsWithMappings.map(({ model, mappedModels }) =>
|
||||
renderProviderModelItem(provider, model, showSuffix, mappedModels)
|
||||
)}
|
||||
</CommandGroup>
|
||||
);
|
||||
})}
|
||||
|
||||
{(groupedModels.length > 0 || standaloneCursorModels.length > 0) && (
|
||||
<CommandGroup heading="Cursor Models">
|
||||
{/* Grouped models with secondary popover */}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function ClaudeSettingsTab() {
|
||||
onRefresh={handleRefreshClaudeCli}
|
||||
/>
|
||||
|
||||
{/* API Profiles for Claude-compatible endpoints */}
|
||||
{/* Claude-compatible providers */}
|
||||
<ApiProfilesSection />
|
||||
|
||||
<ClaudeMdSettings
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -67,9 +67,25 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// No-op fallback for HMR transitions when context temporarily becomes unavailable
|
||||
const hmrFallback: FileBrowserContextValue = {
|
||||
openFileBrowser: async () => {
|
||||
console.warn('[HMR] FileBrowserContext not available, returning null');
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
export function useFileBrowser() {
|
||||
const context = useContext(FileBrowserContext);
|
||||
// During HMR, the context can temporarily be null as modules reload.
|
||||
// Instead of crashing the app, return a safe no-op fallback that will
|
||||
// be replaced once the provider re-mounts.
|
||||
if (!context) {
|
||||
if (import.meta.hot) {
|
||||
// In development with HMR active, gracefully degrade
|
||||
return hmrFallback;
|
||||
}
|
||||
// In production, this indicates a real bug - throw to help debug
|
||||
throw new Error('useFileBrowser must be used within FileBrowserProvider');
|
||||
}
|
||||
return context;
|
||||
|
||||
@@ -39,6 +39,9 @@ export function useUpdateGlobalSettings(options: UpdateGlobalSettingsOptions = {
|
||||
return useMutation({
|
||||
mutationFn: async (settings: Record<string, unknown>) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.settings) {
|
||||
throw new Error('Settings API not available');
|
||||
}
|
||||
// Use updateGlobal for partial updates
|
||||
const result = await api.settings.updateGlobal(settings);
|
||||
if (!result.success) {
|
||||
@@ -66,33 +69,43 @@ export function useUpdateGlobalSettings(options: UpdateGlobalSettingsOptions = {
|
||||
* @param projectPath - Optional path to the project (can also pass via mutation variables)
|
||||
* @returns Mutation for updating project settings
|
||||
*/
|
||||
interface ProjectSettingsWithPath {
|
||||
projectPath: string;
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function useUpdateProjectSettings(projectPath?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
variables:
|
||||
| Record<string, unknown>
|
||||
| { projectPath: string; settings: Record<string, unknown> }
|
||||
) => {
|
||||
mutationFn: async (variables: Record<string, unknown> | ProjectSettingsWithPath) => {
|
||||
// Support both call patterns:
|
||||
// 1. useUpdateProjectSettings(projectPath) then mutate(settings)
|
||||
// 2. useUpdateProjectSettings() then mutate({ projectPath, settings })
|
||||
let path: string;
|
||||
let settings: Record<string, unknown>;
|
||||
|
||||
if ('projectPath' in variables && 'settings' in variables) {
|
||||
if (
|
||||
typeof variables === 'object' &&
|
||||
'projectPath' in variables &&
|
||||
'settings' in variables &&
|
||||
typeof variables.projectPath === 'string' &&
|
||||
typeof variables.settings === 'object'
|
||||
) {
|
||||
path = variables.projectPath;
|
||||
settings = variables.settings;
|
||||
settings = variables.settings as Record<string, unknown>;
|
||||
} else if (projectPath) {
|
||||
path = projectPath;
|
||||
settings = variables;
|
||||
settings = variables as Record<string, unknown>;
|
||||
} else {
|
||||
throw new Error('Project path is required');
|
||||
}
|
||||
|
||||
const api = getElectronAPI();
|
||||
const result = await api.settings.setProject(path, settings);
|
||||
if (!api.settings) {
|
||||
throw new Error('Settings API not available');
|
||||
}
|
||||
const result = await api.settings.updateProject(path, settings);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update project settings');
|
||||
}
|
||||
@@ -122,9 +135,12 @@ export function useSaveCredentials() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (credentials: Record<string, string>) => {
|
||||
mutationFn: async (credentials: { anthropic?: string; google?: string; openai?: string }) => {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.settings.setCredentials(credentials);
|
||||
if (!api.settings) {
|
||||
throw new Error('Settings API not available');
|
||||
}
|
||||
const result = await api.settings.updateCredentials({ apiKeys: credentials });
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to save credentials');
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
getWorktreeKey,
|
||||
getMaxConcurrencyForWorktree,
|
||||
setMaxConcurrencyForWorktree,
|
||||
isPrimaryWorktreeBranch,
|
||||
} = useAppStore(
|
||||
useShallow((state) => ({
|
||||
autoModeByWorktree: state.autoModeByWorktree,
|
||||
@@ -90,6 +91,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
getWorktreeKey: state.getWorktreeKey,
|
||||
getMaxConcurrencyForWorktree: state.getMaxConcurrencyForWorktree,
|
||||
setMaxConcurrencyForWorktree: state.setMaxConcurrencyForWorktree,
|
||||
isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -197,9 +199,21 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
}
|
||||
|
||||
// Extract branchName from event, defaulting to null (main worktree)
|
||||
const eventBranchName: string | null =
|
||||
const rawEventBranchName: string | null =
|
||||
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
|
||||
|
||||
// Get projectPath for worktree lookup
|
||||
const eventProjectPath = 'projectPath' in event ? event.projectPath : currentProject?.path;
|
||||
|
||||
// Normalize branchName: convert primary worktree branch to null for consistent key lookup
|
||||
// This handles cases where the main branch is named something other than 'main' (e.g., 'master', 'develop')
|
||||
const eventBranchName: string | null =
|
||||
eventProjectPath &&
|
||||
rawEventBranchName &&
|
||||
isPrimaryWorktreeBranch(eventProjectPath, rawEventBranchName)
|
||||
? null
|
||||
: rawEventBranchName;
|
||||
|
||||
// Skip event if we couldn't determine the project
|
||||
if (!eventProjectId) {
|
||||
logger.warn('Could not determine project for event:', event);
|
||||
@@ -493,6 +507,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
currentProject?.path,
|
||||
getMaxConcurrencyForWorktree,
|
||||
setMaxConcurrencyForWorktree,
|
||||
isPrimaryWorktreeBranch,
|
||||
]);
|
||||
|
||||
// Start auto mode - calls backend to start the auto loop for this worktree
|
||||
|
||||
@@ -5,6 +5,10 @@ import { useUpdateProjectSettings } from '@/hooks/mutations';
|
||||
/**
|
||||
* Hook for managing board background settings with automatic persistence to server.
|
||||
* Uses React Query mutation for server persistence with automatic error handling.
|
||||
*
|
||||
* For sliders, the modal uses local state during dragging and calls:
|
||||
* - setCardOpacity/setColumnOpacity/setCardBorderOpacity to update store on commit
|
||||
* - persistSettings directly to save to server on commit
|
||||
*/
|
||||
export function useBoardBackgroundSettings() {
|
||||
const store = useAppStore();
|
||||
@@ -65,22 +69,20 @@ export function useBoardBackgroundSettings() {
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
);
|
||||
|
||||
// Update store (called on slider commit to update the board view)
|
||||
const setCardOpacity = useCallback(
|
||||
async (projectPath: string, opacity: number) => {
|
||||
const current = getCurrentSettings(projectPath);
|
||||
(projectPath: string, opacity: number) => {
|
||||
store.setCardOpacity(projectPath, opacity);
|
||||
await persistSettings(projectPath, { ...current, cardOpacity: opacity });
|
||||
},
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
[store]
|
||||
);
|
||||
|
||||
// Update store (called on slider commit to update the board view)
|
||||
const setColumnOpacity = useCallback(
|
||||
async (projectPath: string, opacity: number) => {
|
||||
const current = getCurrentSettings(projectPath);
|
||||
(projectPath: string, opacity: number) => {
|
||||
store.setColumnOpacity(projectPath, opacity);
|
||||
await persistSettings(projectPath, { ...current, columnOpacity: opacity });
|
||||
},
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
[store]
|
||||
);
|
||||
|
||||
const setColumnBorderEnabled = useCallback(
|
||||
@@ -119,16 +121,12 @@ export function useBoardBackgroundSettings() {
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
);
|
||||
|
||||
// Update store (called on slider commit to update the board view)
|
||||
const setCardBorderOpacity = useCallback(
|
||||
async (projectPath: string, opacity: number) => {
|
||||
const current = getCurrentSettings(projectPath);
|
||||
(projectPath: string, opacity: number) => {
|
||||
store.setCardBorderOpacity(projectPath, opacity);
|
||||
await persistSettings(projectPath, {
|
||||
...current,
|
||||
cardBorderOpacity: opacity,
|
||||
});
|
||||
},
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
[store]
|
||||
);
|
||||
|
||||
const setHideScrollbar = useCallback(
|
||||
@@ -170,5 +168,6 @@ export function useBoardBackgroundSettings() {
|
||||
setHideScrollbar,
|
||||
clearBoardBackground,
|
||||
getCurrentSettings,
|
||||
persistSettings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,18 +95,45 @@ export function useProjectSettingsLoader() {
|
||||
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({
|
||||
// Apply activeClaudeApiProfileId and phaseModelOverrides if present
|
||||
// These are stored directly on the project, so we need to update both
|
||||
// currentProject AND the projects array to keep them in sync
|
||||
// Type assertion needed because API returns Record<string, unknown>
|
||||
const settingsWithExtras = settings as Record<string, unknown>;
|
||||
const activeClaudeApiProfileId = settingsWithExtras.activeClaudeApiProfileId as
|
||||
| string
|
||||
| null
|
||||
| undefined;
|
||||
const phaseModelOverrides = settingsWithExtras.phaseModelOverrides as
|
||||
| import('@automaker/types').PhaseModelConfig
|
||||
| undefined;
|
||||
|
||||
// Check if we need to update the project
|
||||
const storeState = useAppStore.getState();
|
||||
const updatedProject = storeState.currentProject;
|
||||
if (updatedProject && updatedProject.path === projectPath) {
|
||||
const needsUpdate =
|
||||
(activeClaudeApiProfileId !== undefined &&
|
||||
updatedProject.activeClaudeApiProfileId !== activeClaudeApiProfileId) ||
|
||||
(phaseModelOverrides !== undefined &&
|
||||
JSON.stringify(updatedProject.phaseModelOverrides) !==
|
||||
JSON.stringify(phaseModelOverrides));
|
||||
|
||||
if (needsUpdate) {
|
||||
const updatedProjectData = {
|
||||
...updatedProject,
|
||||
activeClaudeApiProfileId: settings.activeClaudeApiProfileId,
|
||||
});
|
||||
...(activeClaudeApiProfileId !== undefined && { activeClaudeApiProfileId }),
|
||||
...(phaseModelOverrides !== undefined && { phaseModelOverrides }),
|
||||
};
|
||||
|
||||
// Update currentProject
|
||||
setCurrentProject(updatedProjectData);
|
||||
|
||||
// Also update the project in the projects array to keep them in sync
|
||||
const updatedProjects = storeState.projects.map((p) =>
|
||||
p.id === updatedProject.id ? updatedProjectData : p
|
||||
);
|
||||
useAppStore.setState({ projects: updatedProjects });
|
||||
}
|
||||
}
|
||||
}, [
|
||||
|
||||
@@ -208,12 +208,13 @@ 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
|
||||
// Claude API Profiles (legacy)
|
||||
claudeApiProfiles: (state.claudeApiProfiles as GlobalSettings['claudeApiProfiles']) ?? [],
|
||||
activeClaudeApiProfileId:
|
||||
(state.activeClaudeApiProfileId as GlobalSettings['activeClaudeApiProfileId']) ?? null,
|
||||
// Event hooks
|
||||
eventHooks: state.eventHooks as GlobalSettings['eventHooks'],
|
||||
// Claude Compatible Providers (new system)
|
||||
claudeCompatibleProviders:
|
||||
(state.claudeCompatibleProviders as GlobalSettings['claudeCompatibleProviders']) ?? [],
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse localStorage settings:', error);
|
||||
@@ -348,6 +349,16 @@ export function mergeSettings(
|
||||
merged.activeClaudeApiProfileId = localSettings.activeClaudeApiProfileId;
|
||||
}
|
||||
|
||||
// Claude Compatible Providers - preserve from localStorage if server is empty
|
||||
if (
|
||||
(!serverSettings.claudeCompatibleProviders ||
|
||||
serverSettings.claudeCompatibleProviders.length === 0) &&
|
||||
localSettings.claudeCompatibleProviders &&
|
||||
localSettings.claudeCompatibleProviders.length > 0
|
||||
) {
|
||||
merged.claudeCompatibleProviders = localSettings.claudeCompatibleProviders;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -720,6 +731,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
mcpServers: settings.mcpServers ?? [],
|
||||
promptCustomization: settings.promptCustomization ?? {},
|
||||
eventHooks: settings.eventHooks ?? [],
|
||||
claudeCompatibleProviders: settings.claudeCompatibleProviders ?? [],
|
||||
claudeApiProfiles: settings.claudeApiProfiles ?? [],
|
||||
activeClaudeApiProfileId: settings.activeClaudeApiProfileId ?? null,
|
||||
projects,
|
||||
@@ -798,6 +810,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
mcpServers: state.mcpServers,
|
||||
promptCustomization: state.promptCustomization,
|
||||
eventHooks: state.eventHooks,
|
||||
claudeCompatibleProviders: state.claudeCompatibleProviders,
|
||||
claudeApiProfiles: state.claudeApiProfiles,
|
||||
activeClaudeApiProfileId: state.activeClaudeApiProfileId,
|
||||
projects: state.projects,
|
||||
|
||||
@@ -3403,8 +3403,15 @@ export interface Project {
|
||||
* - undefined: Use global setting (activeClaudeApiProfileId)
|
||||
* - null: Explicitly use Direct Anthropic API (no profile)
|
||||
* - string: Use specific profile by ID
|
||||
* @deprecated Use phaseModelOverrides instead for per-phase model selection
|
||||
*/
|
||||
activeClaudeApiProfileId?: string | null;
|
||||
/**
|
||||
* Per-phase model overrides for this project.
|
||||
* Keys are phase names (e.g., 'enhancementModel'), values are PhaseModelEntry.
|
||||
* If a phase is not present, the global setting is used.
|
||||
*/
|
||||
phaseModelOverrides?: Partial<import('@automaker/types').PhaseModelConfig>;
|
||||
}
|
||||
|
||||
export interface TrashedProject extends Project {
|
||||
|
||||
@@ -895,12 +895,15 @@ function RootLayoutContent() {
|
||||
}
|
||||
|
||||
function RootLayout() {
|
||||
// Hide devtools on compact screens (mobile/tablet) to avoid overlap with sidebar settings
|
||||
const isCompact = useIsCompact();
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<FileBrowserProvider>
|
||||
<RootLayoutContent />
|
||||
</FileBrowserProvider>
|
||||
{SHOW_QUERY_DEVTOOLS ? (
|
||||
{SHOW_QUERY_DEVTOOLS && !isCompact ? (
|
||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
|
||||
) : null}
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
ServerLogLevel,
|
||||
EventHook,
|
||||
ClaudeApiProfile,
|
||||
ClaudeCompatibleProvider,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
getAllCursorModelIds,
|
||||
@@ -752,7 +753,10 @@ export interface AppState {
|
||||
// Event Hooks
|
||||
eventHooks: EventHook[]; // Event hooks for custom commands or webhooks
|
||||
|
||||
// Claude API Profiles
|
||||
// Claude-Compatible Providers (new system)
|
||||
claudeCompatibleProviders: ClaudeCompatibleProvider[]; // Providers that expose models to dropdowns
|
||||
|
||||
// Claude API Profiles (deprecated - kept for backward compatibility)
|
||||
claudeApiProfiles: ClaudeApiProfile[]; // Claude-compatible API endpoint profiles
|
||||
activeClaudeApiProfileId: string | null; // Active profile ID (null = use direct Anthropic API)
|
||||
|
||||
@@ -1040,8 +1044,17 @@ export interface AppActions {
|
||||
getEffectiveFontMono: () => string | null; // Get effective code font (project override -> global -> null for default)
|
||||
|
||||
// Claude API Profile actions (per-project override)
|
||||
/** @deprecated Use setProjectPhaseModelOverride instead */
|
||||
setProjectClaudeApiProfile: (projectId: string, profileId: string | null | undefined) => void; // Set per-project Claude API profile (undefined = use global, null = direct API, string = specific profile)
|
||||
|
||||
// Project Phase Model Overrides
|
||||
setProjectPhaseModelOverride: (
|
||||
projectId: string,
|
||||
phase: import('@automaker/types').PhaseModelKey,
|
||||
entry: import('@automaker/types').PhaseModelEntry | null // null = use global
|
||||
) => void;
|
||||
clearAllProjectPhaseModelOverrides: (projectId: string) => void;
|
||||
|
||||
// Feature actions
|
||||
setFeatures: (features: Feature[]) => void;
|
||||
updateFeature: (id: string, updates: Partial<Feature>) => void;
|
||||
@@ -1211,7 +1224,17 @@ export interface AppActions {
|
||||
// Event Hook actions
|
||||
setEventHooks: (hooks: EventHook[]) => void;
|
||||
|
||||
// Claude API Profile actions
|
||||
// Claude-Compatible Provider actions (new system)
|
||||
addClaudeCompatibleProvider: (provider: ClaudeCompatibleProvider) => Promise<void>;
|
||||
updateClaudeCompatibleProvider: (
|
||||
id: string,
|
||||
updates: Partial<ClaudeCompatibleProvider>
|
||||
) => Promise<void>;
|
||||
deleteClaudeCompatibleProvider: (id: string) => Promise<void>;
|
||||
setClaudeCompatibleProviders: (providers: ClaudeCompatibleProvider[]) => Promise<void>;
|
||||
toggleClaudeCompatibleProviderEnabled: (id: string) => Promise<void>;
|
||||
|
||||
// Claude API Profile actions (deprecated - kept for backward compatibility)
|
||||
addClaudeApiProfile: (profile: ClaudeApiProfile) => Promise<void>;
|
||||
updateClaudeApiProfile: (id: string, updates: Partial<ClaudeApiProfile>) => Promise<void>;
|
||||
deleteClaudeApiProfile: (id: string) => Promise<void>;
|
||||
@@ -1476,8 +1499,9 @@ 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
|
||||
claudeCompatibleProviders: [], // Claude-compatible providers that expose models
|
||||
claudeApiProfiles: [], // No Claude API profiles configured by default (deprecated)
|
||||
activeClaudeApiProfileId: null, // Use direct Anthropic API by default (deprecated)
|
||||
projectAnalysis: null,
|
||||
isAnalyzing: false,
|
||||
boardBackgroundByProject: {},
|
||||
@@ -2017,6 +2041,98 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
// Project Phase Model Override actions
|
||||
setProjectPhaseModelOverride: (projectId, phase, entry) => {
|
||||
// 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 phase model override: project not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current overrides or start fresh
|
||||
const currentOverrides = project.phaseModelOverrides || {};
|
||||
|
||||
// Build new overrides
|
||||
let newOverrides: typeof currentOverrides;
|
||||
if (entry === null) {
|
||||
// Remove the override (use global)
|
||||
const { [phase]: _, ...rest } = currentOverrides;
|
||||
newOverrides = rest;
|
||||
} else {
|
||||
// Set the override
|
||||
newOverrides = { ...currentOverrides, [phase]: entry };
|
||||
}
|
||||
|
||||
// Update the project's phaseModelOverrides
|
||||
const projects = get().projects.map((p) =>
|
||||
p.id === projectId
|
||||
? {
|
||||
...p,
|
||||
phaseModelOverrides: Object.keys(newOverrides).length > 0 ? newOverrides : undefined,
|
||||
}
|
||||
: p
|
||||
);
|
||||
set({ projects });
|
||||
|
||||
// Also update currentProject if it's the same project
|
||||
const currentProject = get().currentProject;
|
||||
if (currentProject?.id === projectId) {
|
||||
set({
|
||||
currentProject: {
|
||||
...currentProject,
|
||||
phaseModelOverrides: Object.keys(newOverrides).length > 0 ? newOverrides : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Persist to server
|
||||
const httpClient = getHttpApiClient();
|
||||
httpClient.settings
|
||||
.updateProject(project.path, {
|
||||
phaseModelOverrides: Object.keys(newOverrides).length > 0 ? newOverrides : '__CLEAR__',
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to persist phaseModelOverrides:', error);
|
||||
});
|
||||
},
|
||||
|
||||
clearAllProjectPhaseModelOverrides: (projectId) => {
|
||||
// Find the project to get its path for server sync
|
||||
const project = get().projects.find((p) => p.id === projectId);
|
||||
if (!project) {
|
||||
console.error('Cannot clear phase model overrides: project not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear overrides from project
|
||||
const projects = get().projects.map((p) =>
|
||||
p.id === projectId ? { ...p, phaseModelOverrides: undefined } : p
|
||||
);
|
||||
set({ projects });
|
||||
|
||||
// Also update currentProject if it's the same project
|
||||
const currentProject = get().currentProject;
|
||||
if (currentProject?.id === projectId) {
|
||||
set({
|
||||
currentProject: {
|
||||
...currentProject,
|
||||
phaseModelOverrides: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Persist to server
|
||||
const httpClient = getHttpApiClient();
|
||||
httpClient.settings
|
||||
.updateProject(project.path, {
|
||||
phaseModelOverrides: '__CLEAR__',
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to clear phaseModelOverrides:', error);
|
||||
});
|
||||
},
|
||||
|
||||
// Feature actions
|
||||
setFeatures: (features) => set({ features }),
|
||||
|
||||
@@ -2601,7 +2717,53 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Event Hook actions
|
||||
setEventHooks: (hooks) => set({ eventHooks: hooks }),
|
||||
|
||||
// Claude API Profile actions
|
||||
// Claude-Compatible Provider actions (new system)
|
||||
addClaudeCompatibleProvider: async (provider) => {
|
||||
set({ claudeCompatibleProviders: [...get().claudeCompatibleProviders, provider] });
|
||||
// Sync immediately to persist provider
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
|
||||
updateClaudeCompatibleProvider: async (id, updates) => {
|
||||
set({
|
||||
claudeCompatibleProviders: get().claudeCompatibleProviders.map((p) =>
|
||||
p.id === id ? { ...p, ...updates } : p
|
||||
),
|
||||
});
|
||||
// Sync immediately to persist changes
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
|
||||
deleteClaudeCompatibleProvider: async (id) => {
|
||||
set({
|
||||
claudeCompatibleProviders: get().claudeCompatibleProviders.filter((p) => p.id !== id),
|
||||
});
|
||||
// Sync immediately to persist deletion
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
|
||||
setClaudeCompatibleProviders: async (providers) => {
|
||||
set({ claudeCompatibleProviders: providers });
|
||||
// Sync immediately to persist providers
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
|
||||
toggleClaudeCompatibleProviderEnabled: async (id) => {
|
||||
set({
|
||||
claudeCompatibleProviders: get().claudeCompatibleProviders.map((p) =>
|
||||
p.id === id ? { ...p, enabled: p.enabled === false ? true : false } : p
|
||||
),
|
||||
});
|
||||
// Sync immediately to persist change
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
|
||||
// Claude API Profile actions (deprecated - kept for backward compatibility)
|
||||
addClaudeApiProfile: async (profile) => {
|
||||
set({ claudeApiProfiles: [...get().claudeApiProfiles, profile] });
|
||||
// Sync immediately to persist profile
|
||||
|
||||
Reference in New Issue
Block a user