Merge remote-tracking branch 'origin/v0.10.0rc' into stefandevo/main

This commit is contained in:
Kacper
2026-01-11 17:34:19 +01:00
156 changed files with 8389 additions and 5916 deletions

View File

@@ -17,7 +17,6 @@ import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
import {
CollapseToggleButton,
SidebarHeader,
ProjectActions,
SidebarNavigation,
ProjectSelectorWithOptions,
SidebarFooter,
@@ -59,7 +58,7 @@ export function Sidebar() {
} = useAppStore();
// Environment variable flags for hiding sidebar items
const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor, hideAiProfiles } =
const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor } =
SIDEBAR_FEATURE_FLAGS;
// Get customizable keyboard shortcuts
@@ -127,6 +126,9 @@ export function Sidebar() {
// Derive isCreatingSpec from store state
const isCreatingSpec = specCreatingForProject !== null;
const creatingSpecProjectPath = specCreatingForProject;
// Check if the current project is specifically the one generating spec
const isCurrentProjectGeneratingSpec =
specCreatingForProject !== null && specCreatingForProject === currentProject?.path;
// Auto-collapse sidebar on small screens and update Electron window minWidth
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
@@ -232,7 +234,6 @@ export function Sidebar() {
hideSpecEditor,
hideContext,
hideTerminal,
hideAiProfiles,
currentProject,
projects,
projectHistory,
@@ -243,6 +244,7 @@ export function Sidebar() {
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
isSpecGenerating: isCurrentProjectGeneratingSpec,
});
// Register keyboard shortcuts
@@ -277,17 +279,6 @@ export function Sidebar() {
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
{/* Project Actions - Moved above project selector */}
{sidebarOpen && (
<ProjectActions
setShowNewProjectModal={setShowNewProjectModal}
handleOpenFolder={handleOpenFolder}
setShowTrashDialog={setShowTrashDialog}
trashedProjects={trashedProjects}
shortcuts={{ openProject: shortcuts.openProject }}
/>
)}
<ProjectSelectorWithOptions
sidebarOpen={sidebarOpen}
isProjectPickerOpen={isProjectPickerOpen}

View File

@@ -32,7 +32,7 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
'flex items-center gap-3 titlebar-no-drag cursor-pointer group',
!sidebarOpen && 'flex-col gap-1'
)}
onClick={() => navigate({ to: '/' })}
onClick={() => navigate({ to: '/dashboard' })}
data-testid="logo-button"
>
{/* Collapsed logo - shown when sidebar is closed OR on small screens when sidebar is open */}

View File

@@ -1,4 +1,5 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import type { NavSection } from '../types';
@@ -80,14 +81,23 @@ export function SidebarNavigation({
data-testid={`nav-${item.id}`}
>
<div className="relative">
<Icon
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActive
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
{item.isLoading ? (
<Loader2
className={cn(
'w-[18px] h-[18px] shrink-0 animate-spin',
isActive ? 'text-brand-500' : 'text-muted-foreground'
)}
/>
) : (
<Icon
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActive
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
)}
{/* Count badge for collapsed state */}
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
<span

View File

@@ -20,5 +20,4 @@ export const SIDEBAR_FEATURE_FLAGS = {
hideRunningAgents: import.meta.env.VITE_HIDE_RUNNING_AGENTS === 'true',
hideContext: import.meta.env.VITE_HIDE_CONTEXT === 'true',
hideSpecEditor: import.meta.env.VITE_HIDE_SPEC_EDITOR === 'true',
hideAiProfiles: import.meta.env.VITE_HIDE_AI_PROFILES === 'true',
} as const;

View File

@@ -1,3 +1,4 @@
import { useRef } from 'react';
import { Rocket, CheckCircle2, Zap, FileText, Sparkles, ArrowRight } from 'lucide-react';
import {
Dialog,
@@ -24,13 +25,25 @@ export function OnboardingDialog({
onSkip,
onGenerateSpec,
}: OnboardingDialogProps) {
// Track if we're closing because user clicked "Generate App Spec"
// to avoid incorrectly calling onSkip
const isGeneratingRef = useRef(false);
const handleGenerateSpec = () => {
isGeneratingRef.current = true;
onGenerateSpec();
};
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
if (!isOpen && !isGeneratingRef.current) {
// Only call onSkip when user dismisses dialog (escape, click outside, or skip button)
// NOT when they click "Generate App Spec"
onSkip();
}
isGeneratingRef.current = false;
onOpenChange(isOpen);
}}
>
@@ -108,7 +121,7 @@ export function OnboardingDialog({
Skip for now
</Button>
<Button
onClick={onGenerateSpec}
onClick={handleGenerateSpec}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
>
<Sparkles className="w-4 h-4 mr-2" />

View File

@@ -5,12 +5,12 @@ import {
LayoutGrid,
Bot,
BookOpen,
UserCircle,
Terminal,
CircleDot,
GitPullRequest,
Zap,
Lightbulb,
Brain,
Network,
} from 'lucide-react';
import type { NavSection, NavItem } from '../types';
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
@@ -26,8 +26,9 @@ interface UseNavigationProps {
cycleNextProject: string;
spec: string;
context: string;
profiles: string;
memory: string;
board: string;
graph: string;
agent: string;
terminal: string;
settings: string;
@@ -38,7 +39,6 @@ interface UseNavigationProps {
hideSpecEditor: boolean;
hideContext: boolean;
hideTerminal: boolean;
hideAiProfiles: boolean;
currentProject: Project | null;
projects: Project[];
projectHistory: string[];
@@ -50,6 +50,8 @@ interface UseNavigationProps {
cycleNextProject: () => void;
/** Count of unviewed validations to show on GitHub Issues nav item */
unviewedValidationsCount?: number;
/** Whether spec generation is currently running for the current project */
isSpecGenerating?: boolean;
}
export function useNavigation({
@@ -57,7 +59,6 @@ export function useNavigation({
hideSpecEditor,
hideContext,
hideTerminal,
hideAiProfiles,
currentProject,
projects,
projectHistory,
@@ -68,6 +69,7 @@ export function useNavigation({
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
isSpecGenerating,
}: UseNavigationProps) {
// Track if current project has a GitHub remote
const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
@@ -107,6 +109,7 @@ export function useNavigation({
label: 'Spec Editor',
icon: FileText,
shortcut: shortcuts.spec,
isLoading: isSpecGenerating,
},
{
id: 'context',
@@ -115,10 +118,10 @@ export function useNavigation({
shortcut: shortcuts.context,
},
{
id: 'profiles',
label: 'AI Profiles',
icon: UserCircle,
shortcut: shortcuts.profiles,
id: 'memory',
label: 'Memory',
icon: Brain,
shortcut: shortcuts.memory,
},
];
@@ -130,9 +133,6 @@ export function useNavigation({
if (item.id === 'context' && hideContext) {
return false;
}
if (item.id === 'profiles' && hideAiProfiles) {
return false;
}
return true;
});
@@ -144,6 +144,12 @@ export function useNavigation({
icon: LayoutGrid,
shortcut: shortcuts.board,
},
{
id: 'graph',
label: 'Graph View',
icon: Network,
shortcut: shortcuts.graph,
},
{
id: 'agent',
label: 'Agent Runner',
@@ -201,9 +207,9 @@ export function useNavigation({
hideSpecEditor,
hideContext,
hideTerminal,
hideAiProfiles,
hasGitHubRemote,
unviewedValidationsCount,
isSpecGenerating,
]);
// Build keyboard shortcuts for navigation

View File

@@ -13,6 +13,8 @@ export interface NavItem {
shortcut?: string;
/** Optional count badge to display next to the nav item */
count?: number;
/** Whether this nav item is in a loading state (shows spinner) */
isLoading?: boolean;
}
export interface SortableProjectItemProps {

View File

@@ -84,11 +84,12 @@ const KEYBOARD_ROWS = [
// Map shortcut names to human-readable labels
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
board: 'Kanban Board',
graph: 'Graph View',
agent: 'Agent Runner',
spec: 'Spec Editor',
context: 'Context',
memory: 'Memory',
settings: 'Settings',
profiles: 'AI Profiles',
terminal: 'Terminal',
ideation: 'Ideation',
githubIssues: 'GitHub Issues',
@@ -102,7 +103,6 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
projectPicker: 'Project Picker',
cyclePrevProject: 'Prev Project',
cycleNextProject: 'Next Project',
addProfile: 'Add Profile',
splitTerminalRight: 'Split Right',
splitTerminalDown: 'Split Down',
closeTerminal: 'Close Terminal',
@@ -112,11 +112,12 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
// Categorize shortcuts for color coding
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' | 'action'> = {
board: 'navigation',
graph: 'navigation',
agent: 'navigation',
spec: 'navigation',
context: 'navigation',
memory: 'navigation',
settings: 'navigation',
profiles: 'navigation',
terminal: 'navigation',
ideation: 'navigation',
githubIssues: 'navigation',
@@ -130,7 +131,6 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' |
projectPicker: 'action',
cyclePrevProject: 'action',
cycleNextProject: 'action',
addProfile: 'action',
splitTerminalRight: 'action',
splitTerminalDown: 'action',
closeTerminal: 'action',

View File

@@ -1,5 +1,4 @@
import type { ComponentType, SVGProps } from 'react';
import { Cpu } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { AgentModel, ModelProvider } from '@automaker/types';
import { getProviderFromModel } from '@/lib/utils';
@@ -10,6 +9,15 @@ const PROVIDER_ICON_KEYS = {
cursor: 'cursor',
gemini: 'gemini',
grok: 'grok',
opencode: 'opencode',
deepseek: 'deepseek',
qwen: 'qwen',
nova: 'nova',
meta: 'meta',
mistral: 'mistral',
minimax: 'minimax',
glm: 'glm',
bigpickle: 'bigpickle',
} as const;
type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
@@ -17,6 +25,8 @@ type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
interface ProviderIconDefinition {
viewBox: string;
path: string;
fillRule?: 'nonzero' | 'evenodd';
fill?: string;
}
const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition> = {
@@ -24,15 +34,18 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
viewBox: '0 0 248 248',
// Official Claude logo from claude.ai favicon
path: 'M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z',
fill: '#d97757',
},
openai: {
viewBox: '0 0 158.7128 157.296',
path: 'M60.8734,57.2556v-14.9432c0-1.2586.4722-2.2029,1.5728-2.8314l30.0443-17.3023c4.0899-2.3593,8.9662-3.4599,13.9988-3.4599,18.8759,0,30.8307,14.6289,30.8307,30.2006,0,1.1007,0,2.3593-.158,3.6178l-31.1446-18.2467c-1.8872-1.1006-3.7754-1.1006-5.6629,0l-39.4812,22.9651ZM131.0276,115.4561v-35.7074c0-2.2028-.9446-3.7756-2.8318-4.8763l-39.481-22.9651,12.8982-7.3934c1.1007-.6285,2.0453-.6285,3.1458,0l30.0441,17.3024c8.6523,5.0341,14.4708,15.7296,14.4708,26.1107,0,11.9539-7.0769,22.965-18.2461,27.527v.0021ZM51.593,83.9964l-12.8982-7.5497c-1.1007-.6285-1.5728-1.5728-1.5728-2.8314v-34.6048c0-16.8303,12.8982-29.5722,30.3585-29.5722,6.607,0,12.7403,2.2029,17.9324,6.1349l-30.987,17.9324c-1.8871,1.1007-2.8314,2.6735-2.8314,4.8764v45.6159l-.0014-.0015ZM79.3562,100.0403l-18.4829-10.3811v-22.0209l18.4829-10.3811,18.4812,10.3811v22.0209l-18.4812,10.3811ZM91.2319,147.8591c-6.607,0-12.7403-2.2031-17.9324-6.1344l30.9866-17.9333c1.8872-1.1005,2.8318-2.6728,2.8318-4.8759v-45.616l13.0564,7.5498c1.1005.6285,1.5723,1.5728,1.5723,2.8314v34.6051c0,16.8297-13.0564,29.5723-30.5147,29.5723v.001ZM53.9522,112.7822l-30.0443-17.3024c-8.652-5.0343-14.471-15.7296-14.471-26.1107,0-12.1119,7.2356-22.9652,18.403-27.5272v35.8634c0,2.2028.9443,3.7756,2.8314,4.8763l39.3248,22.8068-12.8982,7.3938c-1.1007.6287-2.045.6287-3.1456,0ZM52.2229,138.5791c-17.7745,0-30.8306-13.3713-30.8306-29.8871,0-1.2585.1578-2.5169.3143-3.7754l30.987,17.9323c1.8871,1.1005,3.7757,1.1005,5.6628,0l39.4811-22.807v14.9435c0,1.2585-.4721,2.2021-1.5728,2.8308l-30.0443,17.3025c-4.0898,2.359-8.9662,3.4605-13.9989,3.4605h.0014ZM91.2319,157.296c19.0327,0,34.9188-13.5272,38.5383-31.4594,17.6164-4.562,28.9425-21.0779,28.9425-37.908,0-11.0112-4.719-21.7066-13.2133-29.4143.7867-3.3035,1.2595-6.607,1.2595-9.909,0-22.4929-18.2471-39.3247-39.3251-39.3247-4.2461,0-8.3363.6285-12.4262,2.045-7.0792-6.9213-16.8318-11.3254-27.5271-11.3254-19.0331,0-34.9191,13.5268-38.5384,31.4591C11.3255,36.0212,0,52.5373,0,69.3675c0,11.0112,4.7184,21.7065,13.2125,29.4142-.7865,3.3035-1.2586,6.6067-1.2586,9.9092,0,22.4923,18.2466,39.3241,39.3248,39.3241,4.2462,0,8.3362-.6277,12.426-2.0441,7.0776,6.921,16.8302,11.3251,27.5271,11.3251Z',
fill: '#74aa9c',
},
cursor: {
viewBox: '0 0 512 512',
// Official Cursor logo - hexagonal shape with triangular wedge
path: 'M415.035 156.35l-151.503-87.4695c-4.865-2.8094-10.868-2.8094-15.733 0l-151.4969 87.4695c-4.0897 2.362-6.6146 6.729-6.6146 11.459v176.383c0 4.73 2.5249 9.097 6.6146 11.458l151.5039 87.47c4.865 2.809 10.868 2.809 15.733 0l151.504-87.47c4.089-2.361 6.614-6.728 6.614-11.458v-176.383c0-4.73-2.525-9.097-6.614-11.459zm-9.516 18.528l-146.255 253.32c-.988 1.707-3.599 1.01-3.599-.967v-165.872c0-3.314-1.771-6.379-4.644-8.044l-143.645-82.932c-1.707-.988-1.01-3.599.968-3.599h292.509c4.154 0 6.75 4.503 4.673 8.101h-.007z',
fill: '#5E9EFF',
},
gemini: {
viewBox: '0 0 192 192',
@@ -44,6 +57,55 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
// Official Grok/xAI logo - stylized symbol from grok.com
path: 'M213.235 306.019l178.976-180.002v.169l51.695-51.763c-.924 1.32-1.86 2.605-2.785 3.89-39.281 54.164-58.46 80.649-43.07 146.922l-.09-.101c10.61 45.11-.744 95.137-37.398 131.836-46.216 46.306-120.167 56.611-181.063 14.928l42.462-19.675c38.863 15.278 81.392 8.57 111.947-22.03 30.566-30.6 37.432-75.159 22.065-112.252-2.92-7.025-11.67-8.795-17.792-4.263l-124.947 92.341zm-25.786 22.437l-.033.034L68.094 435.217c7.565-10.429 16.957-20.294 26.327-30.149 26.428-27.803 52.653-55.359 36.654-94.302-21.422-52.112-8.952-113.177 30.724-152.898 41.243-41.254 101.98-51.661 152.706-30.758 11.23 4.172 21.016 10.114 28.638 15.639l-42.359 19.584c-39.44-16.563-84.629-5.299-112.207 22.313-37.298 37.308-44.84 102.003-1.128 143.81z',
},
opencode: {
viewBox: '0 0 512 512',
// Official OpenCode favicon - geometric icon from opencode.ai
path: 'M384 416H128V96H384V416ZM320 160H192V352H320V160Z',
fillRule: 'evenodd',
fill: '#6366F1',
},
deepseek: {
viewBox: '0 0 24 24',
// Official DeepSeek logo - whale icon from lobehub/lobe-icons
path: 'M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z',
},
qwen: {
viewBox: '0 0 24 24',
// Official Qwen logo - geometric star from lobehub/lobe-icons
path: 'M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z',
},
nova: {
viewBox: '0 0 33 32',
// Official Amazon Nova logo from lobehub/lobe-icons
path: 'm17.865 23.28 1.533 1.543c.07.07.092.175.055.267l-2.398 6.118A1.24 1.24 0 0 1 15.9 32c-.51 0-.969-.315-1.155-.793l-3.451-8.804-5.582 5.617a.246.246 0 0 1-.35 0l-1.407-1.415a.25.25 0 0 1 0-.352l6.89-6.932a1.3 1.3 0 0 1 .834-.398 1.25 1.25 0 0 1 1.232.79l2.992 7.63 1.557-3.977a.248.248 0 0 1 .408-.085zm8.224-19.3-5.583 5.617-3.45-8.805a1.24 1.24 0 0 0-1.43-.762c-.414.092-.744.407-.899.805l-2.38 6.072a.25.25 0 0 0 .055.267l1.533 1.543c.127.127.34.082.407-.085L15.9 4.655l2.991 7.629a1.24 1.24 0 0 0 2.035.425l6.922-6.965a.25.25 0 0 0 0-.352L26.44 3.977a.246.246 0 0 0-.35 0zM8.578 17.566l-3.953-1.567 7.582-3.01c.49-.195.815-.685.785-1.24a1.3 1.3 0 0 0-.395-.84l-6.886-6.93a.246.246 0 0 0-.35 0L3.954 5.395a.25.25 0 0 0 0 .353l5.583 5.617-8.75 3.472a1.25 1.25 0 0 0 0 2.325l6.079 2.412a.24.24 0 0 0 .266-.055l1.533-1.542a.25.25 0 0 0-.085-.41zm22.434-2.73-6.08-2.412a.24.24 0 0 0-.265.055l-1.533 1.542a.25.25 0 0 0 .084.41L27.172 16l-7.583 3.01a1.255 1.255 0 0 0-.785 1.24c.018.317.172.614.395.84l6.89 6.931a.246.246 0 0 0 .35 0l1.406-1.415a.25.25 0 0 0 0-.352l-5.582-5.617 8.75-3.472a1.25 1.25 0 0 0 0-2.325z',
fill: '#FF9900',
},
// Meta and Mistral use custom standalone SVG components
// These placeholder entries prevent TypeScript errors
meta: {
viewBox: '0 0 24 24',
path: '',
},
mistral: {
viewBox: '0 0 24 24',
path: '',
},
minimax: {
viewBox: '0 0 24 24',
// Official MiniMax logo from lobehub/lobe-icons
path: 'M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z',
},
glm: {
viewBox: '0 0 24 24',
// Official Z.ai logo from lobehub/lobe-icons (GLM provider)
path: 'M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z',
},
bigpickle: {
viewBox: '0 0 24 24',
// Big Pickle logo - stylized shape with dots
path: 'M8 4c-2.21 0-4 1.79-4 4v8c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V8c0-2.21-1.79-4-4-4H8zm0 2h8c1.103 0 2 .897 2 2v8c0 1.103-.897 2-2 2H8c-1.103 0-2-.897-2-2V8c0-1.103.897-2 2-2zm2 3a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2zm-4 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2z',
fill: '#4ADE80',
},
};
export interface ProviderIconProps extends Omit<SVGProps<SVGSVGElement>, 'viewBox'> {
@@ -72,7 +134,11 @@ export function ProviderIcon({ provider, title, className, ...props }: ProviderI
{...rest}
>
{title && <title>{title}</title>}
<path d={definition.path} fill="currentColor" />
<path
d={definition.path}
fill={definition.fill || 'currentColor'}
fillRule={definition.fillRule}
/>
</svg>
);
}
@@ -97,8 +163,217 @@ export function GrokIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.grok} {...props} />;
}
export function OpenCodeIcon({ className, ...props }: { className?: string }) {
return <Cpu className={cn('inline-block', className)} {...props} />;
export function OpenCodeIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.opencode} {...props} />;
}
export function DeepSeekIcon({
className,
title,
...props
}: {
className?: string;
title?: string;
}) {
const hasAccessibleLabel = Boolean(title);
return (
<svg
viewBox="0 0 24 24"
className={cn('inline-block', className)}
role={hasAccessibleLabel ? 'img' : 'presentation'}
aria-hidden={!hasAccessibleLabel}
focusable="false"
{...props}
>
{title && <title>{title}</title>}
<path
d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"
fill="#4D6BFE"
/>
</svg>
);
}
export function QwenIcon({ className, title, ...props }: { className?: string; title?: string }) {
const hasAccessibleLabel = Boolean(title);
return (
<svg
viewBox="0 0 24 24"
className={cn('inline-block', className)}
role={hasAccessibleLabel ? 'img' : 'presentation'}
aria-hidden={!hasAccessibleLabel}
focusable="false"
{...props}
>
{title && <title>{title}</title>}
<defs>
<linearGradient id="qwen-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style={{ stopColor: '#6336E7', stopOpacity: 0.84 }} />
<stop offset="100%" style={{ stopColor: '#6F69F7', stopOpacity: 0.84 }} />
</linearGradient>
</defs>
<path
d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"
fill="url(#qwen-gradient)"
/>
</svg>
);
}
export function NovaIcon({ className, title, ...props }: { className?: string; title?: string }) {
const hasAccessibleLabel = Boolean(title);
return (
<svg
viewBox="0 0 33 32"
className={cn('inline-block', className)}
role={hasAccessibleLabel ? 'img' : 'presentation'}
aria-hidden={!hasAccessibleLabel}
focusable="false"
{...props}
>
{title && <title>{title}</title>}
<path
d="m17.865 23.28 1.533 1.543c.07.07.092.175.055.267l-2.398 6.118A1.24 1.24 0 0 1 15.9 32c-.51 0-.969-.315-1.155-.793l-3.451-8.804-5.582 5.617a.246.246 0 0 1-.35 0l-1.407-1.415a.25.25 0 0 1 0-.352l6.89-6.932a1.3 1.3 0 0 1 .834-.398 1.25 1.25 0 0 1 1.232.79l2.992 7.63 1.557-3.977a.248.248 0 0 1 .408-.085zm8.224-19.3-5.583 5.617-3.45-8.805a1.24 1.24 0 0 0-1.43-.762c-.414.092-.744.407-.899.805l-2.38 6.072a.25.25 0 0 0 .055.267l1.533 1.543c.127.127.34.082.407-.085L15.9 4.655l2.991 7.629a1.24 1.24 0 0 0 2.035.425l6.922-6.965a.25.25 0 0 0 0-.352L26.44 3.977a.246.246 0 0 0-.35 0zM8.578 17.566l-3.953-1.567 7.582-3.01c.49-.195.815-.685.785-1.24a1.3 1.3 0 0 0-.395-.84l-6.886-6.93a.246.246 0 0 0-.35 0L3.954 5.395a.25.25 0 0 0 0 .353l5.583 5.617-8.75 3.472a1.25 1.25 0 0 0 0 2.325l6.079 2.412a.24.24 0 0 0 .266-.055l1.533-1.542a.25.25 0 0 0-.085-.41zm22.434-2.73-6.08-2.412a.24.24 0 0 0-.265.055l-1.533 1.542a.25.25 0 0 0 .084.41L27.172 16l-7.583 3.01a1.255 1.255 0 0 0-.785 1.24c.018.317.172.614.395.84l6.89 6.931a.246.246 0 0 0 .35 0l1.406-1.415a.25.25 0 0 0 0-.352l-5.582-5.617 8.75-3.472a1.25 1.25 0 0 0 0-2.325z"
fill="#FF9900"
/>
</svg>
);
}
export function MistralIcon({
className,
title,
...props
}: {
className?: string;
title?: string;
}) {
const hasAccessibleLabel = Boolean(title);
return (
<svg
viewBox="0 0 24 24"
className={cn('inline-block', className)}
role={hasAccessibleLabel ? 'img' : 'presentation'}
aria-hidden={!hasAccessibleLabel}
focusable="false"
{...props}
>
{title && <title>{title}</title>}
<path d="M3.428 3.4h3.429v3.428H3.428V3.4zm13.714 0h3.43v3.428h-3.43V3.4z" fill="gold" />
<path
d="M3.428 6.828h6.857v3.429H3.429V6.828zm10.286 0h6.857v3.429h-6.857V6.828z"
fill="#FFAF00"
/>
<path d="M3.428 10.258h17.144v3.428H3.428v-3.428z" fill="#FF8205" />
<path
d="M3.428 13.686h3.429v3.428H3.428v-3.428zm6.858 0h3.429v3.428h-3.429v-3.428zm6.856 0h3.43v3.428h-3.43v-3.428z"
fill="#FA500F"
/>
<path d="M0 17.114h10.286v3.429H0v-3.429zm13.714 0H24v3.429H13.714v-3.429z" fill="#E10500" />
</svg>
);
}
export function MetaIcon({ className, title, ...props }: { className?: string; title?: string }) {
const hasAccessibleLabel = Boolean(title);
return (
<svg
viewBox="0 0 24 24"
className={cn('inline-block', className)}
role={hasAccessibleLabel ? 'img' : 'presentation'}
aria-hidden={!hasAccessibleLabel}
focusable="false"
{...props}
>
{title && <title>{title}</title>}
<path
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
fill="#1877F2"
/>
</svg>
);
}
export function MiniMaxIcon({
className,
title,
...props
}: {
className?: string;
title?: string;
}) {
const hasAccessibleLabel = Boolean(title);
return (
<svg
viewBox="0 0 24 24"
className={cn('inline-block', className)}
role={hasAccessibleLabel ? 'img' : 'presentation'}
aria-hidden={!hasAccessibleLabel}
focusable="false"
{...props}
>
{title && <title>{title}</title>}
<path
d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"
fill="currentColor"
/>
</svg>
);
}
export function GlmIcon({ className, title, ...props }: { className?: string; title?: string }) {
const hasAccessibleLabel = Boolean(title);
return (
<svg
viewBox="0 0 24 24"
className={cn('inline-block', className)}
role={hasAccessibleLabel ? 'img' : 'presentation'}
aria-hidden={!hasAccessibleLabel}
focusable="false"
{...props}
>
{title && <title>{title}</title>}
<path
d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"
fill="currentColor"
/>
</svg>
);
}
export function BigPickleIcon({
className,
title,
...props
}: {
className?: string;
title?: string;
}) {
const hasAccessibleLabel = Boolean(title);
return (
<svg
viewBox="0 0 24 24"
className={cn('inline-block', className)}
role={hasAccessibleLabel ? 'img' : 'presentation'}
aria-hidden={!hasAccessibleLabel}
focusable="false"
{...props}
>
{title && <title>{title}</title>}
<path
d="M8 4c-2.21 0-4 1.79-4 4v8c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V8c0-2.21-1.79-4-4-4H8zm0 2h8c1.103 0 2 .897 2 2v8c0 1.103-.897 2-2 2H8c-1.103 0-2-.897-2-2V8c0-1.103.897-2 2-2zm2 3a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2zm-4 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2z"
fill="#4ADE80"
/>
</svg>
);
}
export const PROVIDER_ICON_COMPONENTS: Record<
@@ -106,7 +381,7 @@ export const PROVIDER_ICON_COMPONENTS: Record<
ComponentType<{ className?: string }>
> = {
claude: AnthropicIcon,
cursor: CursorIcon, // Default for Cursor provider (will be overridden by getProviderIconForModel)
cursor: CursorIcon,
codex: OpenAIIcon,
opencode: OpenCodeIcon,
};
@@ -120,6 +395,53 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
const modelStr = typeof model === 'string' ? model.toLowerCase() : model;
// Check for Amazon Bedrock models first (amazon-bedrock/...)
if (modelStr.startsWith('amazon-bedrock/')) {
// Bedrock-hosted models - detect the specific provider
if (modelStr.includes('anthropic') || modelStr.includes('claude')) {
return 'anthropic';
}
if (modelStr.includes('deepseek')) {
return 'deepseek';
}
if (modelStr.includes('nova')) {
return 'nova';
}
if (modelStr.includes('meta') || modelStr.includes('llama')) {
return 'meta';
}
if (modelStr.includes('mistral')) {
return 'mistral';
}
if (modelStr.includes('qwen')) {
return 'qwen';
}
// Default for unknown Bedrock models
return 'opencode';
}
// Check for native OpenCode models (opencode/...)
if (modelStr.startsWith('opencode/')) {
// Native OpenCode models - check specific model types
if (modelStr.includes('big-pickle')) {
return 'bigpickle';
}
if (modelStr.includes('grok')) {
return 'grok';
}
if (modelStr.includes('glm')) {
return 'glm';
}
if (modelStr.includes('gpt-5-nano') || modelStr.includes('nano')) {
return 'openai'; // GPT-5 Nano uses OpenAI icon
}
if (modelStr.includes('minimax')) {
return 'minimax';
}
// Default for other OpenCode models
return 'opencode';
}
// Check for Cursor-specific models with underlying providers
if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) {
return 'anthropic';
@@ -141,6 +463,7 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
const provider = getProviderFromModel(model);
if (provider === 'codex') return 'openai';
if (provider === 'cursor') return 'cursor';
if (provider === 'opencode') return 'opencode';
return 'anthropic';
}
@@ -155,6 +478,15 @@ export function getProviderIconForModel(
cursor: CursorIcon,
gemini: GeminiIcon,
grok: GrokIcon,
opencode: OpenCodeIcon,
deepseek: DeepSeekIcon,
qwen: QwenIcon,
nova: NovaIcon,
meta: MetaIcon,
mistral: MistralIcon,
minimax: MiniMaxIcon,
glm: GlmIcon,
bigpickle: BigPickleIcon,
};
return iconMap[iconKey] || AnthropicIcon;

View File

@@ -7,7 +7,24 @@ import {
useSensors,
rectIntersection,
pointerWithin,
type PointerEvent as DndPointerEvent,
} from '@dnd-kit/core';
// Custom pointer sensor that ignores drag events from within dialogs
class DialogAwarePointerSensor extends PointerSensor {
static activators = [
{
eventName: 'onPointerDown' as const,
handler: ({ nativeEvent: event }: { nativeEvent: DndPointerEvent }) => {
// Don't start drag if the event originated from inside a dialog
if ((event.target as Element)?.closest?.('[role="dialog"]')) {
return false;
}
return true;
},
},
];
}
import { useAppStore, Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
@@ -23,10 +40,7 @@ import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
// Board-view specific imports
import { BoardHeader } from './board-view/board-header';
import { BoardSearchBar } from './board-view/board-search-bar';
import { BoardControls } from './board-view/board-controls';
import { KanbanBoard } from './board-view/kanban-board';
import { GraphView } from './graph-view';
import {
AddFeatureDialog,
AgentOutputModal,
@@ -73,12 +87,6 @@ export function BoardView() {
maxConcurrency,
setMaxConcurrency,
defaultSkipTests,
showProfilesOnly,
aiProfiles,
kanbanCardDetailLevel,
setKanbanCardDetailLevel,
boardViewMode,
setBoardViewMode,
specCreatingForProject,
setSpecCreatingForProject,
pendingPlanApproval,
@@ -97,6 +105,8 @@ export function BoardView() {
} = useAppStore();
// Subscribe to pipelineConfigByProject to trigger re-renders when it changes
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
const shortcuts = useKeyboardShortcutsConfig();
const {
features: hookFeatures,
@@ -151,12 +161,14 @@ export function BoardView() {
followUpPrompt,
followUpImagePaths,
followUpPreviewMap,
followUpPromptHistory,
setShowFollowUpDialog,
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
handleFollowUpDialogChange,
addToPromptHistory,
} = useFollowUpState();
// Selection mode hook for mass editing
@@ -248,7 +260,7 @@ export function BoardView() {
}, []);
const sensors = useSensors(
useSensor(PointerSensor, {
useSensor(DialogAwarePointerSensor, {
activationConstraint: {
distance: 8,
},
@@ -495,6 +507,45 @@ export function BoardView() {
[currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]
);
// Handler for bulk deleting multiple features
const handleBulkDelete = useCallback(async () => {
if (!currentProject || selectedFeatureIds.size === 0) return;
try {
const api = getHttpApiClient();
const featureIds = Array.from(selectedFeatureIds);
const result = await api.features.bulkDelete(currentProject.path, featureIds);
const successfullyDeletedIds =
result.results?.filter((r) => r.success).map((r) => r.featureId) ?? [];
if (successfullyDeletedIds.length > 0) {
// Delete from local state without calling the API again
successfullyDeletedIds.forEach((featureId) => {
useAppStore.getState().removeFeature(featureId);
});
toast.success(`Deleted ${successfullyDeletedIds.length} features`);
}
if (result.failedCount && result.failedCount > 0) {
toast.error('Failed to delete some features', {
description: `${result.failedCount} features failed to delete`,
});
}
// Exit selection mode and reload if the operation was at least partially processed.
if (result.results) {
exitSelectionMode();
loadFeatures();
} else if (!result.success) {
toast.error('Failed to delete features', { description: result.error });
}
} catch (error) {
logger.error('Bulk delete failed:', error);
toast.error('Failed to delete features');
}
}, [currentProject, selectedFeatureIds, exitSelectionMode, loadFeatures]);
// Get selected features for mass edit dialog
const selectedFeatures = useMemo(() => {
return hookFeatures.filter((f) => selectedFeatureIds.has(f.id));
@@ -1140,7 +1191,7 @@ export function BoardView() {
>
{/* Header */}
<BoardHeader
projectName={currentProject.name}
projectPath={currentProject.path}
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAutoTasks.length}
onConcurrencyChange={setMaxConcurrency}
@@ -1152,134 +1203,93 @@ export function BoardView() {
autoMode.stop();
}
}}
onAddFeature={() => setShowAddDialog(true)}
onOpenPlanDialog={() => setShowPlanDialog(true)}
addFeatureShortcut={{
key: shortcuts.addFeature,
action: () => setShowAddDialog(true),
description: 'Add new feature',
}}
isMounted={isMounted}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
isCreatingSpec={isCreatingSpec}
creatingSpecProjectPath={creatingSpecProjectPath}
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
onShowCompletedModal={() => setShowCompletedModal(true)}
completedCount={completedFeatures.length}
/>
{/* Worktree Panel */}
<WorktreePanel
refreshTrigger={worktreeRefreshKey}
projectPath={currentProject.path}
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
onDeleteWorktree={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowDeleteWorktreeDialog(true);
}}
onCommit={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCommitWorktreeDialog(true);
}}
onCreatePR={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreatePRDialog(true);
}}
onCreateBranch={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
}}
onAddressPRComments={handleAddressPRComments}
onResolveConflicts={handleResolveConflicts}
onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasks}
branchCardCounts={branchCardCounts}
features={hookFeatures.map((f) => ({
id: f.id,
branchName: f.branchName,
}))}
/>
{/* Worktree Panel - conditionally rendered based on visibility setting */}
{(worktreePanelVisibleByProject[currentProject.path] ?? true) && (
<WorktreePanel
refreshTrigger={worktreeRefreshKey}
projectPath={currentProject.path}
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
onDeleteWorktree={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowDeleteWorktreeDialog(true);
}}
onCommit={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCommitWorktreeDialog(true);
}}
onCreatePR={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreatePRDialog(true);
}}
onCreateBranch={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
}}
onAddressPRComments={handleAddressPRComments}
onResolveConflicts={handleResolveConflicts}
onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasks}
branchCardCounts={branchCardCounts}
features={hookFeatures.map((f) => ({
id: f.id,
branchName: f.branchName,
}))}
/>
)}
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Search Bar Row */}
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
<BoardSearchBar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
isCreatingSpec={isCreatingSpec}
creatingSpecProjectPath={creatingSpecProjectPath ?? undefined}
currentProjectPath={currentProject?.path}
/>
{/* Board Background & Detail Level Controls */}
<BoardControls
isMounted={isMounted}
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
onShowCompletedModal={() => setShowCompletedModal(true)}
completedCount={completedFeatures.length}
kanbanCardDetailLevel={kanbanCardDetailLevel}
onDetailLevelChange={setKanbanCardDetailLevel}
boardViewMode={boardViewMode}
onBoardViewModeChange={setBoardViewMode}
/>
</div>
{/* View Content - Kanban or Graph */}
{boardViewMode === 'kanban' ? (
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
pipelineConfig={
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
/>
) : (
<GraphView
features={hookFeatures}
runningAutoTasks={runningAutoTasks}
currentWorktreePath={currentWorktreePath}
currentWorktreeBranch={currentWorktreeBranch}
projectPath={currentProject?.path || null}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
onEditFeature={(feature) => setEditingFeature(feature)}
onViewOutput={handleViewOutput}
onStartTask={handleStartImplementation}
onStopTask={handleForceStopFeature}
onResumeTask={handleResumeFeature}
onUpdateFeature={updateFeature}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
/>
)}
{/* View Content - Kanban Board */}
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
onAddFeature={() => setShowAddDialog(true)}
pipelineConfig={
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
/>
</div>
{/* Selection Action Bar */}
@@ -1288,6 +1298,7 @@ export function BoardView() {
selectedCount={selectedCount}
totalCount={allSelectableFeatureIds.length}
onEdit={() => setShowMassEditDialog(true)}
onDelete={handleBulkDelete}
onClear={clearSelection}
onSelectAll={() => selectAll(allSelectableFeatureIds)}
/>
@@ -1299,8 +1310,6 @@ export function BoardView() {
onClose={() => setShowMassEditDialog(false)}
selectedFeatures={selectedFeatures}
onApply={handleBulkUpdate}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
/>
{/* Board Background Modal */}
@@ -1348,8 +1357,6 @@ export function BoardView() {
defaultBranch={selectedWorktreeBranch}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
parentFeature={spawnParentFeature}
allFeatures={hookFeatures}
/>
@@ -1364,8 +1371,6 @@ export function BoardView() {
branchCardCounts={branchCardCounts}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
allFeatures={hookFeatures}
/>
@@ -1419,6 +1424,8 @@ export function BoardView() {
onPreviewMapChange={setFollowUpPreviewMap}
onSend={handleSendFollowUp}
isMaximized={isMaximized}
promptHistory={followUpPromptHistory}
onHistoryAdd={addToPromptHistory}
/>
{/* Backlog Plan Dialog */}

View File

@@ -1,18 +1,12 @@
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ImageIcon, Archive, Minimize2, Square, Maximize2, Columns3, Network } from 'lucide-react';
import { cn } from '@/lib/utils';
import { BoardViewMode } from '@/store/app-store';
import { ImageIcon, Archive } from 'lucide-react';
interface BoardControlsProps {
isMounted: boolean;
onShowBoardBackground: () => void;
onShowCompletedModal: () => void;
completedCount: number;
kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed';
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void;
boardViewMode: BoardViewMode;
onBoardViewModeChange: (mode: BoardViewMode) => void;
}
export function BoardControls({
@@ -20,61 +14,12 @@ export function BoardControls({
onShowBoardBackground,
onShowCompletedModal,
completedCount,
kanbanCardDetailLevel,
onDetailLevelChange,
boardViewMode,
onBoardViewModeChange,
}: BoardControlsProps) {
if (!isMounted) return null;
return (
<TooltipProvider>
<div className="flex items-center gap-2 ml-4">
{/* View Mode Toggle - Kanban / Graph */}
<div
className="flex items-center rounded-lg bg-secondary border border-border"
data-testid="view-mode-toggle"
>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onBoardViewModeChange('kanban')}
className={cn(
'p-2 rounded-l-lg transition-colors',
boardViewMode === 'kanban'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-mode-kanban"
>
<Columns3 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Kanban Board View</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onBoardViewModeChange('graph')}
className={cn(
'p-2 rounded-r-lg transition-colors',
boardViewMode === 'graph'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-mode-graph"
>
<Network className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Dependency Graph View</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>
@@ -115,70 +60,6 @@ export function BoardControls({
<p>Completed Features ({completedCount})</p>
</TooltipContent>
</Tooltip>
{/* Kanban Card Detail Level Toggle */}
<div
className="flex items-center rounded-lg bg-secondary border border-border"
data-testid="kanban-detail-toggle"
>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange('minimal')}
className={cn(
'p-2 rounded-l-lg transition-colors',
kanbanCardDetailLevel === 'minimal'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="kanban-toggle-minimal"
>
<Minimize2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Minimal - Title & category only</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange('standard')}
className={cn(
'p-2 transition-colors',
kanbanCardDetailLevel === 'standard'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="kanban-toggle-standard"
>
<Square className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Standard - Steps & progress</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange('detailed')}
className={cn(
'p-2 rounded-r-lg transition-colors',
kanbanCardDetailLevel === 'detailed'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="kanban-toggle-detailed"
>
<Maximize2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Detailed - Model, tools & tasks</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipProvider>
);

View File

@@ -1,27 +1,36 @@
import { useState } from 'react';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Plus, Bot, Wand2, Settings2 } from 'lucide-react';
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react';
import { UsagePopover } from '@/components/usage-popover';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
import { getHttpApiClient } from '@/lib/http-api-client';
import { BoardSearchBar } from './board-search-bar';
import { BoardControls } from './board-controls';
interface BoardHeaderProps {
projectName: string;
projectPath: string;
maxConcurrency: number;
runningAgentsCount: number;
onConcurrencyChange: (value: number) => void;
isAutoModeRunning: boolean;
onAutoModeToggle: (enabled: boolean) => void;
onAddFeature: () => void;
onOpenPlanDialog: () => void;
addFeatureShortcut: KeyboardShortcut;
isMounted: boolean;
// Search bar props
searchQuery: string;
onSearchChange: (query: string) => void;
isCreatingSpec: boolean;
creatingSpecProjectPath?: string;
// Board controls props
onShowBoardBackground: () => void;
onShowCompletedModal: () => void;
completedCount: number;
}
// Shared styles for header control containers
@@ -29,16 +38,21 @@ const controlContainerClass =
'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border';
export function BoardHeader({
projectName,
projectPath,
maxConcurrency,
runningAgentsCount,
onConcurrencyChange,
isAutoModeRunning,
onAutoModeToggle,
onAddFeature,
onOpenPlanDialog,
addFeatureShortcut,
isMounted,
searchQuery,
onSearchChange,
isCreatingSpec,
creatingSpecProjectPath,
onShowBoardBackground,
onShowCompletedModal,
completedCount,
}: BoardHeaderProps) {
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
const apiKeys = useAppStore((state) => state.apiKeys);
@@ -47,6 +61,29 @@ export function BoardHeader({
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
// Worktree panel visibility (per-project)
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
const setWorktreePanelVisible = useAppStore((state) => state.setWorktreePanelVisible);
const isWorktreePanelVisible = worktreePanelVisibleByProject[projectPath] ?? true;
const handleWorktreePanelToggle = useCallback(
async (visible: boolean) => {
// Update local store
setWorktreePanelVisible(projectPath, visible);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(projectPath, {
worktreePanelVisible: visible,
});
} catch (error) {
console.error('Failed to persist worktree panel visibility:', error);
}
},
[projectPath, setWorktreePanelVisible]
);
// Claude usage tracking visibility logic
// Hide when using API key (only show for Claude Code CLI users)
// Also hide on Windows for now (CLI usage command not supported)
@@ -63,37 +100,84 @@ export function BoardHeader({
return (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div>
<h1 className="text-xl font-bold">Kanban Board</h1>
<p className="text-sm text-muted-foreground">{projectName}</p>
<div className="flex items-center gap-4">
<BoardSearchBar
searchQuery={searchQuery}
onSearchChange={onSearchChange}
isCreatingSpec={isCreatingSpec}
creatingSpecProjectPath={creatingSpecProjectPath}
currentProjectPath={projectPath}
/>
<BoardControls
isMounted={isMounted}
onShowBoardBackground={onShowBoardBackground}
onShowCompletedModal={onShowCompletedModal}
completedCount={completedCount}
/>
</div>
<div className="flex gap-2 items-center">
{/* Usage Popover - show if either provider is authenticated */}
{isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
{/* Worktrees Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
<div className={controlContainerClass} data-testid="concurrency-slider-container">
<Bot className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Agents</span>
<Slider
value={[maxConcurrency]}
onValueChange={(value) => onConcurrencyChange(value[0])}
min={1}
max={10}
step={1}
className="w-20"
data-testid="concurrency-slider"
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<Label htmlFor="worktrees-toggle" className="text-sm font-medium cursor-pointer">
Worktrees
</Label>
<Switch
id="worktrees-toggle"
checked={isWorktreePanelVisible}
onCheckedChange={handleWorktreePanelToggle}
data-testid="worktrees-toggle"
/>
<span
className="text-sm text-muted-foreground min-w-[5ch] text-center"
data-testid="concurrency-value"
>
{runningAgentsCount} / {maxConcurrency}
</span>
</div>
)}
{/* Concurrency Control - only show after mount to prevent hydration issues */}
{isMounted && (
<Popover>
<PopoverTrigger asChild>
<button
className={`${controlContainerClass} cursor-pointer hover:bg-accent/50 transition-colors`}
data-testid="concurrency-slider-container"
>
<Bot className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Agents</span>
<span className="text-sm text-muted-foreground" data-testid="concurrency-value">
{runningAgentsCount}/{maxConcurrency}
</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="space-y-4">
<div>
<h4 className="font-medium text-sm mb-1">Max Concurrent Agents</h4>
<p className="text-xs text-muted-foreground">
Controls how many AI agents can run simultaneously. Higher values process more
features in parallel but use more API resources.
</p>
</div>
<div className="flex items-center gap-3">
<Slider
value={[maxConcurrency]}
onValueChange={(value) => onConcurrencyChange(value[0])}
min={1}
max={10}
step={1}
className="flex-1"
data-testid="concurrency-slider"
/>
<span className="text-sm font-medium min-w-[2ch] text-right">
{maxConcurrency}
</span>
</div>
</div>
</PopoverContent>
</Popover>
)}
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
@@ -134,17 +218,6 @@ export function BoardHeader({
<Wand2 className="w-4 h-4 mr-2" />
Plan
</Button>
<HotkeyButton
size="sm"
onClick={onAddFeature}
hotkey={addFeatureShortcut}
hotkeyActive={false}
data-testid="add-feature-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
</HotkeyButton>
</div>
</div>
);

View File

@@ -1,6 +1,6 @@
// @ts-nocheck
import { useEffect, useState } from 'react';
import { Feature, ThinkingLevel, useAppStore } from '@/store/app-store';
import { Feature, ThinkingLevel } from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
import { getProviderFromModel } from '@/lib/utils';
import {
@@ -68,11 +68,9 @@ export function AgentInfoPanel({
summary,
isCurrentAutoTask,
}: AgentInfoPanelProps) {
const { kanbanCardDetailLevel } = useAppStore();
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const showAgentInfo = kanbanCardDetailLevel === 'detailed';
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
useEffect(() => {
const loadContext = async () => {
@@ -123,7 +121,7 @@ export function AgentInfoPanel({
}
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
// Model/Preset Info for Backlog Cards
if (showAgentInfo && feature.status === 'backlog') {
if (feature.status === 'backlog') {
const provider = getProviderFromModel(feature.model);
const isCodex = provider === 'codex';
const isClaude = provider === 'claude';
@@ -160,7 +158,7 @@ export function AgentInfoPanel({
}
// Agent Info Panel for non-backlog cards
if (showAgentInfo && feature.status !== 'backlog' && agentInfo) {
if (feature.status !== 'backlog' && agentInfo) {
return (
<>
<div className="mb-3 space-y-2 overflow-hidden">
@@ -200,32 +198,47 @@ export function AgentInfoPanel({
{agentInfo.todos.length} tasks
</span>
</div>
<div className="space-y-0.5 max-h-16 overflow-y-auto">
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
{todo.status === 'completed' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === 'in_progress' ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}
<span
className={cn(
'break-words hyphens-auto line-clamp-2 leading-relaxed',
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
todo.status === 'pending' && 'text-muted-foreground/80'
<div
className={cn(
'space-y-0.5 overflow-y-auto',
isTodosExpanded ? 'max-h-40' : 'max-h-16'
)}
>
{(isTodosExpanded ? agentInfo.todos : agentInfo.todos.slice(0, 3)).map(
(todo, idx) => (
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
{todo.status === 'completed' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === 'in_progress' ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}
>
{todo.content}
</span>
</div>
))}
<span
className={cn(
'break-words hyphens-auto line-clamp-2 leading-relaxed',
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
todo.status === 'pending' && 'text-muted-foreground/80'
)}
>
{todo.content}
</span>
</div>
)
)}
{agentInfo.todos.length > 3 && (
<p className="text-[10px] text-muted-foreground/60 pl-4">
+{agentInfo.todos.length - 3} more
</p>
<button
onClick={(e) => {
e.stopPropagation();
setIsTodosExpanded(!isTodosExpanded);
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="text-[10px] text-muted-foreground/60 pl-4 hover:text-muted-foreground transition-colors cursor-pointer"
>
{isTodosExpanded ? 'Show less' : `+${agentInfo.todos.length - 3} more`}
</button>
)}
</div>
</div>
@@ -255,7 +268,11 @@ export function AgentInfoPanel({
<Expand className="w-3 h-3" />
</button>
</div>
<p className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
<p
className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden select-text cursor-text"
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{feature.summary || summary || agentInfo.summary}
</p>
</div>
@@ -292,58 +309,15 @@ export function AgentInfoPanel({
);
}
// Show just the todo list for non-backlog features when showAgentInfo is false
// This ensures users always see what the agent is working on
if (!showAgentInfo && feature.status !== 'backlog' && agentInfo && agentInfo.todos.length > 0) {
return (
<div className="mb-3 space-y-1 overflow-hidden">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
<ListTodo className="w-3 h-3" />
<span>
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
{agentInfo.todos.length} tasks
</span>
</div>
<div className="space-y-0.5 max-h-24 overflow-y-auto">
{agentInfo.todos.map((todo, idx) => (
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
{todo.status === 'completed' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === 'in_progress' ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}
<span
className={cn(
'break-words hyphens-auto line-clamp-2 leading-relaxed',
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
todo.status === 'pending' && 'text-muted-foreground/80'
)}
>
{todo.content}
</span>
</div>
))}
</div>
</div>
);
}
// Always render SummaryDialog if showAgentInfo is true (even if no agentInfo yet)
// Always render SummaryDialog (even if no agentInfo yet)
// This ensures the dialog can be opened from the expand button
return (
<>
{showAgentInfo && (
<SummaryDialog
feature={feature}
agentInfo={agentInfo}
summary={summary}
isOpen={isSummaryDialogOpen}
onOpenChange={setIsSummaryDialogOpen}
/>
)}
</>
<SummaryDialog
feature={feature}
agentInfo={agentInfo}
summary={summary}
isOpen={isSummaryDialogOpen}
onOpenChange={setIsSummaryDialogOpen}
/>
);
}

View File

@@ -31,8 +31,11 @@ export function SummaryDialog({
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col"
className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col select-text"
data-testid={`summary-dialog-${feature.id}`}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">

View File

@@ -10,6 +10,8 @@ interface KanbanColumnProps {
count: number;
children: ReactNode;
headerAction?: ReactNode;
/** Floating action button at the bottom of the column */
footerAction?: ReactNode;
opacity?: number;
showBorder?: boolean;
hideScrollbar?: boolean;
@@ -24,6 +26,7 @@ export const KanbanColumn = memo(function KanbanColumn({
count,
children,
headerAction,
footerAction,
opacity = 100,
showBorder = true,
hideScrollbar = false,
@@ -79,12 +82,21 @@ export const KanbanColumn = memo(function KanbanColumn({
hideScrollbar &&
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
// Smooth scrolling
'scroll-smooth'
'scroll-smooth',
// Add padding at bottom if there's a footer action
footerAction && 'pb-14'
)}
>
{children}
</div>
{/* Floating Footer Action */}
{footerAction && (
<div className="absolute bottom-0 left-0 right-0 z-20 p-2 bg-gradient-to-t from-card/95 via-card/80 to-transparent pt-6">
{footerAction}
</div>
)}
{/* Drop zone indicator when dragging over */}
{isOver && (
<div className="absolute inset-0 rounded-xl bg-primary/5 pointer-events-none z-5 border-2 border-dashed border-primary/20" />

View File

@@ -1,11 +1,21 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Pencil, X, CheckSquare } from 'lucide-react';
import { Pencil, X, CheckSquare, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface SelectionActionBarProps {
selectedCount: number;
totalCount: number;
onEdit: () => void;
onDelete: () => void;
onClear: () => void;
onSelectAll: () => void;
}
@@ -14,65 +24,126 @@ export function SelectionActionBar({
selectedCount,
totalCount,
onEdit,
onDelete,
onClear,
onSelectAll,
}: SelectionActionBarProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
if (selectedCount === 0) return null;
const allSelected = selectedCount === totalCount;
const handleDeleteClick = () => {
setShowDeleteDialog(true);
};
const handleConfirmDelete = () => {
setShowDeleteDialog(false);
onDelete();
};
return (
<div
className={cn(
'fixed bottom-6 left-1/2 -translate-x-1/2 z-50',
'flex items-center gap-3 px-4 py-3 rounded-xl',
'bg-background/95 backdrop-blur-sm border border-border shadow-lg',
'animate-in slide-in-from-bottom-4 fade-in duration-200'
)}
data-testid="selection-action-bar"
>
<span className="text-sm font-medium text-foreground">
{selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected
</span>
<>
<div
className={cn(
'fixed bottom-6 left-1/2 -translate-x-1/2 z-50',
'flex items-center gap-3 px-4 py-3 rounded-xl',
'bg-background/95 backdrop-blur-sm border border-border shadow-lg',
'animate-in slide-in-from-bottom-4 fade-in duration-200'
)}
data-testid="selection-action-bar"
>
<span className="text-sm font-medium text-foreground">
{selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected
</span>
<div className="h-4 w-px bg-border" />
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={onEdit}
className="h-8 bg-brand-500 hover:bg-brand-600"
data-testid="selection-edit-button"
>
<Pencil className="w-4 h-4 mr-1.5" />
Edit Selected
</Button>
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={onEdit}
className="h-8 bg-brand-500 hover:bg-brand-600"
data-testid="selection-edit-button"
>
<Pencil className="w-4 h-4 mr-1.5" />
Edit Selected
</Button>
{!allSelected && (
<Button
variant="outline"
size="sm"
onClick={onSelectAll}
className="h-8"
data-testid="selection-select-all-button"
onClick={handleDeleteClick}
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
data-testid="selection-delete-button"
>
<CheckSquare className="w-4 h-4 mr-1.5" />
Select All ({totalCount})
<Trash2 className="w-4 h-4 mr-1.5" />
Delete
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={onClear}
className="h-8 text-muted-foreground hover:text-foreground"
data-testid="selection-clear-button"
>
<X className="w-4 h-4 mr-1.5" />
Clear
</Button>
{!allSelected && (
<Button
variant="outline"
size="sm"
onClick={onSelectAll}
className="h-8"
data-testid="selection-select-all-button"
>
<CheckSquare className="w-4 h-4 mr-1.5" />
Select All ({totalCount})
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={onClear}
className="h-8 text-muted-foreground hover:text-foreground"
data-testid="selection-clear-button"
>
<X className="w-4 h-4 mr-1.5" />
Clear
</Button>
</div>
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent data-testid="bulk-delete-confirmation-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<Trash2 className="w-5 h-5" />
Delete Selected Features?
</DialogTitle>
<DialogDescription>
Are you sure you want to permanently delete {selectedCount} feature
{selectedCount !== 1 ? 's' : ''}?
<span className="block mt-2 text-destructive font-medium">
This action cannot be undone.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setShowDeleteDialog(false)}
data-testid="cancel-bulk-delete-button"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
data-testid="confirm-bulk-delete-button"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,254 @@
import { useState, useRef, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Upload } from 'lucide-react';
import { toast } from 'sonner';
import type { PipelineStep } from '@automaker/types';
import { cn } from '@/lib/utils';
import { STEP_TEMPLATES } from './pipeline-step-templates';
// Color options for pipeline columns
const COLOR_OPTIONS = [
{ value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' },
{ value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' },
{ value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' },
{ value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' },
{ value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' },
{ value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' },
{ value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' },
{ value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' },
{ value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' },
];
interface AddEditPipelineStepDialogProps {
open: boolean;
onClose: () => void;
onSave: (step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }) => void;
existingStep?: PipelineStep | null;
defaultOrder: number;
}
export function AddEditPipelineStepDialog({
open,
onClose,
onSave,
existingStep,
defaultOrder,
}: AddEditPipelineStepDialogProps) {
const isEditing = !!existingStep;
const fileInputRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState('');
const [instructions, setInstructions] = useState('');
const [colorClass, setColorClass] = useState(COLOR_OPTIONS[0].value);
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
// Reset form when dialog opens/closes or existingStep changes
useEffect(() => {
if (open) {
if (existingStep) {
setName(existingStep.name);
setInstructions(existingStep.instructions);
setColorClass(existingStep.colorClass);
setSelectedTemplate(null);
} else {
setName('');
setInstructions('');
setColorClass(COLOR_OPTIONS[defaultOrder % COLOR_OPTIONS.length].value);
setSelectedTemplate(null);
}
}
}, [open, existingStep, defaultOrder]);
const handleTemplateClick = (templateId: string) => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
if (template) {
setName(template.name);
setInstructions(template.instructions);
setColorClass(template.colorClass);
setSelectedTemplate(templateId);
toast.success(`Loaded "${template.name}" template`);
}
};
const handleFileUpload = () => {
fileInputRef.current?.click();
};
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const content = await file.text();
setInstructions(content);
toast.success('Instructions loaded from file');
} catch {
toast.error('Failed to load file');
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSave = () => {
if (!name.trim()) {
toast.error('Step name is required');
return;
}
if (!instructions.trim()) {
toast.error('Step instructions are required');
return;
}
onSave({
id: existingStep?.id,
name: name.trim(),
instructions: instructions.trim(),
colorClass,
order: existingStep?.order ?? defaultOrder,
});
onClose();
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
{/* Hidden file input for loading instructions from .md files */}
<input
ref={fileInputRef}
type="file"
accept=".md,.txt"
className="hidden"
onChange={handleFileInputChange}
/>
<DialogHeader>
<DialogTitle>{isEditing ? 'Edit Pipeline Step' : 'Add Pipeline Step'}</DialogTitle>
<DialogDescription>
{isEditing
? 'Modify the step configuration below.'
: 'Configure a new step for your pipeline. Choose a template to get started quickly, or create from scratch.'}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 space-y-6">
{/* Template Quick Start - Only show for new steps */}
{!isEditing && (
<div className="space-y-3">
<Label className="text-sm font-medium">Quick Start from Template</Label>
<div className="flex flex-wrap gap-2">
{STEP_TEMPLATES.map((template) => (
<button
key={template.id}
type="button"
onClick={() => handleTemplateClick(template.id)}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg border transition-all text-sm',
selectedTemplate === template.id
? 'border-primary bg-primary/10 ring-1 ring-primary'
: 'border-border hover:border-primary/50 hover:bg-muted/50'
)}
>
<div
className={cn('w-2 h-2 rounded-full', template.colorClass.replace('/20', ''))}
/>
{template.name}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
Click a template to pre-fill the form, then customize as needed.
</p>
</div>
)}
{/* Divider */}
{!isEditing && <div className="border-t" />}
{/* Step Name */}
<div className="space-y-2">
<Label htmlFor="step-name">
Step Name <span className="text-destructive">*</span>
</Label>
<Input
id="step-name"
placeholder="e.g., Code Review, Testing, Documentation"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus={isEditing}
/>
</div>
{/* Color Selection */}
<div className="space-y-2">
<Label>Column Color</Label>
<div className="flex flex-wrap gap-2">
{COLOR_OPTIONS.map((color) => (
<button
key={color.value}
type="button"
className={cn(
'w-8 h-8 rounded-full transition-all',
color.preview,
colorClass === color.value
? 'ring-2 ring-offset-2 ring-primary'
: 'opacity-60 hover:opacity-100'
)}
onClick={() => setColorClass(color.value)}
title={color.label}
/>
))}
</div>
</div>
{/* Agent Instructions */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="step-instructions">
Agent Instructions <span className="text-destructive">*</span>
</Label>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={handleFileUpload}>
<Upload className="h-3 w-3 mr-1" />
Load from file
</Button>
</div>
<Textarea
id="step-instructions"
placeholder="Instructions for the agent to follow during this pipeline step. Use markdown formatting for best results."
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
rows={10}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
These instructions will be sent to the agent when this step runs. Be specific about
what you want the agent to review, check, or modify.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave}>{isEditing ? 'Update Step' : 'Add to Pipeline'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -21,46 +21,33 @@ import {
FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Sparkles, ChevronDown, ChevronRight, Play, Cpu, FolderKanban } from 'lucide-react';
import { Play, Cpu, FolderKanban } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
import {
useAppStore,
ModelAlias,
ThinkingLevel,
FeatureImage,
AIProfile,
PlanningMode,
Feature,
} from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry } from '@automaker/types';
import {
supportsReasoningEffort,
PROVIDER_PREFIXES,
isCursorModel,
isClaudeModel,
} from '@automaker/types';
import { supportsReasoningEffort, isClaudeModel } from '@automaker/types';
import {
TestingTabContent,
PrioritySelector,
WorkModeSelector,
PlanningModeSelect,
AncestorContextSection,
ProfileTypeahead,
EnhanceWithAI,
EnhancementHistoryButton,
type BaseHistoryEntry,
} from '../shared';
import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useNavigate } from '@tanstack/react-router';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
getAncestors,
formatAncestorContextForPrompt,
@@ -100,12 +87,17 @@ interface AddFeatureDialogProps {
defaultBranch?: string;
currentBranch?: string;
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
parentFeature?: Feature | null;
allFeatures?: Feature[];
}
/**
* A single entry in the description history
*/
interface DescriptionHistoryEntry extends BaseHistoryEntry {
description: string;
}
export function AddFeatureDialog({
open,
onOpenChange,
@@ -118,13 +110,10 @@ export function AddFeatureDialog({
defaultBranch = 'main',
currentBranch,
isMaximized,
showProfilesOnly,
aiProfiles,
parentFeature = null,
allFeatures = [],
}: AddFeatureDialogProps) {
const isSpawnMode = !!parentFeature;
const navigate = useNavigate();
const [workMode, setWorkMode] = useState<WorkMode>('current');
// Form state
@@ -139,7 +128,6 @@ export function AddFeatureDialog({
const [priority, setPriority] = useState(2);
// Model selection state
const [selectedProfileId, setSelectedProfileId] = useState<string | undefined>();
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'opus' });
// Check if current model supports planning mode (Claude/Anthropic only)
@@ -152,21 +140,16 @@ export function AddFeatureDialog({
// UI state
const [previewMap, setPreviewMap] = useState<ImagePreviewMap>(() => new Map());
const [descriptionError, setDescriptionError] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<
'improve' | 'technical' | 'simplify' | 'acceptance'
>('improve');
const [enhanceOpen, setEnhanceOpen] = useState(false);
// Description history state
const [descriptionHistory, setDescriptionHistory] = useState<DescriptionHistoryEntry[]>([]);
// Spawn mode state
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
// Get defaults from store
const { defaultPlanningMode, defaultRequirePlanApproval, defaultAIProfileId } = useAppStore();
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
const { defaultPlanningMode, defaultRequirePlanApproval } = useAppStore();
// Track previous open state to detect when dialog opens
const wasOpenRef = useRef(false);
@@ -177,24 +160,15 @@ export function AddFeatureDialog({
wasOpenRef.current = open;
if (justOpened) {
const defaultProfile = defaultAIProfileId
? aiProfiles.find((p) => p.id === defaultAIProfileId)
: null;
setSkipTests(defaultSkipTests);
setBranchName(defaultBranch || '');
setWorkMode('current');
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setModelEntry({ model: 'opus' });
// Set model from default profile or fallback
if (defaultProfile) {
setSelectedProfileId(defaultProfile.id);
applyProfileToModel(defaultProfile);
} else {
setSelectedProfileId(undefined);
setModelEntry({ model: 'opus' });
}
// Initialize description history (empty for new feature)
setDescriptionHistory([]);
// Initialize ancestors for spawn mode
if (parentFeature) {
@@ -212,41 +186,12 @@ export function AddFeatureDialog({
defaultBranch,
defaultPlanningMode,
defaultRequirePlanApproval,
defaultAIProfileId,
aiProfiles,
parentFeature,
allFeatures,
]);
const applyProfileToModel = (profile: AIProfile) => {
if (profile.provider === 'cursor') {
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
setModelEntry({ model: cursorModel as ModelAlias });
} else if (profile.provider === 'codex') {
setModelEntry({
model: profile.codexModel || 'codex-gpt-5.2-codex',
reasoningEffort: 'none',
});
} else if (profile.provider === 'opencode') {
setModelEntry({ model: profile.opencodeModel || 'opencode/big-pickle' });
} else {
// Claude
setModelEntry({
model: profile.model || 'sonnet',
thinkingLevel: profile.thinkingLevel || 'none',
});
}
};
const handleProfileSelect = (profile: AIProfile) => {
setSelectedProfileId(profile.id);
applyProfileToModel(profile);
};
const handleModelChange = (entry: PhaseModelEntry) => {
setModelEntry(entry);
// Clear profile selection when manually changing model
setSelectedProfileId(undefined);
};
const buildFeatureData = (): FeatureData | null => {
@@ -327,14 +272,13 @@ export function AddFeatureDialog({
setSkipTests(defaultSkipTests);
setBranchName('');
setPriority(2);
setSelectedProfileId(undefined);
setModelEntry({ model: 'opus' });
setWorkMode('current');
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setPreviewMap(new Map());
setDescriptionError(false);
setEnhanceOpen(false);
setDescriptionHistory([]);
onOpenChange(false);
};
@@ -357,33 +301,6 @@ export function AddFeatureDialog({
}
};
const handleEnhanceDescription = async () => {
if (!description.trim() || isEnhancing) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
description,
enhancementMode,
enhancementOverride.effectiveModel,
enhancementOverride.effectiveModelEntry.thinkingLevel
);
if (result?.success && result.enhancedText) {
setDescription(result.enhancedText);
toast.success('Description enhanced!');
} else {
toast.error(result?.error || 'Failed to enhance description');
}
} catch (error) {
logger.error('Enhancement failed:', error);
toast.error('Failed to enhance description');
} finally {
setIsEnhancing(false);
}
};
// Shared card styling
const cardClass = 'rounded-lg border border-border/50 bg-muted/30 p-4 space-y-3';
const sectionHeaderClass = 'flex items-center gap-2 text-sm font-medium text-foreground';
@@ -435,7 +352,18 @@ export function AddFeatureDialog({
{/* Task Details Section */}
<div className={cardClass}>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<div className="flex items-center justify-between">
<Label htmlFor="description">Description</Label>
{/* Version History Button */}
<EnhancementHistoryButton
history={descriptionHistory}
currentValue={description}
onRestore={setDescription}
valueAccessor={(entry) => entry.description}
title="Version History"
restoreMessage="Description restored from history"
/>
</div>
<DescriptionImageDropZone
value={description}
onChange={(value) => {
@@ -464,71 +392,35 @@ export function AddFeatureDialog({
/>
</div>
{/* Collapsible Enhancement Section */}
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}>
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1">
{enhanceOpen ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
<Sparkles className="w-4 h-4" />
<span>Enhance with AI</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="flex flex-wrap items-center gap-2 pl-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-xs">
{enhancementMode === 'improve' && 'Improve Clarity'}
{enhancementMode === 'technical' && 'Add Technical Details'}
{enhancementMode === 'simplify' && 'Simplify'}
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="default"
size="sm"
className="h-8 text-xs"
onClick={handleEnhanceDescription}
disabled={!description.trim() || isEnhancing}
loading={isEnhancing}
>
<Sparkles className="w-3 h-3 mr-1" />
Enhance
</Button>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
</CollapsibleContent>
</Collapsible>
{/* Enhancement Section */}
<EnhanceWithAI
value={description}
onChange={setDescription}
onHistoryAdd={({ mode, originalText, enhancedText }) => {
const timestamp = new Date().toISOString();
setDescriptionHistory((prev) => {
const newHistory = [...prev];
// Add original text first (so user can restore to pre-enhancement state)
// Only add if it's different from the last entry to avoid duplicates
const lastEntry = prev[prev.length - 1];
if (!lastEntry || lastEntry.description !== originalText) {
newHistory.push({
description: originalText,
timestamp,
source: prev.length === 0 ? 'initial' : 'edit',
});
}
// Add enhanced text
newHistory.push({
description: enhancedText,
timestamp,
source: 'enhance',
enhancementMode: mode,
});
return newHistory;
});
}}
/>
</div>
{/* AI & Execution Section */}
@@ -538,50 +430,54 @@ export function AddFeatureDialog({
<span>AI & Execution</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Profile</Label>
<ProfileTypeahead
profiles={aiProfiles}
selectedProfileId={selectedProfileId}
onSelect={handleProfileSelect}
placeholder="Select profile..."
showManageLink
onManageLinkClick={() => {
onOpenChange(false);
navigate({ to: '/profiles' });
}}
testIdPrefix="add-feature-profile"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Model</Label>
<PhaseModelSelector
value={modelEntry}
onChange={handleModelChange}
compact
align="end"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Model</Label>
<PhaseModelSelector
value={modelEntry}
onChange={handleModelChange}
compact
align="end"
/>
</div>
<div
className={cn(
'grid gap-3',
modelSupportsPlanningMode ? 'grid-cols-2' : 'grid-cols-1'
)}
>
{modelSupportsPlanningMode && (
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Planning</Label>
<div className="grid gap-3 grid-cols-2">
<div className="space-y-1.5">
<Label
className={cn(
'text-xs text-muted-foreground',
!modelSupportsPlanningMode && 'opacity-50'
)}
>
Planning
</Label>
{modelSupportsPlanningMode ? (
<PlanningModeSelect
mode={planningMode}
onModeChange={setPlanningMode}
testIdPrefix="add-feature-planning"
compact
/>
</div>
)}
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<PlanningModeSelect
mode="skip"
onModeChange={() => {}}
testIdPrefix="add-feature-planning"
compact
disabled
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Planning modes are only available for Claude Provider</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Options</Label>
<div className="flex flex-col gap-2 pt-1">
@@ -599,28 +495,32 @@ export function AddFeatureDialog({
Run tests
</Label>
</div>
{modelSupportsPlanningMode && (
<div className="flex items-center gap-2">
<Checkbox
id="add-feature-require-approval"
checked={requirePlanApproval}
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
disabled={planningMode === 'skip' || planningMode === 'lite'}
data-testid="add-feature-require-approval-checkbox"
/>
<Label
htmlFor="add-feature-require-approval"
className={cn(
'text-xs font-normal',
planningMode === 'skip' || planningMode === 'lite'
? 'cursor-not-allowed text-muted-foreground'
: 'cursor-pointer'
)}
>
Require approval
</Label>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id="add-feature-require-approval"
checked={requirePlanApproval}
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
disabled={
!modelSupportsPlanningMode ||
planningMode === 'skip' ||
planningMode === 'lite'
}
data-testid="add-feature-require-approval-checkbox"
/>
<Label
htmlFor="add-feature-require-approval"
className={cn(
'text-xs font-normal',
!modelSupportsPlanningMode ||
planningMode === 'skip' ||
planningMode === 'lite'
? 'cursor-not-allowed text-muted-foreground'
: 'cursor-pointer'
)}
>
Require approval
</Label>
</div>
</div>
</div>
</div>

View File

@@ -21,53 +21,25 @@ import {
FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
Sparkles,
ChevronDown,
ChevronRight,
GitBranch,
History,
Cpu,
FolderKanban,
} from 'lucide-react';
import { GitBranch, Cpu, FolderKanban } from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { cn, modelSupportsThinking } from '@/lib/utils';
import {
Feature,
ModelAlias,
ThinkingLevel,
AIProfile,
useAppStore,
PlanningMode,
} from '@/store/app-store';
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
import {
TestingTabContent,
PrioritySelector,
WorkModeSelector,
PlanningModeSelect,
ProfileTypeahead,
EnhanceWithAI,
EnhancementHistoryButton,
type EnhancementMode,
} from '../shared';
import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { DependencyTreeDialog } from './dependency-tree-dialog';
import {
isCursorModel,
isClaudeModel,
PROVIDER_PREFIXES,
supportsReasoningEffort,
} from '@automaker/types';
import { useNavigate } from '@tanstack/react-router';
import { isClaudeModel, supportsReasoningEffort } from '@automaker/types';
const logger = createLogger('EditFeatureDialog');
@@ -92,15 +64,14 @@ interface EditFeatureDialogProps {
requirePlanApproval: boolean;
},
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
enhancementMode?: EnhancementMode,
preEnhancementDescription?: string
) => void;
categorySuggestions: string[];
branchSuggestions: string[];
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
currentBranch?: string;
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
allFeatures: Feature[];
}
@@ -113,11 +84,8 @@ export function EditFeatureDialog({
branchCardCounts,
currentBranch,
isMaximized,
showProfilesOnly,
aiProfiles,
allFeatures,
}: EditFeatureDialogProps) {
const navigate = useNavigate();
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
// Derive initial workMode from feature's branchName
const [workMode, setWorkMode] = useState<WorkMode>(() => {
@@ -128,11 +96,6 @@ export function EditFeatureDialog({
const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>(
() => new Map()
);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<
'improve' | 'technical' | 'simplify' | 'acceptance'
>('improve');
const [enhanceOpen, setEnhanceOpen] = useState(false);
const [showDependencyTree, setShowDependencyTree] = useState(false);
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(
@@ -140,7 +103,6 @@ export function EditFeatureDialog({
);
// Model selection state
const [selectedProfileId, setSelectedProfileId] = useState<string | undefined>();
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>(() => ({
model: (feature?.model as ModelAlias) || 'opus',
thinkingLevel: feature?.thinkingLevel || 'none',
@@ -152,15 +114,16 @@ export function EditFeatureDialog({
// Track the source of description changes for history
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
{ source: 'enhance'; mode: 'improve' | 'technical' | 'simplify' | 'acceptance' } | 'edit' | null
{ source: 'enhance'; mode: EnhancementMode } | 'edit' | null
>(null);
// Track the original description when the dialog opened for comparison
const [originalDescription, setOriginalDescription] = useState(feature?.description ?? '');
// Track if history dropdown is open
const [showHistory, setShowHistory] = useState(false);
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
// Track the description before enhancement (so it can be restored)
const [preEnhancementDescription, setPreEnhancementDescription] = useState<string | null>(null);
// Local history state for real-time display (combines persisted + session history)
const [localHistory, setLocalHistory] = useState<DescriptionHistoryEntry[]>(
feature?.descriptionHistory ?? []
);
useEffect(() => {
setEditingFeature(feature);
@@ -172,51 +135,24 @@ export function EditFeatureDialog({
// Reset history tracking state
setOriginalDescription(feature.description ?? '');
setDescriptionChangeSource(null);
setShowHistory(false);
setEnhanceOpen(false);
setPreEnhancementDescription(null);
setLocalHistory(feature.descriptionHistory ?? []);
// Reset model entry
setModelEntry({
model: (feature.model as ModelAlias) || 'opus',
thinkingLevel: feature.thinkingLevel || 'none',
reasoningEffort: feature.reasoningEffort || 'none',
});
setSelectedProfileId(undefined);
} else {
setEditFeaturePreviewMap(new Map());
setDescriptionChangeSource(null);
setShowHistory(false);
setPreEnhancementDescription(null);
setLocalHistory([]);
}
}, [feature]);
const applyProfileToModel = (profile: AIProfile) => {
if (profile.provider === 'cursor') {
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
setModelEntry({ model: cursorModel as ModelAlias });
} else if (profile.provider === 'codex') {
setModelEntry({
model: profile.codexModel || 'codex-gpt-5.2-codex',
reasoningEffort: 'none',
});
} else if (profile.provider === 'opencode') {
setModelEntry({ model: profile.opencodeModel || 'opencode/big-pickle' });
} else {
// Claude
setModelEntry({
model: profile.model || 'sonnet',
thinkingLevel: profile.thinkingLevel || 'none',
});
}
};
const handleProfileSelect = (profile: AIProfile) => {
setSelectedProfileId(profile.id);
applyProfileToModel(profile);
};
const handleModelChange = (entry: PhaseModelEntry) => {
setModelEntry(entry);
// Clear profile selection when manually changing model
setSelectedProfileId(undefined);
};
const handleUpdate = () => {
@@ -273,7 +209,13 @@ export function EditFeatureDialog({
}
}
onUpdate(editingFeature.id, updates, historySource, historyEnhancementMode);
onUpdate(
editingFeature.id,
updates,
historySource,
historyEnhancementMode,
preEnhancementDescription ?? undefined
);
setEditFeaturePreviewMap(new Map());
onClose();
};
@@ -284,36 +226,6 @@ export function EditFeatureDialog({
}
};
const handleEnhanceDescription = async () => {
if (!editingFeature?.description.trim() || isEnhancing) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
editingFeature.description,
enhancementMode,
enhancementOverride.effectiveModel, // API accepts string, extract from PhaseModelEntry
enhancementOverride.effectiveModelEntry.thinkingLevel // Pass thinking level
);
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setEditingFeature((prev) => (prev ? { ...prev, description: enhancedText } : prev));
// Track that this change was from enhancement
setDescriptionChangeSource({ source: 'enhance', mode: enhancementMode });
toast.success('Description enhanced!');
} else {
toast.error(result?.error || 'Failed to enhance description');
}
} catch (error) {
logger.error('Enhancement failed:', error);
toast.error('Failed to enhance description');
} finally {
setIsEnhancing(false);
}
};
if (!editingFeature) {
return null;
}
@@ -351,85 +263,18 @@ export function EditFeatureDialog({
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="edit-description">Description</Label>
{/* Version History Button */}
{feature?.descriptionHistory && feature.descriptionHistory.length > 0 && (
<Popover open={showHistory} onOpenChange={setShowHistory}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-muted-foreground"
>
<History className="w-3.5 h-3.5" />
History ({feature.descriptionHistory.length})
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="p-3 border-b">
<h4 className="font-medium text-sm">Version History</h4>
<p className="text-xs text-muted-foreground mt-1">
Click a version to restore it
</p>
</div>
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{[...(feature.descriptionHistory || [])]
.reverse()
.map((entry: DescriptionHistoryEntry, index: number) => {
const isCurrentVersion =
entry.description === editingFeature.description;
const date = new Date(entry.timestamp);
const formattedDate = date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const sourceLabel =
entry.source === 'initial'
? 'Original'
: entry.source === 'enhance'
? `Enhanced (${entry.enhancementMode || 'improve'})`
: 'Edited';
return (
<button
key={`${entry.timestamp}-${index}`}
onClick={() => {
setEditingFeature((prev) =>
prev ? { ...prev, description: entry.description } : prev
);
// Mark as edit since user is restoring from history
setDescriptionChangeSource('edit');
setShowHistory(false);
toast.success('Description restored from history');
}}
className={`w-full text-left p-2 rounded-md hover:bg-muted transition-colors ${
isCurrentVersion ? 'bg-muted/50 border border-primary/20' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{sourceLabel}</span>
<span className="text-xs text-muted-foreground">
{formattedDate}
</span>
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{entry.description.slice(0, 100)}
{entry.description.length > 100 ? '...' : ''}
</p>
{isCurrentVersion && (
<span className="text-xs text-primary font-medium mt-1 block">
Current version
</span>
)}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
)}
{/* Version History Button - uses local history for real-time updates */}
<EnhancementHistoryButton
history={localHistory}
currentValue={editingFeature.description}
onRestore={(description) => {
setEditingFeature((prev) => (prev ? { ...prev, description } : prev));
setDescriptionChangeSource('edit');
}}
valueAccessor={(entry) => entry.description}
title="Version History"
restoreMessage="Description restored from history"
/>
</div>
<DescriptionImageDropZone
value={editingFeature.description}
@@ -480,71 +325,40 @@ export function EditFeatureDialog({
/>
</div>
{/* Collapsible Enhancement Section */}
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}>
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1">
{enhanceOpen ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
<Sparkles className="w-4 h-4" />
<span>Enhance with AI</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="flex flex-wrap items-center gap-2 pl-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-xs">
{enhancementMode === 'improve' && 'Improve Clarity'}
{enhancementMode === 'technical' && 'Add Technical Details'}
{enhancementMode === 'simplify' && 'Simplify'}
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Enhancement Section */}
<EnhanceWithAI
value={editingFeature.description}
onChange={(enhanced) =>
setEditingFeature((prev) => (prev ? { ...prev, description: enhanced } : prev))
}
onHistoryAdd={({ mode, originalText, enhancedText }) => {
setDescriptionChangeSource({ source: 'enhance', mode });
setPreEnhancementDescription(originalText);
<Button
type="button"
variant="default"
size="sm"
className="h-8 text-xs"
onClick={handleEnhanceDescription}
disabled={!editingFeature.description.trim() || isEnhancing}
loading={isEnhancing}
>
<Sparkles className="w-3 h-3 mr-1" />
Enhance
</Button>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
</CollapsibleContent>
</Collapsible>
// Update local history for real-time display
const timestamp = new Date().toISOString();
setLocalHistory((prev) => {
const newHistory = [...prev];
// Add original text first (so user can restore to pre-enhancement state)
const lastEntry = prev[prev.length - 1];
if (!lastEntry || lastEntry.description !== originalText) {
newHistory.push({
description: originalText,
timestamp,
source: prev.length === 0 ? 'initial' : 'edit',
});
}
// Add enhanced text
newHistory.push({
description: enhancedText,
timestamp,
source: 'enhance',
enhancementMode: mode,
});
return newHistory;
});
}}
/>
</div>
{/* AI & Execution Section */}
@@ -554,50 +368,54 @@ export function EditFeatureDialog({
<span>AI & Execution</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Profile</Label>
<ProfileTypeahead
profiles={aiProfiles}
selectedProfileId={selectedProfileId}
onSelect={handleProfileSelect}
placeholder="Select profile..."
showManageLink
onManageLinkClick={() => {
onClose();
navigate({ to: '/profiles' });
}}
testIdPrefix="edit-feature-profile"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Model</Label>
<PhaseModelSelector
value={modelEntry}
onChange={handleModelChange}
compact
align="end"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Model</Label>
<PhaseModelSelector
value={modelEntry}
onChange={handleModelChange}
compact
align="end"
/>
</div>
<div
className={cn(
'grid gap-3',
modelSupportsPlanningMode ? 'grid-cols-2' : 'grid-cols-1'
)}
>
{modelSupportsPlanningMode && (
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Planning</Label>
<div className="grid gap-3 grid-cols-2">
<div className="space-y-1.5">
<Label
className={cn(
'text-xs text-muted-foreground',
!modelSupportsPlanningMode && 'opacity-50'
)}
>
Planning
</Label>
{modelSupportsPlanningMode ? (
<PlanningModeSelect
mode={planningMode}
onModeChange={setPlanningMode}
testIdPrefix="edit-feature-planning"
compact
/>
</div>
)}
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<PlanningModeSelect
mode="skip"
onModeChange={() => {}}
testIdPrefix="edit-feature-planning"
compact
disabled
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Planning modes are only available for Claude Provider</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Options</Label>
<div className="flex flex-col gap-2 pt-1">
@@ -617,28 +435,32 @@ export function EditFeatureDialog({
Run tests
</Label>
</div>
{modelSupportsPlanningMode && (
<div className="flex items-center gap-2">
<Checkbox
id="edit-feature-require-approval"
checked={requirePlanApproval}
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
disabled={planningMode === 'skip' || planningMode === 'lite'}
data-testid="edit-feature-require-approval-checkbox"
/>
<Label
htmlFor="edit-feature-require-approval"
className={cn(
'text-xs font-normal',
planningMode === 'skip' || planningMode === 'lite'
? 'cursor-not-allowed text-muted-foreground'
: 'cursor-pointer'
)}
>
Require approval
</Label>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id="edit-feature-require-approval"
checked={requirePlanApproval}
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
disabled={
!modelSupportsPlanningMode ||
planningMode === 'skip' ||
planningMode === 'lite'
}
data-testid="edit-feature-require-approval-checkbox"
/>
<Label
htmlFor="edit-feature-require-approval"
className={cn(
'text-xs font-normal',
!modelSupportsPlanningMode ||
planningMode === 'skip' ||
planningMode === 'lite'
? 'cursor-not-allowed text-muted-foreground'
: 'cursor-pointer'
)}
>
Require approval
</Label>
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
@@ -17,6 +18,21 @@ import {
} from '@/components/ui/description-image-dropzone';
import { MessageSquare } from 'lucide-react';
import { Feature } from '@/store/app-store';
import {
EnhanceWithAI,
EnhancementHistoryButton,
type EnhancementMode,
type BaseHistoryEntry,
} from '../shared';
const logger = createLogger('FollowUpDialog');
/**
* A single entry in the follow-up prompt history
*/
export interface FollowUpHistoryEntry extends BaseHistoryEntry {
prompt: string;
}
interface FollowUpDialogProps {
open: boolean;
@@ -30,6 +46,10 @@ interface FollowUpDialogProps {
onPreviewMapChange: (map: ImagePreviewMap) => void;
onSend: () => void;
isMaximized: boolean;
/** History of prompt versions for restoration */
promptHistory?: FollowUpHistoryEntry[];
/** Callback to add a new entry to prompt history */
onHistoryAdd?: (entry: FollowUpHistoryEntry) => void;
}
export function FollowUpDialog({
@@ -44,9 +64,11 @@ export function FollowUpDialog({
onPreviewMapChange,
onSend,
isMaximized,
promptHistory = [],
onHistoryAdd,
}: FollowUpDialogProps) {
const handleClose = (open: boolean) => {
if (!open) {
const handleClose = (openState: boolean) => {
if (!openState) {
onOpenChange(false);
}
};
@@ -77,7 +99,18 @@ export function FollowUpDialog({
</DialogHeader>
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
<div className="space-y-2">
<Label htmlFor="follow-up-prompt">Instructions</Label>
<div className="flex items-center justify-between">
<Label htmlFor="follow-up-prompt">Instructions</Label>
{/* Version History Button */}
<EnhancementHistoryButton
history={promptHistory}
currentValue={prompt}
onRestore={onPromptChange}
valueAccessor={(entry) => entry.prompt}
title="Prompt History"
restoreMessage="Prompt restored from history"
/>
</div>
<DescriptionImageDropZone
value={prompt}
onChange={onPromptChange}
@@ -88,6 +121,33 @@ export function FollowUpDialog({
onPreviewMapChange={onPreviewMapChange}
/>
</div>
{/* Enhancement Section */}
<EnhanceWithAI
value={prompt}
onChange={onPromptChange}
onHistoryAdd={({ mode, originalText, enhancedText }) => {
const timestamp = new Date().toISOString();
// Add original text first (so user can restore to pre-enhancement state)
// Only add if it's different from the last history entry
const lastEntry = promptHistory[promptHistory.length - 1];
if (!lastEntry || lastEntry.prompt !== originalText) {
onHistoryAdd?.({
prompt: originalText,
timestamp,
source: promptHistory.length === 0 ? 'initial' : 'edit',
});
}
// Add enhanced text
onHistoryAdd?.({
prompt: enhancedText,
timestamp,
source: 'enhance',
enhancementMode: mode,
});
}}
/>
<p className="text-xs text-muted-foreground">
The agent will continue from where it left off, using the existing context. You can
attach screenshots to help explain the issue.

View File

@@ -5,6 +5,6 @@ export { CompletedFeaturesModal } from './completed-features-modal';
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
export { EditFeatureDialog } from './edit-feature-dialog';
export { FollowUpDialog } from './follow-up-dialog';
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
export { PlanApprovalDialog } from './plan-approval-dialog';
export { MassEditDialog } from './mass-edit-dialog';

View File

@@ -12,19 +12,18 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { AlertCircle } from 'lucide-react';
import { modelSupportsThinking } from '@/lib/utils';
import { Feature, ModelAlias, ThinkingLevel, AIProfile, PlanningMode } from '@/store/app-store';
import { ProfileSelect, TestingTabContent, PrioritySelect, PlanningModeSelect } from '../shared';
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
import { TestingTabContent, PrioritySelect, PlanningModeSelect } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { isCursorModel, PROVIDER_PREFIXES, type PhaseModelEntry } from '@automaker/types';
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface MassEditDialogProps {
open: boolean;
onClose: () => void;
selectedFeatures: Feature[];
onApply: (updates: Partial<Feature>) => Promise<void>;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
}
interface ApplyState {
@@ -98,14 +97,7 @@ function FieldWrapper({ label, isMixed, willApply, onApplyChange, children }: Fi
);
}
export function MassEditDialog({
open,
onClose,
selectedFeatures,
onApply,
showProfilesOnly,
aiProfiles,
}: MassEditDialogProps) {
export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: MassEditDialogProps) {
const [isApplying, setIsApplying] = useState(false);
// Track which fields to apply
@@ -149,26 +141,6 @@ export function MassEditDialog({
}
}, [open, selectedFeatures]);
const handleModelSelect = (newModel: string) => {
const isCursor = isCursorModel(newModel);
setModel(newModel as ModelAlias);
if (isCursor || !modelSupportsThinking(newModel)) {
setThinkingLevel('none');
}
};
const handleProfileSelect = (profile: AIProfile) => {
if (profile.provider === 'cursor') {
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
setModel(cursorModel as ModelAlias);
setThinkingLevel('none');
} else {
setModel((profile.model || 'sonnet') as ModelAlias);
setThinkingLevel(profile.thinkingLevel || 'none');
}
setApplyState((prev) => ({ ...prev, model: true, thinkingLevel: true }));
};
const handleApply = async () => {
const updates: Partial<Feature> = {};
@@ -196,6 +168,7 @@ export function MassEditDialog({
const hasAnyApply = Object.values(applyState).some(Boolean);
const isCurrentModelCursor = isCursorModel(model);
const modelAllowsThinking = !isCurrentModelCursor && modelSupportsThinking(model);
const modelSupportsPlanningMode = isClaudeModel(model);
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
@@ -208,29 +181,11 @@ export function MassEditDialog({
</DialogHeader>
<div className="py-4 pr-4 space-y-4 max-h-[60vh] overflow-y-auto">
{/* Quick Select Profile Section */}
{aiProfiles.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Quick Select Profile</Label>
<p className="text-xs text-muted-foreground mb-2">
Selecting a profile will automatically enable model settings
</p>
<ProfileSelect
profiles={aiProfiles}
selectedModel={model}
selectedThinkingLevel={thinkingLevel}
selectedCursorModel={isCurrentModelCursor ? model : undefined}
onSelect={handleProfileSelect}
testIdPrefix="mass-edit-profile"
/>
</div>
)}
{/* Model Selector */}
<div className="space-y-2">
<Label className="text-sm font-medium">AI Model</Label>
<p className="text-xs text-muted-foreground mb-2">
Or select a specific model configuration
Select a specific model configuration
</p>
<PhaseModelSelector
value={{ model, thinkingLevel }}
@@ -252,30 +207,64 @@ export function MassEditDialog({
<div className="border-t border-border" />
{/* Planning Mode */}
<FieldWrapper
label="Planning Mode"
isMixed={mixedValues.planningMode || mixedValues.requirePlanApproval}
willApply={applyState.planningMode || applyState.requirePlanApproval}
onApplyChange={(apply) =>
setApplyState((prev) => ({
...prev,
planningMode: apply,
requirePlanApproval: apply,
}))
}
>
<PlanningModeSelect
mode={planningMode}
onModeChange={(newMode) => {
setPlanningMode(newMode);
// Auto-suggest approval based on mode, but user can override
setRequirePlanApproval(newMode === 'spec' || newMode === 'full');
}}
requireApproval={requirePlanApproval}
onRequireApprovalChange={setRequirePlanApproval}
testIdPrefix="mass-edit-planning"
/>
</FieldWrapper>
{modelSupportsPlanningMode ? (
<FieldWrapper
label="Planning Mode"
isMixed={mixedValues.planningMode || mixedValues.requirePlanApproval}
willApply={applyState.planningMode || applyState.requirePlanApproval}
onApplyChange={(apply) =>
setApplyState((prev) => ({
...prev,
planningMode: apply,
requirePlanApproval: apply,
}))
}
>
<PlanningModeSelect
mode={planningMode}
onModeChange={(newMode) => {
setPlanningMode(newMode);
// Auto-suggest approval based on mode, but user can override
setRequirePlanApproval(newMode === 'spec' || newMode === 'full');
}}
requireApproval={requirePlanApproval}
onRequireApprovalChange={setRequirePlanApproval}
testIdPrefix="mass-edit-planning"
/>
</FieldWrapper>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'p-3 rounded-lg border transition-colors border-border bg-muted/20 opacity-50 cursor-not-allowed'
)}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Checkbox checked={false} disabled className="opacity-50" />
<Label className="text-sm font-medium text-muted-foreground">
Planning Mode
</Label>
</div>
</div>
<div className="opacity-50 pointer-events-none">
<PlanningModeSelect
mode="skip"
onModeChange={() => {}}
testIdPrefix="mass-edit-planning"
disabled
/>
</div>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Planning modes are only available for Claude Provider</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Priority */}
<FieldWrapper

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -8,223 +8,11 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Trash2, ChevronUp, ChevronDown, Upload, Pencil, X, FileText } from 'lucide-react';
import { Plus, Trash2, ChevronUp, ChevronDown, Pencil } from 'lucide-react';
import { toast } from 'sonner';
import type { PipelineConfig, PipelineStep } from '@automaker/types';
import { cn } from '@/lib/utils';
// Color options for pipeline columns
const COLOR_OPTIONS = [
{ value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' },
{ value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' },
{ value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' },
{ value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' },
{ value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' },
{ value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' },
{ value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' },
{ value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' },
{ value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' },
];
// Pre-built step templates with well-designed prompts
const STEP_TEMPLATES = [
{
id: 'code-review',
name: 'Code Review',
colorClass: 'bg-blue-500/20',
instructions: `## Code Review
Please perform a thorough code review of the changes made in this feature. Focus on:
### Code Quality
- **Readability**: Is the code easy to understand? Are variable/function names descriptive?
- **Maintainability**: Will this code be easy to modify in the future?
- **DRY Principle**: Is there any duplicated code that should be abstracted?
- **Single Responsibility**: Do functions and classes have a single, clear purpose?
### Best Practices
- Follow established patterns and conventions used in the codebase
- Ensure proper error handling is in place
- Check for appropriate logging where needed
- Verify that magic numbers/strings are replaced with named constants
### Performance
- Identify any potential performance bottlenecks
- Check for unnecessary re-renders (React) or redundant computations
- Ensure efficient data structures are used
### Testing
- Verify that new code has appropriate test coverage
- Check that edge cases are handled
### Action Required
After reviewing, make any necessary improvements directly. If you find issues:
1. Fix them immediately if they are straightforward
2. For complex issues, document them clearly with suggested solutions
Provide a brief summary of changes made or issues found.`,
},
{
id: 'security-review',
name: 'Security Review',
colorClass: 'bg-red-500/20',
instructions: `## Security Review
Perform a comprehensive security audit of the changes made in this feature. Check for vulnerabilities in the following areas:
### Input Validation & Sanitization
- Verify all user inputs are properly validated and sanitized
- Check for SQL injection vulnerabilities
- Check for XSS (Cross-Site Scripting) vulnerabilities
- Ensure proper encoding of output data
### Authentication & Authorization
- Verify authentication checks are in place where needed
- Ensure authorization logic correctly restricts access
- Check for privilege escalation vulnerabilities
- Verify session management is secure
### Data Protection
- Ensure sensitive data is not logged or exposed
- Check that secrets/credentials are not hardcoded
- Verify proper encryption is used for sensitive data
- Check for secure transmission of data (HTTPS, etc.)
### Common Vulnerabilities (OWASP Top 10)
- Injection flaws
- Broken authentication
- Sensitive data exposure
- XML External Entities (XXE)
- Broken access control
- Security misconfiguration
- Cross-Site Scripting (XSS)
- Insecure deserialization
- Using components with known vulnerabilities
- Insufficient logging & monitoring
### Action Required
1. Fix any security vulnerabilities immediately
2. For complex security issues, document them with severity levels
3. Add security-related comments where appropriate
Provide a security assessment summary with any issues found and fixes applied.`,
},
{
id: 'testing',
name: 'Testing',
colorClass: 'bg-green-500/20',
instructions: `## Testing Step
Please ensure comprehensive test coverage for the changes made in this feature.
### Unit Tests
- Write unit tests for all new functions and methods
- Ensure edge cases are covered
- Test error handling paths
- Aim for high code coverage on new code
### Integration Tests
- Test interactions between components/modules
- Verify API endpoints work correctly
- Test database operations if applicable
### Test Quality
- Tests should be readable and well-documented
- Each test should have a clear purpose
- Use descriptive test names that explain the scenario
- Follow the Arrange-Act-Assert pattern
### Run Tests
After writing tests, run the full test suite and ensure:
1. All new tests pass
2. No existing tests are broken
3. Test coverage meets project standards
Provide a summary of tests added and any issues found during testing.`,
},
{
id: 'documentation',
name: 'Documentation',
colorClass: 'bg-amber-500/20',
instructions: `## Documentation Step
Please ensure all changes are properly documented.
### Code Documentation
- Add/update JSDoc or docstrings for new functions and classes
- Document complex algorithms or business logic
- Add inline comments for non-obvious code
### API Documentation
- Document any new or modified API endpoints
- Include request/response examples
- Document error responses
### README Updates
- Update README if new setup steps are required
- Document any new environment variables
- Update architecture diagrams if applicable
### Changelog
- Document notable changes for the changelog
- Include breaking changes if any
Provide a summary of documentation added or updated.`,
},
{
id: 'optimization',
name: 'Performance Optimization',
colorClass: 'bg-cyan-500/20',
instructions: `## Performance Optimization Step
Review and optimize the performance of the changes made in this feature.
### Code Performance
- Identify and optimize slow algorithms (O(n²) → O(n log n), etc.)
- Remove unnecessary computations or redundant operations
- Optimize loops and iterations
- Use appropriate data structures
### Memory Usage
- Check for memory leaks
- Optimize memory-intensive operations
- Ensure proper cleanup of resources
### Database/API
- Optimize database queries (add indexes, reduce N+1 queries)
- Implement caching where appropriate
- Batch API calls when possible
### Frontend (if applicable)
- Minimize bundle size
- Optimize render performance
- Implement lazy loading where appropriate
- Use memoization for expensive computations
### Action Required
1. Profile the code to identify bottlenecks
2. Apply optimizations
3. Measure improvements
Provide a summary of optimizations applied and performance improvements achieved.`,
},
];
// Helper to get template color class
const getTemplateColorClass = (templateId: string): string => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
return template?.colorClass || COLOR_OPTIONS[0].value;
};
import { AddEditPipelineStepDialog } from './add-edit-pipeline-step-dialog';
interface PipelineSettingsDialogProps {
open: boolean;
@@ -234,18 +22,10 @@ interface PipelineSettingsDialogProps {
onSave: (config: PipelineConfig) => Promise<void>;
}
interface EditingStep {
id?: string;
name: string;
instructions: string;
colorClass: string;
order: number;
}
export function PipelineSettingsDialog({
open,
onClose,
projectPath,
projectPath: _projectPath,
pipelineConfig,
onSave,
}: PipelineSettingsDialogProps) {
@@ -262,9 +42,11 @@ export function PipelineSettingsDialog({
};
const [steps, setSteps] = useState<PipelineStep[]>(() => validateSteps(pipelineConfig?.steps));
const [editingStep, setEditingStep] = useState<EditingStep | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sub-dialog state
const [addEditDialogOpen, setAddEditDialogOpen] = useState(false);
const [editingStep, setEditingStep] = useState<PipelineStep | null>(null);
// Sync steps when dialog opens or pipelineConfig changes
useEffect(() => {
@@ -276,22 +58,13 @@ export function PipelineSettingsDialog({
const sortedSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const handleAddStep = () => {
setEditingStep({
name: '',
instructions: '',
colorClass: COLOR_OPTIONS[steps.length % COLOR_OPTIONS.length].value,
order: steps.length,
});
setEditingStep(null);
setAddEditDialogOpen(true);
};
const handleEditStep = (step: PipelineStep) => {
setEditingStep({
id: step.id,
name: step.name,
instructions: step.instructions,
colorClass: step.colorClass,
order: step.order,
});
setEditingStep(step);
setAddEditDialogOpen(true);
};
const handleDeleteStep = (stepId: string) => {
@@ -323,53 +96,21 @@ export function PipelineSettingsDialog({
setSteps(newSteps);
};
const handleFileUpload = () => {
fileInputRef.current?.click();
};
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const content = await file.text();
setEditingStep((prev) => (prev ? { ...prev, instructions: content } : null));
toast.success('Instructions loaded from file');
} catch (error) {
toast.error('Failed to load file');
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSaveStep = () => {
if (!editingStep) return;
if (!editingStep.name.trim()) {
toast.error('Step name is required');
return;
}
if (!editingStep.instructions.trim()) {
toast.error('Step instructions are required');
return;
}
const handleSaveStep = (
stepData: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }
) => {
const now = new Date().toISOString();
if (editingStep.id) {
if (stepData.id) {
// Update existing step
setSteps((prev) =>
prev.map((s) =>
s.id === editingStep.id
s.id === stepData.id
? {
...s,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
name: stepData.name,
instructions: stepData.instructions,
colorClass: stepData.colorClass,
updatedAt: now,
}
: s
@@ -379,90 +120,21 @@ export function PipelineSettingsDialog({
// Add new step
const newStep: PipelineStep = {
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
name: stepData.name,
instructions: stepData.instructions,
colorClass: stepData.colorClass,
order: steps.length,
createdAt: now,
updatedAt: now,
};
setSteps((prev) => [...prev, newStep]);
}
setEditingStep(null);
};
const handleSaveConfig = async () => {
setIsSubmitting(true);
try {
// If the user is currently editing a step and clicks "Save Configuration",
// include that step in the config (common expectation) instead of silently dropping it.
let effectiveSteps = steps;
if (editingStep) {
if (!editingStep.name.trim()) {
toast.error('Step name is required');
return;
}
if (!editingStep.instructions.trim()) {
toast.error('Step instructions are required');
return;
}
const now = new Date().toISOString();
if (editingStep.id) {
// Update existing (or add if missing for some reason)
const existingIdx = effectiveSteps.findIndex((s) => s.id === editingStep.id);
if (existingIdx >= 0) {
effectiveSteps = effectiveSteps.map((s) =>
s.id === editingStep.id
? {
...s,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
updatedAt: now,
}
: s
);
} else {
effectiveSteps = [
...effectiveSteps,
{
id: editingStep.id,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: effectiveSteps.length,
createdAt: now,
updatedAt: now,
},
];
}
} else {
// Add new step
effectiveSteps = [
...effectiveSteps,
{
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: effectiveSteps.length,
createdAt: now,
updatedAt: now,
},
];
}
// Keep local UI state consistent with what we are saving.
setSteps(effectiveSteps);
setEditingStep(null);
}
const sortedEffectiveSteps = [...effectiveSteps].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0)
);
const sortedEffectiveSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const config: PipelineConfig = {
version: 1,
steps: sortedEffectiveSteps.map((s, index) => ({ ...s, order: index })),
@@ -470,7 +142,7 @@ export function PipelineSettingsDialog({
await onSave(config);
toast.success('Pipeline configuration saved');
onClose();
} catch (error) {
} catch {
toast.error('Failed to save pipeline configuration');
} finally {
setIsSubmitting(false);
@@ -478,259 +150,121 @@ export function PipelineSettingsDialog({
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
{/* Hidden file input for loading instructions from .md files */}
<input
ref={fileInputRef}
type="file"
accept=".md,.txt"
className="hidden"
onChange={handleFileInputChange}
/>
<DialogHeader>
<DialogTitle>Pipeline Settings</DialogTitle>
<DialogDescription>
Configure custom pipeline steps that run after a feature completes "In Progress". Each
step will automatically prompt the agent with its instructions.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 space-y-4">
{/* Steps List */}
{sortedSteps.length > 0 ? (
<div className="space-y-2">
{sortedSteps.map((step, index) => (
<div
key={step.id}
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
>
<div className="flex flex-col gap-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'down')}
disabled={index === sortedSteps.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<>
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Pipeline Settings</DialogTitle>
<DialogDescription>
Configure custom pipeline steps that run after a feature completes "In Progress". Each
step will automatically prompt the agent with its instructions.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 space-y-4">
{/* Steps List */}
{sortedSteps.length > 0 ? (
<div className="space-y-2">
{sortedSteps.map((step, index) => (
<div
className={cn(
'w-3 h-8 rounded',
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
)}
/>
key={step.id}
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
>
<div className="flex flex-col gap-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'down')}
disabled={index === sortedSteps.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div>
<div className="text-xs text-muted-foreground truncate">
{(step.instructions || '').substring(0, 100)}
{(step.instructions || '').length > 100 ? '...' : ''}
<div
className={cn(
'w-3 h-8 rounded',
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
)}
/>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div>
<div className="text-xs text-muted-foreground truncate">
{(step.instructions || '').substring(0, 100)}
{(step.instructions || '').length > 100 ? '...' : ''}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditStep(step)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDeleteStep(step.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>No pipeline steps configured.</p>
<p className="text-sm">
Add steps to create a custom workflow after features complete.
</p>
</div>
)}
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditStep(step)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDeleteStep(step.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>No pipeline steps configured.</p>
<p className="text-sm">
Add steps to create a custom workflow after features complete.
</p>
</div>
)}
{/* Add Step Button */}
{!editingStep && (
{/* Add Step Button */}
<Button variant="outline" className="w-full" onClick={handleAddStep}>
<Plus className="h-4 w-4 mr-2" />
Add Pipeline Step
</Button>
)}
</div>
{/* Edit/Add Step Form */}
{editingStep && (
<div className="border rounded-lg p-4 space-y-4 bg-muted/20">
<div className="flex items-center justify-between">
<h4 className="font-medium">{editingStep.id ? 'Edit Step' : 'New Step'}</h4>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setEditingStep(null)}
>
<X className="h-4 w-4" />
</Button>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Pipeline'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Template Selector - only show for new steps */}
{!editingStep.id && (
<div className="space-y-2">
<Label>Start from Template</Label>
<Select
onValueChange={(templateId) => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
if (template) {
setEditingStep((prev) =>
prev
? {
...prev,
name: template.name,
instructions: template.instructions,
colorClass: template.colorClass,
}
: null
);
toast.success(`Loaded "${template.name}" template`);
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a template (optional)" />
</SelectTrigger>
<SelectContent>
{STEP_TEMPLATES.map((template) => (
<SelectItem key={template.id} value={template.id}>
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
template.colorClass.replace('/20', '')
)}
/>
{template.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Select a pre-built template to populate the form, or create your own from
scratch.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="step-name">Step Name</Label>
<Input
id="step-name"
placeholder="e.g., Code Review, Testing, Documentation"
value={editingStep.name}
onChange={(e) =>
setEditingStep((prev) => (prev ? { ...prev, name: e.target.value } : null))
}
/>
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex flex-wrap gap-2">
{COLOR_OPTIONS.map((color) => (
<button
key={color.value}
type="button"
className={cn(
'w-8 h-8 rounded-full transition-all',
color.preview,
editingStep.colorClass === color.value
? 'ring-2 ring-offset-2 ring-primary'
: 'opacity-60 hover:opacity-100'
)}
onClick={() =>
setEditingStep((prev) =>
prev ? { ...prev, colorClass: color.value } : null
)
}
title={color.label}
/>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="step-instructions">Agent Instructions</Label>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={handleFileUpload}
>
<Upload className="h-3 w-3 mr-1" />
Load from .md file
</Button>
</div>
<Textarea
id="step-instructions"
placeholder="Instructions for the agent to follow during this pipeline step..."
value={editingStep.instructions}
onChange={(e) =>
setEditingStep((prev) =>
prev ? { ...prev, instructions: e.target.value } : null
)
}
rows={6}
className="font-mono text-sm"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditingStep(null)}>
Cancel
</Button>
<Button onClick={handleSaveStep}>
{editingStep.id ? 'Update Step' : 'Add Step'}
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
{isSubmitting
? 'Saving...'
: editingStep
? 'Save Step & Configuration'
: 'Save Configuration'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Sub-dialog for adding/editing steps */}
<AddEditPipelineStepDialog
open={addEditDialogOpen}
onClose={() => {
setAddEditDialogOpen(false);
setEditingStep(null);
}}
onSave={handleSaveStep}
existingStep={editingStep}
defaultOrder={steps.length}
/>
</>
);
}

View File

@@ -0,0 +1,94 @@
export const codeReviewTemplate = {
id: 'code-review',
name: 'Code Review',
colorClass: 'bg-blue-500/20',
instructions: `## Code Review & Update
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE ⚠️
**THIS IS NOT OPTIONAL. AFTER REVIEWING, YOU MUST MODIFY THE CODE WITH YOUR FINDINGS.**
This step has TWO mandatory phases:
1. **REVIEW** the code (identify issues)
2. **UPDATE** the code (fix the issues you found)
**You cannot complete this step by only reviewing. You MUST make code changes based on your review findings.**
---
### Phase 1: Review Phase
Perform a thorough code review of the changes made in this feature. Focus on:
#### Code Quality
- **Readability**: Is the code easy to understand? Are variable/function names descriptive?
- **Maintainability**: Will this code be easy to modify in the future?
- **DRY Principle**: Is there any duplicated code that should be abstracted?
- **Single Responsibility**: Do functions and classes have a single, clear purpose?
#### Best Practices
- Follow established patterns and conventions used in the codebase
- Ensure proper error handling is in place
- Check for appropriate logging where needed
- Verify that magic numbers/strings are replaced with named constants
#### Performance
- Identify any potential performance bottlenecks
- Check for unnecessary re-renders (React) or redundant computations
- Ensure efficient data structures are used
#### Testing
- Verify that new code has appropriate test coverage
- Check that edge cases are handled
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW MODIFY THE CODE BASED ON YOUR REVIEW FINDINGS.**
**This is not optional. Every issue you identify must be addressed with code changes.**
#### Action Steps (You MUST complete these):
1. **Fix Issues Immediately**: For every issue you found during review:
- ✅ Refactor code for better readability
- ✅ Extract duplicated code into reusable functions
- ✅ Improve variable/function names for clarity
- ✅ Add missing error handling
- ✅ Replace magic numbers/strings with named constants
- ✅ Optimize performance bottlenecks
- ✅ Fix any code quality issues you identify
- ✅ **MAKE THE ACTUAL CODE CHANGES - DO NOT JUST DOCUMENT THEM**
2. **Apply All Improvements**: Don't just identify problems - fix them in code:
- ✅ Improve code structure and organization
- ✅ Enhance error handling and logging
- ✅ Optimize performance where possible
- ✅ Ensure consistency with codebase patterns
- ✅ Add or improve comments where needed
- ✅ **MODIFY THE FILES DIRECTLY WITH YOUR IMPROVEMENTS**
3. **For Complex Issues**: If you encounter issues that require significant refactoring:
- ✅ Make the improvements you can make safely
- ✅ Document remaining issues with clear explanations
- ✅ Provide specific suggestions for future improvements
- ✅ **STILL MAKE AS MANY CODE CHANGES AS POSSIBLE**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A summary of issues found during review
- **A detailed list of ALL code changes and improvements made (this proves you updated the code)**
- Any remaining issues that need attention (if applicable)
---
# ⚠️ FINAL REMINDER ⚠️
**Reviewing without updating is INCOMPLETE and UNACCEPTABLE.**
**You MUST modify the code files directly with your improvements.**
**You MUST show evidence of code changes in your summary.**
**This step is only complete when code has been updated.**`,
};

View File

@@ -0,0 +1,77 @@
export const documentationTemplate = {
id: 'documentation',
name: 'Documentation',
colorClass: 'bg-amber-500/20',
instructions: `## Documentation Step
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE WITH DOCUMENTATION ⚠️
**THIS IS NOT OPTIONAL. YOU MUST ADD/UPDATE DOCUMENTATION IN THE CODEBASE.**
This step requires you to:
1. **REVIEW** what needs documentation
2. **UPDATE** the code by adding/updating documentation files and code comments
**You cannot complete this step by only identifying what needs documentation. You MUST add the documentation directly to the codebase.**
---
### Phase 1: Review Phase
Identify what documentation is needed:
- Review new functions, classes, and modules
- Identify new or modified API endpoints
- Check for missing README updates
- Identify changelog entries needed
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW ADD/UPDATE DOCUMENTATION IN THE CODEBASE.**
**This is not optional. You must modify files to add documentation.**
#### Action Steps (You MUST complete these):
1. **Code Documentation** - UPDATE THE CODE FILES:
- ✅ Add/update JSDoc or docstrings for new functions and classes
- ✅ Document complex algorithms or business logic
- ✅ Add inline comments for non-obvious code
- ✅ **MODIFY THE SOURCE FILES DIRECTLY WITH DOCUMENTATION**
2. **API Documentation** - UPDATE API DOCUMENTATION FILES:
- ✅ Document any new or modified API endpoints
- ✅ Include request/response examples
- ✅ Document error responses
- ✅ **UPDATE THE API DOCUMENTATION FILES DIRECTLY**
3. **README Updates** - UPDATE THE README FILE:
- ✅ Update README if new setup steps are required
- ✅ Document any new environment variables
- ✅ Update architecture diagrams if applicable
- ✅ **MODIFY THE README FILE DIRECTLY**
4. **Changelog** - UPDATE THE CHANGELOG FILE:
- ✅ Document notable changes for the changelog
- ✅ Include breaking changes if any
- ✅ **UPDATE THE CHANGELOG FILE DIRECTLY**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A summary of documentation needs identified
- **A detailed list of ALL documentation files and code comments added/updated (this proves you updated the code)**
- Specific files modified with documentation
---
# ⚠️ FINAL REMINDER ⚠️
**Identifying documentation needs without adding documentation is INCOMPLETE and UNACCEPTABLE.**
**You MUST modify the code files directly to add documentation.**
**You MUST show evidence of documentation changes in your summary.**
**This step is only complete when documentation has been added to the codebase.**`,
};

View File

@@ -0,0 +1,28 @@
import { codeReviewTemplate } from './code-review';
import { securityReviewTemplate } from './security-review';
import { uxReviewTemplate } from './ux-review';
import { testingTemplate } from './testing';
import { documentationTemplate } from './documentation';
import { optimizationTemplate } from './optimization';
export interface PipelineStepTemplate {
id: string;
name: string;
colorClass: string;
instructions: string;
}
export const STEP_TEMPLATES: PipelineStepTemplate[] = [
codeReviewTemplate,
securityReviewTemplate,
uxReviewTemplate,
testingTemplate,
documentationTemplate,
optimizationTemplate,
];
// Helper to get template color class
export const getTemplateColorClass = (templateId: string): string => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
return template?.colorClass || 'bg-blue-500/20';
};

View File

@@ -0,0 +1,103 @@
export const optimizationTemplate = {
id: 'optimization',
name: 'Performance',
colorClass: 'bg-cyan-500/20',
instructions: `## Performance Optimization Step
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE WITH OPTIMIZATIONS ⚠️
**THIS IS NOT OPTIONAL. AFTER IDENTIFYING OPTIMIZATION OPPORTUNITIES, YOU MUST UPDATE THE CODE.**
This step has TWO mandatory phases:
1. **REVIEW** the code for performance issues (identify bottlenecks)
2. **UPDATE** the code with optimizations (fix the performance issues)
**You cannot complete this step by only identifying performance issues. You MUST modify the code to optimize it.**
---
### Phase 1: Review Phase
Identify performance bottlenecks and optimization opportunities:
#### Code Performance
- Identify slow algorithms (O(n²) → O(n log n), etc.)
- Find unnecessary computations or redundant operations
- Identify inefficient loops and iterations
- Check for inappropriate data structures
#### Memory Usage
- Check for memory leaks
- Identify memory-intensive operations
- Check for proper cleanup of resources
#### Database/API
- Identify slow database queries (N+1 queries, missing indexes)
- Find opportunities for caching
- Identify API calls that could be batched
#### Frontend (if applicable)
- Identify bundle size issues
- Find render performance problems
- Identify opportunities for lazy loading
- Find expensive computations that need memoization
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW MODIFY THE CODE TO APPLY OPTIMIZATIONS.**
**This is not optional. Every performance issue you identify must be addressed with code changes.**
#### Action Steps (You MUST complete these):
1. **Optimize Code Performance** - UPDATE THE CODE:
- ✅ Optimize slow algorithms (O(n²) → O(n log n), etc.)
- ✅ Remove unnecessary computations or redundant operations
- ✅ Optimize loops and iterations
- ✅ Use appropriate data structures
- ✅ **MODIFY THE SOURCE FILES DIRECTLY WITH OPTIMIZATIONS**
2. **Fix Memory Issues** - UPDATE THE CODE:
- ✅ Fix memory leaks
- ✅ Optimize memory-intensive operations
- ✅ Ensure proper cleanup of resources
- ✅ **MAKE THE ACTUAL CODE CHANGES**
3. **Optimize Database/API** - UPDATE THE CODE:
- ✅ Optimize database queries (add indexes, reduce N+1 queries)
- ✅ Implement caching where appropriate
- ✅ Batch API calls when possible
- ✅ **MODIFY THE DATABASE/API CODE DIRECTLY**
4. **Optimize Frontend** (if applicable) - UPDATE THE CODE:
- ✅ Minimize bundle size
- ✅ Optimize render performance
- ✅ Implement lazy loading where appropriate
- ✅ Use memoization for expensive computations
- ✅ **MODIFY THE FRONTEND CODE DIRECTLY**
5. **Profile and Measure**:
- ✅ Profile the code to verify bottlenecks are fixed
- ✅ Measure improvements achieved
- ✅ **DOCUMENT THE PERFORMANCE IMPROVEMENTS**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A summary of performance issues identified
- **A detailed list of ALL optimizations applied to the code (this proves you updated the code)**
- Performance improvements achieved (with metrics if possible)
- Any remaining optimization opportunities
---
# ⚠️ FINAL REMINDER ⚠️
**Identifying performance issues without optimizing the code is INCOMPLETE and UNACCEPTABLE.**
**You MUST modify the code files directly with optimizations.**
**You MUST show evidence of optimization changes in your summary.**
**This step is only complete when code has been optimized.**`,
};

View File

@@ -0,0 +1,114 @@
export const securityReviewTemplate = {
id: 'security-review',
name: 'Security Review',
colorClass: 'bg-red-500/20',
instructions: `## Security Review & Update
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE TO FIX SECURITY ISSUES ⚠️
**THIS IS NOT OPTIONAL. AFTER REVIEWING FOR SECURITY ISSUES, YOU MUST FIX THEM IN THE CODE.**
This step has TWO mandatory phases:
1. **REVIEW** the code for security vulnerabilities (identify issues)
2. **UPDATE** the code to fix vulnerabilities (secure the code)
**You cannot complete this step by only identifying security issues. You MUST modify the code to fix them.**
**Security vulnerabilities left unfixed are unacceptable. You must address them with code changes.**
---
### Phase 1: Review Phase
Perform a comprehensive security audit of the changes made in this feature. Check for vulnerabilities in the following areas:
#### Input Validation & Sanitization
- Verify all user inputs are properly validated and sanitized
- Check for SQL injection vulnerabilities
- Check for XSS (Cross-Site Scripting) vulnerabilities
- Ensure proper encoding of output data
#### Authentication & Authorization
- Verify authentication checks are in place where needed
- Ensure authorization logic correctly restricts access
- Check for privilege escalation vulnerabilities
- Verify session management is secure
#### Data Protection
- Ensure sensitive data is not logged or exposed
- Check that secrets/credentials are not hardcoded
- Verify proper encryption is used for sensitive data
- Check for secure transmission of data (HTTPS, etc.)
#### Common Vulnerabilities (OWASP Top 10)
- Injection flaws
- Broken authentication
- Sensitive data exposure
- XML External Entities (XXE)
- Broken access control
- Security misconfiguration
- Cross-Site Scripting (XSS)
- Insecure deserialization
- Using components with known vulnerabilities
- Insufficient logging & monitoring
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW MODIFY THE CODE TO FIX ALL SECURITY VULNERABILITIES.**
**This is not optional. Every security issue you identify must be fixed with code changes.**
**Security vulnerabilities cannot be left unfixed. You must address them immediately.**
#### Action Steps (You MUST complete these):
1. **Fix Vulnerabilities Immediately** - UPDATE THE CODE:
- ✅ Add input validation and sanitization where missing
- ✅ Fix SQL injection vulnerabilities by using parameterized queries
- ✅ Fix XSS vulnerabilities by properly encoding output
- ✅ Add authentication/authorization checks where needed
- ✅ Remove hardcoded secrets and credentials
- ✅ Implement proper encryption for sensitive data
- ✅ Fix broken access control
- ✅ Add security headers and configurations
- ✅ Fix any other security vulnerabilities you find
- ✅ **MODIFY THE SOURCE FILES DIRECTLY TO FIX SECURITY ISSUES**
2. **Apply Security Best Practices** - UPDATE THE CODE:
- ✅ Implement proper input validation on all user inputs
- ✅ Ensure all outputs are properly encoded
- ✅ Add authentication checks to protected routes/endpoints
- ✅ Implement proper authorization logic
- ✅ Remove or secure any exposed sensitive data
- ✅ Add security logging and monitoring
- ✅ Update dependencies with known vulnerabilities
- ✅ **MAKE THE ACTUAL CODE CHANGES - DO NOT JUST DOCUMENT THEM**
3. **For Complex Security Issues** - UPDATE THE CODE:
- ✅ Fix what you can fix safely
- ✅ Document critical security issues with severity levels
- ✅ Provide specific remediation steps for complex issues
- ✅ Add security-related comments explaining protections in place
- ✅ **STILL MAKE AS MANY SECURITY FIXES AS POSSIBLE**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A security assessment summary of vulnerabilities found
- **A detailed list of ALL security fixes applied to the code (this proves you updated the code)**
- Any remaining security concerns that need attention (if applicable)
- Severity levels for any unfixed issues
---
# ⚠️ FINAL REMINDER ⚠️
**Reviewing security without fixing vulnerabilities is INCOMPLETE, UNACCEPTABLE, and DANGEROUS.**
**You MUST modify the code files directly to fix security issues.**
**You MUST show evidence of security fixes in your summary.**
**This step is only complete when security vulnerabilities have been fixed in the code.**
**Security issues cannot be left as documentation - they must be fixed.**`,
};

View File

@@ -0,0 +1,81 @@
export const testingTemplate = {
id: 'testing',
name: 'Testing',
colorClass: 'bg-green-500/20',
instructions: `## Testing Step
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODEBASE WITH TESTS ⚠️
**THIS IS NOT OPTIONAL. YOU MUST WRITE AND ADD TESTS TO THE CODEBASE.**
This step requires you to:
1. **REVIEW** what needs testing
2. **UPDATE** the codebase by writing and adding test files
**You cannot complete this step by only identifying what needs testing. You MUST create test files and write tests.**
---
### Phase 1: Review Phase
Identify what needs test coverage:
- Review new functions, methods, and classes
- Identify new API endpoints
- Check for edge cases that need testing
- Identify integration points that need testing
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW WRITE AND ADD TESTS TO THE CODEBASE.**
**This is not optional. You must create test files and write actual test code.**
#### Action Steps (You MUST complete these):
1. **Write Unit Tests** - CREATE TEST FILES:
- ✅ Write unit tests for all new functions and methods
- ✅ Ensure edge cases are covered
- ✅ Test error handling paths
- ✅ Aim for high code coverage on new code
- ✅ **CREATE TEST FILES AND WRITE THE ACTUAL TEST CODE**
2. **Write Integration Tests** - CREATE TEST FILES:
- ✅ Test interactions between components/modules
- ✅ Verify API endpoints work correctly
- ✅ Test database operations if applicable
- ✅ **CREATE INTEGRATION TEST FILES AND WRITE THE ACTUAL TEST CODE**
3. **Ensure Test Quality** - WRITE QUALITY TESTS:
- ✅ Tests should be readable and well-documented
- ✅ Each test should have a clear purpose
- ✅ Use descriptive test names that explain the scenario
- ✅ Follow the Arrange-Act-Assert pattern
- ✅ **WRITE COMPLETE, FUNCTIONAL TESTS**
4. **Run Tests** - VERIFY TESTS WORK:
- ✅ Run the full test suite and ensure all new tests pass
- ✅ Verify no existing tests are broken
- ✅ Check that test coverage meets project standards
- ✅ **FIX ANY FAILING TESTS**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A summary of testing needs identified
- **A detailed list of ALL test files created and tests written (this proves you updated the codebase)**
- Test coverage metrics achieved
- Any issues found during testing and how they were resolved
---
# ⚠️ FINAL REMINDER ⚠️
**Identifying what needs testing without writing tests is INCOMPLETE and UNACCEPTABLE.**
**You MUST create test files and write actual test code.**
**You MUST show evidence of test files created in your summary.**
**This step is only complete when tests have been written and added to the codebase.**`,
};

View File

@@ -0,0 +1,116 @@
export const uxReviewTemplate = {
id: 'ux-reviewer',
name: 'User Experience',
colorClass: 'bg-purple-500/20',
instructions: `## User Experience Review & Update
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE TO IMPROVE UX ⚠️
**THIS IS NOT OPTIONAL. AFTER REVIEWING THE USER EXPERIENCE, YOU MUST UPDATE THE CODE.**
This step has TWO mandatory phases:
1. **REVIEW** the user experience (identify UX issues)
2. **UPDATE** the code to improve UX (fix the issues you found)
**You cannot complete this step by only reviewing UX. You MUST modify the code to improve the user experience.**
---
### Phase 1: Review Phase
Review the changes made in this feature from a user experience and design perspective. Focus on creating an exceptional user experience.
#### User-Centered Design
- **User Goals**: Does this feature solve a real user problem?
- **Clarity**: Is the interface clear and easy to understand?
- **Simplicity**: Can the feature be simplified without losing functionality?
- **Consistency**: Does it follow existing design patterns and conventions?
#### Visual Design & Hierarchy
- **Layout**: Is the visual hierarchy clear? Does important information stand out?
- **Spacing**: Is there appropriate whitespace and grouping?
- **Typography**: Is text readable with proper sizing and contrast?
- **Color**: Does color usage support functionality and meet accessibility standards?
#### Accessibility (WCAG 2.1)
- **Keyboard Navigation**: Can all functionality be accessed via keyboard?
- **Screen Readers**: Are ARIA labels and semantic HTML used appropriately?
- **Color Contrast**: Does text meet WCAG AA standards (4.5:1 for body, 3:1 for large)?
- **Focus Indicators**: Are focus states visible and clear?
- **Touch Targets**: Are interactive elements at least 44x44px on mobile?
#### Responsive Design
- **Mobile Experience**: Does it work well on small screens?
- **Touch Targets**: Are buttons and links easy to tap?
- **Content Adaptation**: Does content adapt appropriately to different screen sizes?
- **Navigation**: Is navigation accessible and intuitive on mobile?
#### User Feedback & States
- **Loading States**: Are loading indicators shown for async operations?
- **Error States**: Are error messages clear and actionable?
- **Empty States**: Do empty states guide users on what to do next?
- **Success States**: Are successful actions clearly confirmed?
#### Performance & Perceived Performance
- **Loading Speed**: Does the feature load quickly?
- **Skeleton Screens**: Are skeleton screens used for better perceived performance?
- **Optimistic Updates**: Can optimistic UI updates improve perceived speed?
- **Micro-interactions**: Do animations and transitions enhance the experience?
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW MODIFY THE CODE TO IMPROVE THE USER EXPERIENCE.**
**This is not optional. Every UX issue you identify must be addressed with code changes.**
#### Action Steps (You MUST complete these):
1. **Fix UX Issues Immediately** - UPDATE THE CODE:
- ✅ Improve visual hierarchy and layout
- ✅ Fix spacing and typography issues
- ✅ Add missing ARIA labels and semantic HTML
- ✅ Fix color contrast issues
- ✅ Add or improve focus indicators
- ✅ Ensure touch targets meet size requirements
- ✅ Add missing loading, error, empty, and success states
- ✅ Improve responsive design for mobile
- ✅ Add keyboard navigation support
- ✅ Fix any accessibility issues
- ✅ **MODIFY THE UI COMPONENT FILES DIRECTLY WITH UX IMPROVEMENTS**
2. **Apply UX Improvements** - UPDATE THE CODE:
- ✅ Refactor components for better clarity and simplicity
- ✅ Improve visual design and spacing
- ✅ Enhance accessibility features
- ✅ Add user feedback mechanisms (loading, error, success states)
- ✅ Optimize for mobile and responsive design
- ✅ Improve micro-interactions and animations
- ✅ Ensure consistency with design system
- ✅ **MAKE THE ACTUAL CODE CHANGES - DO NOT JUST DOCUMENT THEM**
3. **For Complex UX Issues** - UPDATE THE CODE:
- ✅ Make the improvements you can make safely
- ✅ Document UX considerations and recommendations
- ✅ Provide specific suggestions for major UX improvements
- ✅ **STILL MAKE AS MANY UX IMPROVEMENTS AS POSSIBLE**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A summary of UX issues found during review
- **A detailed list of ALL UX improvements made to the code (this proves you updated the code)**
- Any remaining UX considerations that need attention (if applicable)
- Recommendations for future UX enhancements
---
# ⚠️ FINAL REMINDER ⚠️
**Reviewing UX without updating the code is INCOMPLETE and UNACCEPTABLE.**
**You MUST modify the UI component files directly with UX improvements.**
**You MUST show evidence of UX code changes in your summary.**
**This step is only complete when code has been updated to improve the user experience.**`,
};

View File

@@ -30,7 +30,8 @@ interface UseBoardActionsProps {
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => Promise<void>;
persistFeatureDelete: (featureId: string) => Promise<void>;
saveCategory: (category: string) => Promise<void>;
@@ -251,7 +252,8 @@ export function useBoardActions({
workMode?: 'current' | 'auto' | 'custom';
},
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => {
const workMode = updates.workMode || 'current';
@@ -308,7 +310,13 @@ export function useBoardActions({
};
updateFeature(featureId, finalUpdates);
persistFeatureUpdate(featureId, finalUpdates, descriptionHistorySource, enhancementMode);
persistFeatureUpdate(
featureId,
finalUpdates,
descriptionHistorySource,
enhancementMode,
preEnhancementDescription
);
if (updates.category) {
saveCategory(updates.category);
}

View File

@@ -70,9 +70,21 @@ export function useBoardColumnFeatures({
// We're viewing main but branch hasn't been initialized yet
// (worktrees disabled or haven't loaded yet).
// Show features assigned to primary worktree's branch.
matchesWorktree = projectPath
? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch)
: false;
if (projectPath) {
const worktrees = useAppStore.getState().worktreesByProject[projectPath] ?? [];
if (worktrees.length === 0) {
// Worktrees not loaded yet - fallback to showing features on common default branches
// This prevents features from disappearing during initial load
matchesWorktree =
featureBranch === 'main' || featureBranch === 'master' || featureBranch === 'develop';
} else {
matchesWorktree = useAppStore
.getState()
.isPrimaryWorktreeBranch(projectPath, featureBranch);
}
} else {
matchesWorktree = false;
}
} else {
// Match by branch name
matchesWorktree = featureBranch === effectiveBranch;

View File

@@ -75,6 +75,17 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
if (isProjectSwitch) {
setPersistedCategories([]);
}
// Check for interrupted features and resume them
// This handles server restarts where features were in pipeline steps
if (api.autoMode?.resumeInterrupted) {
try {
await api.autoMode.resumeInterrupted(currentProject.path);
logger.info('Checked for interrupted features');
} catch (resumeError) {
logger.warn('Failed to check for interrupted features:', resumeError);
}
}
} else if (!result.success && result.error) {
logger.error('API returned error:', result.error);
// If it's a new project or the error indicates no features found,

View File

@@ -19,7 +19,8 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => {
if (!currentProject) return;
@@ -35,7 +36,8 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
featureId,
updates,
descriptionHistorySource,
enhancementMode
enhancementMode,
preEnhancementDescription
);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);

View File

@@ -4,13 +4,18 @@ import {
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import type { FollowUpHistoryEntry } from '../dialogs/follow-up-dialog';
/**
* Custom hook for managing follow-up dialog state including prompt history
*/
export function useFollowUpState() {
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
const [followUpPrompt, setFollowUpPrompt] = useState('');
const [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]);
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map());
const [followUpPromptHistory, setFollowUpPromptHistory] = useState<FollowUpHistoryEntry[]>([]);
const resetFollowUpState = useCallback(() => {
setShowFollowUpDialog(false);
@@ -18,6 +23,7 @@ export function useFollowUpState() {
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
setFollowUpPromptHistory([]);
}, []);
const handleFollowUpDialogChange = useCallback(
@@ -31,6 +37,13 @@ export function useFollowUpState() {
[resetFollowUpState]
);
/**
* Adds a new entry to the prompt history
*/
const addToPromptHistory = useCallback((entry: FollowUpHistoryEntry) => {
setFollowUpPromptHistory((prev) => [...prev, entry]);
}, []);
return {
// State
showFollowUpDialog,
@@ -38,14 +51,17 @@ export function useFollowUpState() {
followUpPrompt,
followUpImagePaths,
followUpPreviewMap,
followUpPromptHistory,
// Setters
setShowFollowUpDialog,
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
setFollowUpPromptHistory,
// Helpers
resetFollowUpState,
handleFollowUpDialogChange,
addToPromptHistory,
};
}

View File

@@ -3,8 +3,8 @@ import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Button } from '@/components/ui/button';
import { KanbanColumn, KanbanCard } from './components';
import { Feature } from '@/store/app-store';
import { Archive, Settings2, CheckSquare, GripVertical } from 'lucide-react';
import { Feature, useAppStore, formatShortcut } from '@/store/app-store';
import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-react';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { getColumnsWithPipeline, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
@@ -43,6 +43,7 @@ interface KanbanBoardProps {
featuresWithContext: Set<string>;
runningAutoTasks: string[];
onArchiveAllVerified: () => void;
onAddFeature: () => void;
pipelineConfig: PipelineConfig | null;
onOpenPipelineSettings?: () => void;
// Selection mode props
@@ -78,6 +79,7 @@ export function KanbanBoard({
featuresWithContext,
runningAutoTasks,
onArchiveAllVerified,
onAddFeature,
pipelineConfig,
onOpenPipelineSettings,
isSelectionMode = false,
@@ -88,12 +90,16 @@ export function KanbanBoard({
// Generate columns including pipeline steps
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
// Get the keyboard shortcut for adding features
const { keyboardShortcuts } = useAppStore();
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
// Use responsive column widths based on window size
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
return (
<div className="flex-1 overflow-x-auto px-5 pb-4 relative" style={backgroundImageStyle}>
<div className="flex-1 overflow-x-auto px-5 pt-4 pb-4 relative" style={backgroundImageStyle}>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
@@ -127,26 +133,38 @@ export function KanbanBoard({
Complete All
</Button>
) : column.id === 'backlog' ? (
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${isSelectionMode ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={onToggleSelectionMode}
title={isSelectionMode ? 'Switch to Drag Mode' : 'Select Multiple'}
data-testid="selection-mode-button"
>
{isSelectionMode ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
<div className="flex items-center gap-1">
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0"
onClick={onAddFeature}
title="Add Feature"
data-testid="add-feature-button"
>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${isSelectionMode ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={onToggleSelectionMode}
title={isSelectionMode ? 'Switch to Drag Mode' : 'Select Multiple'}
data-testid="selection-mode-button"
>
{isSelectionMode ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
</div>
) : column.id === 'in_progress' ? (
<Button
variant="ghost"
@@ -171,6 +189,23 @@ export function KanbanBoard({
</Button>
) : undefined
}
footerAction={
column.id === 'backlog' ? (
<Button
variant="default"
size="sm"
className="w-full h-9 text-sm"
onClick={onAddFeature}
data-testid="add-feature-floating-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
{formatShortcut(addFeatureShortcut, true)}
</span>
</Button>
) : undefined
}
>
<SortableContext
items={columnFeatures.map((f) => f.id)}

View File

@@ -0,0 +1,152 @@
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Sparkles, ChevronDown, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
const logger = createLogger('EnhanceWithAI');
interface EnhanceWithAIProps {
/** Current text value to enhance */
value: string;
/** Callback when text is enhanced */
onChange: (enhancedText: string) => void;
/** Optional callback to track enhancement in history */
onHistoryAdd?: (entry: {
mode: EnhancementMode;
originalText: string;
enhancedText: string;
}) => void;
/** Disable the enhancement feature */
disabled?: boolean;
/** Additional CSS classes */
className?: string;
}
/**
* Reusable "Enhance with AI" component
*
* Provides AI-powered text enhancement with multiple modes:
* - Improve Clarity
* - Add Technical Details
* - Simplify
* - Add Acceptance Criteria
* - User Experience
*
* Used in Add Feature, Edit Feature, and Follow-Up dialogs.
*/
export function EnhanceWithAI({
value,
onChange,
onHistoryAdd,
disabled = false,
className,
}: EnhanceWithAIProps) {
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<EnhancementMode>('improve');
const [enhanceOpen, setEnhanceOpen] = useState(false);
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
const handleEnhance = async () => {
if (!value.trim() || isEnhancing || disabled) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
value,
enhancementMode,
enhancementOverride.effectiveModel,
enhancementOverride.effectiveModelEntry.thinkingLevel
);
if (result?.success && result.enhancedText) {
const originalText = value;
const enhancedText = result.enhancedText;
onChange(enhancedText);
// Track in history if callback provided (includes original for restoration)
onHistoryAdd?.({ mode: enhancementMode, originalText, enhancedText });
toast.success('Enhanced successfully!');
} else {
toast.error(result?.error || 'Failed to enhance');
}
} catch (error) {
logger.error('Enhancement failed:', error);
toast.error('Failed to enhance');
} finally {
setIsEnhancing(false);
}
};
return (
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen} className={className}>
<CollapsibleTrigger asChild>
<button
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1"
disabled={disabled}
>
{enhanceOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
<Sparkles className="w-4 h-4" />
<span>Enhance with AI</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="flex flex-wrap items-center gap-2 pl-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-xs" disabled={disabled}>
{ENHANCEMENT_MODE_LABELS[enhancementMode]}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{(Object.entries(ENHANCEMENT_MODE_LABELS) as [EnhancementMode, string][]).map(
([mode, label]) => (
<DropdownMenuItem key={mode} onClick={() => setEnhancementMode(mode)}>
{label}
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="default"
size="sm"
className="h-8 text-xs"
onClick={handleEnhance}
disabled={!value.trim() || isEnhancing || disabled}
loading={isEnhancing}
>
<Sparkles className="w-3 h-3 mr-1" />
Enhance
</Button>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
</CollapsibleContent>
</Collapsible>
);
}

View File

@@ -0,0 +1,20 @@
/** Enhancement mode options for AI-powered prompt improvement */
export type EnhancementMode = 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
/** Labels for enhancement modes displayed in the UI */
export const ENHANCEMENT_MODE_LABELS: Record<EnhancementMode, string> = {
improve: 'Improve Clarity',
technical: 'Add Technical Details',
simplify: 'Simplify',
acceptance: 'Add Acceptance Criteria',
'ux-reviewer': 'User Experience',
};
/** Descriptions for enhancement modes (for tooltips/accessibility) */
export const ENHANCEMENT_MODE_DESCRIPTIONS: Record<EnhancementMode, string> = {
improve: 'Make the prompt clearer and more concise',
technical: 'Add implementation details and specifications',
simplify: 'Reduce complexity while keeping the core intent',
acceptance: 'Add specific acceptance criteria and test cases',
'ux-reviewer': 'Add user experience considerations and flows',
};

View File

@@ -0,0 +1,136 @@
import { useState, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { History } from 'lucide-react';
import { toast } from 'sonner';
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
/**
* Base interface for history entries
*/
export interface BaseHistoryEntry {
timestamp: string;
source: 'initial' | 'enhance' | 'edit';
enhancementMode?: EnhancementMode;
}
interface EnhancementHistoryButtonProps<T extends BaseHistoryEntry> {
/** Array of history entries */
history: T[];
/** Current value to compare against for highlighting */
currentValue: string;
/** Callback when a history entry is restored */
onRestore: (value: string) => void;
/** Function to extract the text value from a history entry */
valueAccessor: (entry: T) => string;
/** Title for the history popover (e.g., "Version History", "Prompt History") */
title?: string;
/** Message shown when restoring an entry */
restoreMessage?: string;
}
/**
* Reusable history button component for enhancement-related history
*
* Displays a popover with a list of historical versions that can be restored.
* Used in edit-feature-dialog and follow-up-dialog for description/prompt history.
*/
export function EnhancementHistoryButton<T extends BaseHistoryEntry>({
history,
currentValue,
onRestore,
valueAccessor,
title = 'Version History',
restoreMessage = 'Restored from history',
}: EnhancementHistoryButtonProps<T>) {
const [showHistory, setShowHistory] = useState(false);
// Memoize reversed history to avoid creating new array on every render
// NOTE: This hook MUST be called before any early returns to follow Rules of Hooks
const reversedHistory = useMemo(() => [...history].reverse(), [history]);
// Early return AFTER all hooks are called
if (history.length === 0) {
return null;
}
const getSourceLabel = (entry: T): string => {
if (entry.source === 'initial') {
return 'Original';
}
if (entry.source === 'enhance') {
const mode = entry.enhancementMode ?? 'improve';
const label = ENHANCEMENT_MODE_LABELS[mode as EnhancementMode] ?? mode;
return `Enhanced (${label})`;
}
return 'Edited';
};
const formatDate = (timestamp: string): string => {
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<Popover open={showHistory} onOpenChange={setShowHistory}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-muted-foreground"
>
<History className="w-3.5 h-3.5" />
History ({history.length})
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="p-3 border-b">
<h4 className="font-medium text-sm">{title}</h4>
<p className="text-xs text-muted-foreground mt-1">Click a version to restore it</p>
</div>
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{reversedHistory.map((entry, index) => {
const value = valueAccessor(entry);
const isCurrentVersion = value === currentValue;
const sourceLabel = getSourceLabel(entry);
const formattedDate = formatDate(entry.timestamp);
return (
<button
key={`${entry.timestamp}-${index}`}
onClick={() => {
onRestore(value);
setShowHistory(false);
toast.success(restoreMessage);
}}
className={`w-full text-left p-2 rounded-md hover:bg-muted transition-colors ${
isCurrentVersion ? 'bg-muted/50 border border-primary/20' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{sourceLabel}</span>
<span className="text-xs text-muted-foreground">{formattedDate}</span>
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{value.slice(0, 100)}
{value.length > 100 ? '...' : ''}
</p>
{isCurrentVersion && (
<span className="text-xs text-primary font-medium mt-1 block">
Current version
</span>
)}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,3 @@
export * from './enhancement-constants';
export * from './enhance-with-ai';
export * from './enhancement-history-button';

View File

@@ -2,9 +2,6 @@ export * from './model-constants';
export * from './model-selector';
export * from './thinking-level-selector';
export * from './reasoning-effort-selector';
export * from './profile-quick-select';
export * from './profile-select';
export * from './profile-typeahead';
export * from './testing-tab-content';
export * from './priority-selector';
export * from './priority-select';
@@ -13,3 +10,4 @@ export * from './planning-mode-selector';
export * from './planning-mode-select';
export * from './ancestor-context-section';
export * from './work-mode-selector';
export * from './enhancement';

View File

@@ -110,7 +110,7 @@ export const OPENCODE_MODELS: ModelOption[] = OPENCODE_MODEL_CONFIGS.map((config
label: config.label,
description: config.description,
badge: config.tier === 'free' ? 'Free' : config.tier === 'premium' ? 'Premium' : undefined,
provider: 'opencode' as ModelProvider,
provider: config.provider as ModelProvider,
}));
/**

View File

@@ -9,7 +9,9 @@ import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types';
import type { ModelProvider } from '@automaker/types';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, ModelOption } from './model-constants';
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
import { useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
interface ModelSelectorProps {
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
@@ -22,7 +24,14 @@ export function ModelSelector({
onModelSelect,
testIdPrefix = 'model-select',
}: ModelSelectorProps) {
const { enabledCursorModels, cursorDefaultModel } = useAppStore();
const {
enabledCursorModels,
cursorDefaultModel,
codexModels,
codexModelsLoading,
codexModelsError,
fetchCodexModels,
} = useAppStore();
const { cursorCliStatus, codexCliStatus } = useSetupStore();
const selectedProvider = getModelProvider(selectedModel);
@@ -33,6 +42,31 @@ export function ModelSelector({
// Check if Codex CLI is available
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
// Fetch Codex models on mount
useEffect(() => {
if (isCodexAvailable && codexModels.length === 0 && !codexModelsLoading) {
fetchCodexModels();
}
}, [isCodexAvailable, codexModels.length, codexModelsLoading, fetchCodexModels]);
// Transform codex models from store to ModelOption format
const dynamicCodexModels: ModelOption[] = codexModels.map((model) => {
// Infer badge based on tier
let badge: string | undefined;
if (model.tier === 'premium') badge = 'Premium';
else if (model.tier === 'basic') badge = 'Speed';
else if (model.tier === 'standard') badge = 'Balanced';
return {
id: model.id,
label: model.label,
description: model.description,
badge,
provider: 'codex' as ModelProvider,
hasThinking: model.hasThinking,
};
});
// Filter Cursor models based on enabled models from global settings
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
// Extract the cursor model ID from the prefixed ID (e.g., "cursor-auto" -> "auto")
@@ -45,8 +79,10 @@ export function ModelSelector({
// Switch to Cursor's default model (from global settings)
onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`);
} else if (provider === 'codex' && selectedProvider !== 'codex') {
// Switch to Codex's default model (codex-gpt-5.2-codex)
onModelSelect('codex-gpt-5.2-codex');
// Switch to Codex's default model (use isDefault flag from dynamic models)
const defaultModel = codexModels.find((m) => m.isDefault);
const defaultModelId = defaultModel?.id || codexModels[0]?.id || 'codex-gpt-5.2-codex';
onModelSelect(defaultModelId);
} else if (provider === 'claude' && selectedProvider !== 'claude') {
// Switch to Claude's default model
onModelSelect('sonnet');
@@ -234,41 +270,91 @@ export function ModelSelector({
CLI
</span>
</div>
<div className="flex flex-col gap-2">
{CODEX_MODELS.map((option) => {
const isSelected = selectedModel === option.id;
return (
{/* Loading state */}
{codexModelsLoading && dynamicCodexModels.length === 0 && (
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
<RefreshCw className="w-4 h-4 animate-spin" />
Loading models...
</div>
)}
{/* Error state */}
{codexModelsError && !codexModelsLoading && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
<AlertTriangle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
<div className="space-y-1">
<div className="text-sm text-red-400">Failed to load Codex models</div>
<button
key={option.id}
type="button"
onClick={() => onModelSelect(option.id)}
title={option.description}
className={cn(
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
isSelected
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`${testIdPrefix}-${option.id}`}
onClick={() => fetchCodexModels(true)}
className="text-xs text-red-400 underline hover:no-underline"
>
<span>{option.label}</span>
{option.badge && (
<Badge
variant="outline"
className={cn(
'text-xs',
isSelected
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-muted-foreground/50 text-muted-foreground'
)}
>
{option.badge}
</Badge>
)}
Retry
</button>
);
})}
</div>
</div>
</div>
)}
{/* Model list */}
{!codexModelsLoading && !codexModelsError && dynamicCodexModels.length === 0 && (
<div className="text-sm text-muted-foreground p-3 border border-dashed rounded-md text-center">
No Codex models available
</div>
)}
{!codexModelsLoading && dynamicCodexModels.length > 0 && (
<div className="flex flex-col gap-2">
{dynamicCodexModels.map((option) => {
const isSelected = selectedModel === option.id;
return (
<button
key={option.id}
type="button"
onClick={() => onModelSelect(option.id)}
title={option.description}
className={cn(
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
isSelected
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`${testIdPrefix}-${option.id}`}
>
<span>{option.label}</span>
<div className="flex gap-1">
{option.hasThinking && (
<Badge
variant="outline"
className={cn(
'text-xs',
isSelected
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-emerald-500/50 text-emerald-600 dark:text-emerald-400'
)}
>
Thinking
</Badge>
)}
{option.badge && (
<Badge
variant="outline"
className={cn(
'text-xs',
isSelected
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-muted-foreground/50 text-muted-foreground'
)}
>
{option.badge}
</Badge>
)}
</div>
</button>
);
})}
</div>
)}
</div>
)}
</div>

View File

@@ -1,155 +0,0 @@
import { Label } from '@/components/ui/label';
import { Brain, UserCircle, Terminal } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ModelAlias, ThinkingLevel, AIProfile, CursorModelId } from '@automaker/types';
import {
CURSOR_MODEL_MAP,
profileHasThinking,
PROVIDER_PREFIXES,
getCodexModelLabel,
} from '@automaker/types';
import { PROFILE_ICONS } from './model-constants';
/**
* Get display string for a profile's model configuration
*/
function getProfileModelDisplay(profile: AIProfile): string {
if (profile.provider === 'cursor') {
const cursorModel = profile.cursorModel || 'auto';
const modelConfig = CURSOR_MODEL_MAP[cursorModel];
return modelConfig?.label || cursorModel;
}
if (profile.provider === 'codex') {
return getCodexModelLabel(profile.codexModel || 'codex-gpt-5.2-codex');
}
// Claude
return profile.model || 'sonnet';
}
/**
* Get display string for a profile's thinking configuration
*/
function getProfileThinkingDisplay(profile: AIProfile): string | null {
if (profile.provider === 'cursor') {
// For Cursor, thinking is embedded in the model
return profileHasThinking(profile) ? 'thinking' : null;
}
if (profile.provider === 'codex') {
// For Codex, thinking is embedded in the model
return profileHasThinking(profile) ? 'thinking' : null;
}
// Claude
return profile.thinkingLevel && profile.thinkingLevel !== 'none' ? profile.thinkingLevel : null;
}
interface ProfileQuickSelectProps {
profiles: AIProfile[];
selectedModel: ModelAlias | CursorModelId;
selectedThinkingLevel: ThinkingLevel;
selectedCursorModel?: string; // For detecting cursor profile selection
onSelect: (profile: AIProfile) => void; // Changed to pass full profile
testIdPrefix?: string;
showManageLink?: boolean;
onManageLinkClick?: () => void;
}
export function ProfileQuickSelect({
profiles,
selectedModel,
selectedThinkingLevel,
selectedCursorModel,
onSelect,
testIdPrefix = 'profile-quick-select',
showManageLink = false,
onManageLinkClick,
}: ProfileQuickSelectProps) {
// Show both Claude and Cursor profiles
const allProfiles = profiles;
if (allProfiles.length === 0) {
return null;
}
// Check if a profile is selected
const isProfileSelected = (profile: AIProfile): boolean => {
if (profile.provider === 'cursor') {
// For cursor profiles, check if cursor model matches
const profileCursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
return selectedCursorModel === profileCursorModel;
}
// For Claude profiles
return selectedModel === profile.model && selectedThinkingLevel === profile.thinkingLevel;
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<UserCircle className="w-4 h-4 text-brand-500" />
Quick Select Profile
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-brand-500/40 text-brand-500">
Presets
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{allProfiles.slice(0, 6).map((profile) => {
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
const isSelected = isProfileSelected(profile);
const isCursorProfile = profile.provider === 'cursor';
return (
<button
key={profile.id}
type="button"
onClick={() => onSelect(profile)}
className={cn(
'flex items-center gap-2 p-2 rounded-lg border text-left transition-all',
isSelected
? 'bg-brand-500/10 border-brand-500 text-foreground'
: 'bg-background hover:bg-accent border-input'
)}
data-testid={`${testIdPrefix}-${profile.id}`}
>
<div
className={cn(
'w-7 h-7 rounded flex items-center justify-center shrink-0',
isCursorProfile ? 'bg-amber-500/10' : 'bg-primary/10'
)}
>
{isCursorProfile ? (
<Terminal className="w-4 h-4 text-amber-500" />
) : (
IconComponent && <IconComponent className="w-4 h-4 text-primary" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{profile.name}</p>
<p className="text-[10px] text-muted-foreground truncate">
{getProfileModelDisplay(profile)}
{getProfileThinkingDisplay(profile) && ` + ${getProfileThinkingDisplay(profile)}`}
</p>
</div>
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
Or customize below.
{showManageLink && onManageLinkClick && (
<>
{' '}
Manage profiles in{' '}
<button
type="button"
onClick={onManageLinkClick}
className="text-brand-500 hover:underline"
>
AI Profiles
</button>
</>
)}
</p>
</div>
);
}

View File

@@ -1,187 +0,0 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Brain, Terminal } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ModelAlias, ThinkingLevel, AIProfile, CursorModelId } from '@automaker/types';
import {
CURSOR_MODEL_MAP,
profileHasThinking,
PROVIDER_PREFIXES,
getCodexModelLabel,
} from '@automaker/types';
import { PROFILE_ICONS } from './model-constants';
/**
* Get display string for a profile's model configuration
*/
function getProfileModelDisplay(profile: AIProfile): string {
if (profile.provider === 'cursor') {
const cursorModel = profile.cursorModel || 'auto';
const modelConfig = CURSOR_MODEL_MAP[cursorModel];
return modelConfig?.label || cursorModel;
}
if (profile.provider === 'codex') {
return getCodexModelLabel(profile.codexModel || 'codex-gpt-5.2-codex');
}
// Claude
return profile.model || 'sonnet';
}
/**
* Get display string for a profile's thinking configuration
*/
function getProfileThinkingDisplay(profile: AIProfile): string | null {
if (profile.provider === 'cursor') {
// For Cursor, thinking is embedded in the model
return profileHasThinking(profile) ? 'thinking' : null;
}
if (profile.provider === 'codex') {
// For Codex, thinking is embedded in the model
return profileHasThinking(profile) ? 'thinking' : null;
}
// Claude
return profile.thinkingLevel && profile.thinkingLevel !== 'none' ? profile.thinkingLevel : null;
}
interface ProfileSelectProps {
profiles: AIProfile[];
selectedModel: ModelAlias | CursorModelId;
selectedThinkingLevel: ThinkingLevel;
selectedCursorModel?: string; // For detecting cursor profile selection
onSelect: (profile: AIProfile) => void;
testIdPrefix?: string;
className?: string;
disabled?: boolean;
}
/**
* ProfileSelect - Compact dropdown selector for AI profiles
*
* A lightweight alternative to ProfileQuickSelect for contexts where
* space is limited (e.g., mass edit, bulk operations).
*
* Shows icon + profile name in dropdown, with model details below.
*
* @example
* ```tsx
* <ProfileSelect
* profiles={aiProfiles}
* selectedModel={model}
* selectedThinkingLevel={thinkingLevel}
* selectedCursorModel={isCurrentModelCursor ? model : undefined}
* onSelect={handleProfileSelect}
* testIdPrefix="mass-edit-profile"
* />
* ```
*/
export function ProfileSelect({
profiles,
selectedModel,
selectedThinkingLevel,
selectedCursorModel,
onSelect,
testIdPrefix = 'profile-select',
className,
disabled = false,
}: ProfileSelectProps) {
if (profiles.length === 0) {
return null;
}
// Check if a profile is selected
const isProfileSelected = (profile: AIProfile): boolean => {
if (profile.provider === 'cursor') {
// For cursor profiles, check if cursor model matches
const profileCursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
return selectedCursorModel === profileCursorModel;
}
// For Claude profiles
return selectedModel === profile.model && selectedThinkingLevel === profile.thinkingLevel;
};
const selectedProfile = profiles.find(isProfileSelected);
return (
<div className={cn('space-y-2', className)}>
<Select
value={selectedProfile?.id || 'none'}
onValueChange={(value: string) => {
if (value !== 'none') {
const profile = profiles.find((p) => p.id === value);
if (profile) {
onSelect(profile);
}
}
}}
disabled={disabled}
>
<SelectTrigger className="h-9" data-testid={`${testIdPrefix}-select-trigger`}>
<SelectValue>
{selectedProfile ? (
<div className="flex items-center gap-2">
{selectedProfile.provider === 'cursor' ? (
<Terminal className="h-4 w-4 text-amber-500" />
) : (
(() => {
const IconComponent = selectedProfile.icon
? PROFILE_ICONS[selectedProfile.icon]
: Brain;
return <IconComponent className="h-4 w-4 text-primary" />;
})()
)}
<span>{selectedProfile.name}</span>
</div>
) : (
<span className="text-muted-foreground">Select a profile...</span>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-muted-foreground">
No profile selected
</SelectItem>
{profiles.map((profile) => {
const isCursorProfile = profile.provider === 'cursor';
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
return (
<SelectItem
key={profile.id}
value={profile.id}
data-testid={`${testIdPrefix}-option-${profile.id}`}
>
<div className="flex items-center gap-2">
{isCursorProfile ? (
<Terminal className="h-3.5 w-3.5 text-amber-500" />
) : (
<IconComponent className="h-3.5 w-3.5 text-primary" />
)}
<div className="flex flex-col">
<span className="text-sm">{profile.name}</span>
<span className="text-[10px] text-muted-foreground">
{getProfileModelDisplay(profile)}
{getProfileThinkingDisplay(profile) &&
` + ${getProfileThinkingDisplay(profile)}`}
</span>
</div>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
{selectedProfile && (
<p className="text-xs text-muted-foreground">
{getProfileModelDisplay(selectedProfile)}
{getProfileThinkingDisplay(selectedProfile) &&
` + ${getProfileThinkingDisplay(selectedProfile)}`}
</p>
)}
</div>
);
}

View File

@@ -1,237 +0,0 @@
import * as React from 'react';
import { Check, ChevronsUpDown, UserCircle, Settings2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import type { AIProfile } from '@automaker/types';
import { CURSOR_MODEL_MAP, profileHasThinking, getCodexModelLabel } from '@automaker/types';
import { PROVIDER_ICON_COMPONENTS } from '@/components/ui/provider-icon';
/**
* Get display string for a profile's model configuration
*/
function getProfileModelDisplay(profile: AIProfile): string {
if (profile.provider === 'cursor') {
const cursorModel = profile.cursorModel || 'auto';
const modelConfig = CURSOR_MODEL_MAP[cursorModel];
return modelConfig?.label || cursorModel;
}
if (profile.provider === 'codex') {
return getCodexModelLabel(profile.codexModel || 'codex-gpt-5.2-codex');
}
if (profile.provider === 'opencode') {
// Extract a short label from the opencode model
const modelId = profile.opencodeModel || '';
if (modelId.includes('/')) {
const parts = modelId.split('/');
return parts[parts.length - 1].split('.')[0] || modelId;
}
return modelId;
}
// Claude
return profile.model || 'sonnet';
}
/**
* Get display string for a profile's thinking configuration
*/
function getProfileThinkingDisplay(profile: AIProfile): string | null {
if (profile.provider === 'cursor' || profile.provider === 'codex') {
return profileHasThinking(profile) ? 'thinking' : null;
}
// Claude
return profile.thinkingLevel && profile.thinkingLevel !== 'none' ? profile.thinkingLevel : null;
}
interface ProfileTypeaheadProps {
profiles: AIProfile[];
selectedProfileId?: string;
onSelect: (profile: AIProfile) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
showManageLink?: boolean;
onManageLinkClick?: () => void;
testIdPrefix?: string;
}
export function ProfileTypeahead({
profiles,
selectedProfileId,
onSelect,
placeholder = 'Select profile...',
className,
disabled = false,
showManageLink = false,
onManageLinkClick,
testIdPrefix = 'profile-typeahead',
}: ProfileTypeaheadProps) {
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState('');
const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const selectedProfile = React.useMemo(
() => profiles.find((p) => p.id === selectedProfileId),
[profiles, selectedProfileId]
);
// Update trigger width when component mounts or value changes
React.useEffect(() => {
if (triggerRef.current) {
const updateWidth = () => {
setTriggerWidth(triggerRef.current?.offsetWidth || 0);
};
updateWidth();
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(triggerRef.current);
return () => {
resizeObserver.disconnect();
};
}
}, [selectedProfileId]);
// Filter profiles based on input
const filteredProfiles = React.useMemo(() => {
if (!inputValue) return profiles;
const lower = inputValue.toLowerCase();
return profiles.filter(
(p) =>
p.name.toLowerCase().includes(lower) ||
p.description?.toLowerCase().includes(lower) ||
p.provider.toLowerCase().includes(lower)
);
}, [profiles, inputValue]);
const handleSelect = (profile: AIProfile) => {
onSelect(profile);
setInputValue('');
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn('w-full justify-between h-9', className)}
data-testid={`${testIdPrefix}-trigger`}
>
<span className="flex items-center gap-2 truncate">
{selectedProfile ? (
<>
{(() => {
const ProviderIcon = PROVIDER_ICON_COMPONENTS[selectedProfile.provider];
return ProviderIcon ? (
<ProviderIcon className="w-4 h-4 shrink-0 text-muted-foreground" />
) : (
<UserCircle className="w-4 h-4 shrink-0 text-muted-foreground" />
);
})()}
<span className="truncate">{selectedProfile.name}</span>
</>
) : (
<>
<UserCircle className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">{placeholder}</span>
</>
)}
</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: Math.max(triggerWidth, 280) }}
data-testid={`${testIdPrefix}-content`}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search profiles..."
className="h-9"
value={inputValue}
onValueChange={setInputValue}
/>
<CommandList className="max-h-[300px]">
<CommandEmpty>No profile found.</CommandEmpty>
<CommandGroup>
{filteredProfiles.map((profile) => {
const ProviderIcon = PROVIDER_ICON_COMPONENTS[profile.provider];
const isSelected = profile.id === selectedProfileId;
const modelDisplay = getProfileModelDisplay(profile);
const thinkingDisplay = getProfileThinkingDisplay(profile);
return (
<CommandItem
key={profile.id}
value={profile.id}
onSelect={() => handleSelect(profile)}
className="flex items-center gap-2 py-2"
data-testid={`${testIdPrefix}-option-${profile.id}`}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{ProviderIcon ? (
<ProviderIcon className="w-4 h-4 shrink-0 text-muted-foreground" />
) : (
<UserCircle className="w-4 h-4 shrink-0 text-muted-foreground" />
)}
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">{profile.name}</span>
<span className="text-xs text-muted-foreground truncate">
{modelDisplay}
{thinkingDisplay && (
<span className="text-amber-500"> + {thinkingDisplay}</span>
)}
</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{profile.isBuiltIn && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
Built-in
</Badge>
)}
<Check className={cn('h-4 w-4', isSelected ? 'opacity-100' : 'opacity-0')} />
</div>
</CommandItem>
);
})}
</CommandGroup>
{showManageLink && onManageLinkClick && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
setOpen(false);
onManageLinkClick();
}}
className="text-muted-foreground"
data-testid={`${testIdPrefix}-manage-link`}
>
<Settings2 className="w-4 h-4 mr-2" />
Manage AI Profiles
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,8 +1,7 @@
import { useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw, PanelLeftOpen, PanelLeftClose } from 'lucide-react';
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { cn, pathsEqual } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import {
useWorktrees,
@@ -80,12 +79,6 @@ export function WorktreePanel({
features,
});
// Collapse state from store (synced via API)
const isCollapsed = useAppStore((s) => s.worktreePanelCollapsed);
const setWorktreePanelCollapsed = useAppStore((s) => s.setWorktreePanelCollapsed);
const toggleCollapsed = () => setWorktreePanelCollapsed(!isCollapsed);
// Periodic interval check (5 seconds) to detect branch changes on disk
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
const intervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -101,18 +94,6 @@ export function WorktreePanel({
};
}, [fetchWorktrees]);
// Get the currently selected worktree for collapsed view
const selectedWorktree = worktrees.find((w) => {
if (
currentWorktree === null ||
currentWorktree === undefined ||
currentWorktree.path === null
) {
return w.isMain;
}
return pathsEqual(w.path, currentWorktreePath);
});
const isWorktreeSelected = (worktree: WorktreeInfo) => {
return worktree.isMain
? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null
@@ -135,44 +116,8 @@ export function WorktreePanel({
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
// Collapsed view - just show current branch and toggle
if (isCollapsed) {
return (
<div className="flex items-center gap-2 px-4 py-1.5 border-b border-border bg-glass/50 backdrop-blur-sm">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={toggleCollapsed}
title="Expand worktree panel"
>
<PanelLeftOpen className="w-4 h-4" />
</Button>
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Branch:</span>
<span className="text-sm font-mono font-medium">{selectedWorktree?.branch ?? 'main'}</span>
{selectedWorktree?.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30">
{selectedWorktree.changedFilesCount ?? '!'}
</span>
)}
</div>
);
}
// Expanded view - full worktree panel
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={toggleCollapsed}
title="Collapse worktree panel"
>
<PanelLeftClose className="w-4 h-4" />
</Button>
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Branch:</span>

View File

@@ -0,0 +1,885 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useNavigate } from '@tanstack/react-router';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useOSDetection } from '@/hooks/use-os-detection';
import { getElectronAPI, isElectron } from '@/lib/electron';
import { initializeProject } from '@/lib/project-init';
import { getHttpApiClient } from '@/lib/http-api-client';
import { isMac } from '@/lib/utils';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { WorkspacePickerModal } from '@/components/dialogs/workspace-picker-modal';
import type { StarterTemplate } from '@/lib/templates';
import {
FolderOpen,
Plus,
Folder,
Star,
Clock,
Loader2,
ChevronDown,
MessageSquare,
Settings,
MoreVertical,
Trash2,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
const logger = createLogger('DashboardView');
function getOSAbbreviation(os: string): string {
switch (os) {
case 'mac':
return 'M';
case 'windows':
return 'W';
case 'linux':
return 'L';
default:
return '?';
}
}
export function DashboardView() {
const navigate = useNavigate();
const { os } = useOSDetection();
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
const appMode = import.meta.env.VITE_APP_MODE || '?';
const versionSuffix = `${getOSAbbreviation(os)}${appMode}`;
const {
projects,
trashedProjects,
currentProject,
upsertAndSetCurrentProject,
addProject,
setCurrentProject,
toggleProjectFavorite,
moveProjectToTrash,
theme: globalTheme,
} = useAppStore();
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
const [showWorkspacePicker, setShowWorkspacePicker] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [isOpening, setIsOpening] = useState(false);
const [projectToRemove, setProjectToRemove] = useState<{ id: string; name: string } | null>(null);
// Sort projects: favorites first, then by last opened
const sortedProjects = [...projects].sort((a, b) => {
// Favorites first
if (a.isFavorite && !b.isFavorite) return -1;
if (!a.isFavorite && b.isFavorite) return 1;
// Then by last opened
const dateA = a.lastOpened ? new Date(a.lastOpened).getTime() : 0;
const dateB = b.lastOpened ? new Date(b.lastOpened).getTime() : 0;
return dateB - dateA;
});
const favoriteProjects = sortedProjects.filter((p) => p.isFavorite);
const recentProjects = sortedProjects.filter((p) => !p.isFavorite);
/**
* Initialize project and navigate to board
*/
const initializeAndOpenProject = useCallback(
async (path: string, name: string) => {
setIsOpening(true);
try {
const initResult = await initializeProject(path);
if (!initResult.success) {
toast.error('Failed to initialize project', {
description: initResult.error || 'Unknown error occurred',
});
return;
}
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(path, name, effectiveTheme);
toast.success('Project opened', {
description: `Opened ${name}`,
});
navigate({ to: '/board' });
} catch (error) {
logger.error('[Dashboard] Failed to open project:', error);
toast.error('Failed to open project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsOpening(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject, navigate]
);
const handleOpenProject = useCallback(async () => {
try {
const httpClient = getHttpApiClient();
const configResult = await httpClient.workspace.getConfig();
if (configResult.success && configResult.configured) {
setShowWorkspacePicker(true);
} else {
const api = getElectronAPI();
const result = await api.openDirectory();
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
await initializeAndOpenProject(path, name);
}
}
} catch (error) {
logger.error('[Dashboard] Failed to check workspace config:', error);
const api = getElectronAPI();
const result = await api.openDirectory();
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
await initializeAndOpenProject(path, name);
}
}
}, [initializeAndOpenProject]);
const handleWorkspaceSelect = useCallback(
async (path: string, name: string) => {
setShowWorkspacePicker(false);
await initializeAndOpenProject(path, name);
},
[initializeAndOpenProject]
);
const handleProjectClick = useCallback(
async (project: { id: string; name: string; path: string }) => {
await initializeAndOpenProject(project.path, project.name);
},
[initializeAndOpenProject]
);
const handleToggleFavorite = useCallback(
(e: React.MouseEvent, projectId: string) => {
e.stopPropagation();
toggleProjectFavorite(projectId);
},
[toggleProjectFavorite]
);
const handleRemoveProject = useCallback(
(e: React.MouseEvent, project: { id: string; name: string }) => {
e.stopPropagation();
setProjectToRemove(project);
},
[]
);
const handleConfirmRemove = useCallback(() => {
if (projectToRemove) {
moveProjectToTrash(projectToRemove.id);
toast.success('Project removed', {
description: `${projectToRemove.name} has been removed from your projects list`,
});
setProjectToRemove(null);
}
}, [projectToRemove, moveProjectToTrash]);
const handleNewProject = () => {
setShowNewProjectModal(true);
};
const handleInteractiveMode = () => {
navigate({ to: '/interview' });
};
const handleCreateBlankProject = async (projectName: string, parentDir: string) => {
setIsCreating(true);
try {
const api = getElectronAPI();
const projectPath = `${parentDir}/${projectName}`;
const parentExists = await api.exists(parentDir);
if (!parentExists) {
toast.error('Parent directory does not exist', {
description: `Cannot create project in non-existent directory: ${parentDir}`,
});
return;
}
const parentStat = await api.stat(parentDir);
if (parentStat && !parentStat.stats?.isDirectory) {
toast.error('Parent path is not a directory', {
description: `${parentDir} is not a directory`,
});
return;
}
const mkdirResult = await api.mkdir(projectPath);
if (!mkdirResult.success) {
toast.error('Failed to create project directory', {
description: mkdirResult.error || 'Unknown error occurred',
});
return;
}
const initResult = await initializeProject(projectPath);
if (!initResult.success) {
toast.error('Failed to initialize project', {
description: initResult.error || 'Unknown error occurred',
});
return;
}
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>
<overview>
Describe your project here. This file will be analyzed by an AI agent
to understand your project structure and tech stack.
</overview>
<technology_stack>
<!-- The AI agent will fill this in after analyzing your project -->
</technology_stack>
<core_capabilities>
<!-- List core features and capabilities -->
</core_capabilities>
<implemented_features>
<!-- The AI agent will populate this based on code analysis -->
</implemented_features>
</project_specification>`
);
const project = {
id: `project-${Date.now()}`,
name: projectName,
path: projectPath,
lastOpened: new Date().toISOString(),
};
addProject(project);
setCurrentProject(project);
setShowNewProjectModal(false);
toast.success('Project created', {
description: `Created ${projectName}`,
});
navigate({ to: '/board' });
} catch (error) {
logger.error('Failed to create project:', error);
toast.error('Failed to create project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsCreating(false);
}
};
const handleCreateFromTemplate = async (
template: StarterTemplate,
projectName: string,
parentDir: string
) => {
setIsCreating(true);
try {
const httpClient = getHttpApiClient();
const api = getElectronAPI();
const cloneResult = await httpClient.templates.clone(
template.repoUrl,
projectName,
parentDir
);
if (!cloneResult.success || !cloneResult.projectPath) {
toast.error('Failed to clone template', {
description: cloneResult.error || 'Unknown error occurred',
});
return;
}
const projectPath = cloneResult.projectPath;
const initResult = await initializeProject(projectPath);
if (!initResult.success) {
toast.error('Failed to initialize project', {
description: initResult.error || 'Unknown error occurred',
});
return;
}
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>
<overview>
This project was created from the "${template.name}" starter template.
${template.description}
</overview>
<technology_stack>
${template.techStack.map((tech) => `<technology>${tech}</technology>`).join('\n ')}
</technology_stack>
<core_capabilities>
${template.features.map((feature) => `<capability>${feature}</capability>`).join('\n ')}
</core_capabilities>
<implemented_features>
<!-- The AI agent will populate this based on code analysis -->
</implemented_features>
</project_specification>`
);
const project = {
id: `project-${Date.now()}`,
name: projectName,
path: projectPath,
lastOpened: new Date().toISOString(),
};
addProject(project);
setCurrentProject(project);
setShowNewProjectModal(false);
toast.success('Project created from template', {
description: `Created ${projectName} from ${template.name}`,
});
navigate({ to: '/board' });
} catch (error) {
logger.error('Failed to create project from template:', error);
toast.error('Failed to create project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsCreating(false);
}
};
const handleCreateFromCustomUrl = async (
repoUrl: string,
projectName: string,
parentDir: string
) => {
setIsCreating(true);
try {
const httpClient = getHttpApiClient();
const api = getElectronAPI();
const cloneResult = await httpClient.templates.clone(repoUrl, projectName, parentDir);
if (!cloneResult.success || !cloneResult.projectPath) {
toast.error('Failed to clone repository', {
description: cloneResult.error || 'Unknown error occurred',
});
return;
}
const projectPath = cloneResult.projectPath;
const initResult = await initializeProject(projectPath);
if (!initResult.success) {
toast.error('Failed to initialize project', {
description: initResult.error || 'Unknown error occurred',
});
return;
}
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>
<overview>
This project was cloned from ${repoUrl}.
The AI agent will analyze the project structure.
</overview>
<technology_stack>
<!-- The AI agent will fill this in after analyzing your project -->
</technology_stack>
<core_capabilities>
<!-- List core features and capabilities -->
</core_capabilities>
<implemented_features>
<!-- The AI agent will populate this based on code analysis -->
</implemented_features>
</project_specification>`
);
const project = {
id: `project-${Date.now()}`,
name: projectName,
path: projectPath,
lastOpened: new Date().toISOString(),
};
addProject(project);
setCurrentProject(project);
setShowNewProjectModal(false);
toast.success('Project created from repository', {
description: `Created ${projectName}`,
});
navigate({ to: '/board' });
} catch (error) {
logger.error('Failed to create project from custom URL:', error);
toast.error('Failed to create project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsCreating(false);
}
};
const hasProjects = projects.length > 0;
return (
<div className="flex-1 flex flex-col h-screen content-bg" data-testid="dashboard-view">
{/* Header with logo */}
<header className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
{/* Electron titlebar drag region */}
{isElectron() && (
<div
className={`absolute top-0 left-0 right-0 h-6 titlebar-drag-region z-40 pointer-events-none ${isMac ? 'pl-20' : ''}`}
aria-hidden="true"
/>
)}
<div className="px-8 py-4 flex items-center justify-between">
<div
className="flex items-center gap-3 cursor-pointer group titlebar-no-drag"
onClick={() => navigate({ to: '/dashboard' })}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="size-10 group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient
id="bg-dashboard"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
<filter id="iconShadow-dashboard" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow
dx="0"
dy="4"
stdDeviation="4"
floodColor="#000000"
floodOpacity="0.25"
/>
</filter>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-dashboard)" />
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#iconShadow-dashboard)"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
<div className="flex flex-col">
<span className="font-bold text-foreground text-2xl tracking-tight leading-none">
automaker<span className="text-brand-500">.</span>
</span>
<span className="text-xs text-muted-foreground leading-none font-medium mt-1">
v{appVersion} {versionSuffix}
</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => navigate({ to: '/settings' })}
className="titlebar-no-drag"
>
<Settings className="w-5 h-5" />
</Button>
</div>
</header>
{/* Main content */}
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-6xl mx-auto">
{/* No projects - show getting started */}
{!hasProjects && (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-foreground mb-3">Welcome to Automaker</h2>
<p className="text-lg text-muted-foreground max-w-xl mx-auto">
Your autonomous AI development studio. Get started by creating a new project or
opening an existing one.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-3xl mx-auto">
{/* New Project Card */}
<div
className="group relative rounded-xl border border-border bg-card/80 backdrop-blur-sm hover:bg-card hover:border-brand-500/30 hover:shadow-xl hover:shadow-brand-500/5 transition-all duration-300 hover:-translate-y-1"
data-testid="new-project-card"
>
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/5 via-transparent to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="relative p-6 h-full flex flex-col">
<div className="flex items-start gap-4 flex-1">
<div className="w-12 h-12 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/25 flex items-center justify-center group-hover:scale-105 group-hover:shadow-brand-500/40 transition-all duration-300 shrink-0">
<Plus className="w-6 h-6 text-white" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground mb-1.5">
New Project
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Create a new project from scratch with AI-powered development
</p>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="w-full mt-5 bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20 hover:shadow-brand-500/30 transition-all"
data-testid="create-new-project"
>
<Plus className="w-4 h-4 mr-2" />
Create Project
<ChevronDown className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
onClick={handleNewProject}
data-testid="quick-setup-option"
>
<Plus className="w-4 h-4 mr-2" />
Quick Setup
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleInteractiveMode}
data-testid="interactive-mode-option"
>
<MessageSquare className="w-4 h-4 mr-2" />
Interactive Mode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Open Project Card */}
<div
className="group relative rounded-xl border border-border bg-card/80 backdrop-blur-sm hover:bg-card hover:border-blue-500/30 hover:shadow-xl hover:shadow-blue-500/5 transition-all duration-300 cursor-pointer hover:-translate-y-1"
onClick={handleOpenProject}
data-testid="open-project-card"
>
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-blue-500/5 via-transparent to-cyan-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="relative p-6 h-full flex flex-col">
<div className="flex items-start gap-4 flex-1">
<div className="w-12 h-12 rounded-xl bg-muted border border-border flex items-center justify-center group-hover:bg-blue-500/10 group-hover:border-blue-500/30 group-hover:scale-105 transition-all duration-300 shrink-0">
<FolderOpen className="w-6 h-6 text-muted-foreground group-hover:text-blue-500 transition-colors duration-300" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground mb-1.5">
Open Project
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Open an existing project folder to continue working
</p>
</div>
</div>
<Button
variant="secondary"
className="w-full mt-5 bg-secondary/80 hover:bg-secondary text-foreground border border-border hover:border-blue-500/30 transition-all"
data-testid="open-existing-project"
>
<FolderOpen className="w-4 h-4 mr-2" />
Browse Folder
</Button>
</div>
</div>
</div>
</div>
)}
{/* Has projects - show project list */}
{hasProjects && (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Quick actions header */}
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-foreground">Your Projects</h2>
<div className="flex gap-2">
<Button variant="outline" onClick={handleOpenProject}>
<FolderOpen className="w-4 h-4 mr-2" />
Open Folder
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white">
<Plus className="w-4 h-4 mr-2" />
New Project
<ChevronDown className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={handleNewProject}>
<Plus className="w-4 h-4 mr-2" />
Quick Setup
</DropdownMenuItem>
<DropdownMenuItem onClick={handleInteractiveMode}>
<MessageSquare className="w-4 h-4 mr-2" />
Interactive Mode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Favorites section */}
{favoriteProjects.length > 0 && (
<div>
<div className="flex items-center gap-2.5 mb-4">
<div className="w-8 h-8 rounded-lg bg-yellow-500/10 flex items-center justify-center">
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
</div>
<h3 className="text-lg font-semibold text-foreground">Favorites</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{favoriteProjects.map((project) => (
<div
key={project.id}
className="group relative rounded-xl border border-yellow-500/30 bg-card/60 backdrop-blur-sm hover:bg-card hover:border-yellow-500/50 hover:shadow-lg hover:shadow-yellow-500/5 transition-all duration-300 cursor-pointer hover:-translate-y-0.5"
onClick={() => handleProjectClick(project)}
data-testid={`project-card-${project.id}`}
>
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-yellow-500/5 to-amber-600/5 opacity-0 group-hover:opacity-100 transition-all duration-300" />
<div className="relative p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-yellow-500/10 border border-yellow-500/30 flex items-center justify-center group-hover:bg-yellow-500/20 transition-all duration-300 shrink-0">
<Folder className="w-5 h-5 text-yellow-500" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate group-hover:text-yellow-500 transition-colors duration-300">
{project.name}
</p>
<p className="text-xs text-muted-foreground/70 truncate mt-1">
{project.path}
</p>
{project.lastOpened && (
<p className="text-xs text-muted-foreground mt-1.5">
{new Date(project.lastOpened).toLocaleDateString()}
</p>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => handleToggleFavorite(e, project.id)}
className="p-1.5 rounded-lg hover:bg-yellow-500/20 transition-colors"
title="Remove from favorites"
>
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
title="More options"
>
<MoreVertical className="w-4 h-4 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => handleRemoveProject(e, project)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="w-4 h-4 mr-2" />
Remove from Automaker
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Recent projects section */}
{recentProjects.length > 0 && (
<div>
<div className="flex items-center gap-2.5 mb-4">
<div className="w-8 h-8 rounded-lg bg-muted/50 flex items-center justify-center">
<Clock className="w-4 h-4 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold text-foreground">Recent Projects</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{recentProjects.map((project) => (
<div
key={project.id}
className="group relative rounded-xl border border-border bg-card/60 backdrop-blur-sm hover:bg-card hover:border-brand-500/40 hover:shadow-lg hover:shadow-brand-500/5 transition-all duration-300 cursor-pointer hover:-translate-y-0.5"
onClick={() => handleProjectClick(project)}
data-testid={`project-card-${project.id}`}
>
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all duration-300" />
<div className="relative p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0">
<Folder className="w-5 h-5 text-muted-foreground group-hover:text-brand-500 transition-colors duration-300" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate group-hover:text-brand-500 transition-colors duration-300">
{project.name}
</p>
<p className="text-xs text-muted-foreground/70 truncate mt-1">
{project.path}
</p>
{project.lastOpened && (
<p className="text-xs text-muted-foreground mt-1.5">
{new Date(project.lastOpened).toLocaleDateString()}
</p>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => handleToggleFavorite(e, project.id)}
className="p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
title="Add to favorites"
>
<Star className="w-4 h-4 text-muted-foreground hover:text-yellow-500" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
title="More options"
>
<MoreVertical className="w-4 h-4 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => handleRemoveProject(e, project)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="w-4 h-4 mr-2" />
Remove from Automaker
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
{/* Modals */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreating}
/>
<WorkspacePickerModal
open={showWorkspacePicker}
onOpenChange={setShowWorkspacePicker}
onSelect={handleWorkspaceSelect}
/>
{/* Remove project confirmation dialog */}
<Dialog open={!!projectToRemove} onOpenChange={(open) => !open && setProjectToRemove(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Remove Project</DialogTitle>
<DialogDescription>
Are you sure you want to remove <strong>{projectToRemove?.name}</strong> from
Automaker?
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
This will only remove the project from your Automaker projects list. The project files
on your computer will not be deleted.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setProjectToRemove(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirmRemove}>
<Trash2 className="w-4 h-4 mr-2" />
Remove Project
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Loading overlay */}
{isOpening && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm"
data-testid="project-opening-overlay"
>
<div className="flex flex-col items-center gap-4 p-8 rounded-2xl bg-card border border-border shadow-2xl">
<Loader2 className="w-10 h-10 text-brand-500 animate-spin" />
<p className="text-foreground font-medium">Opening project...</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -26,8 +26,7 @@ export function GitHubIssuesView() {
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
useState<ValidateIssueOptions | null>(null);
const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } =
useAppStore();
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
// Model override for validation
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
@@ -45,12 +44,6 @@ export function GitHubIssuesView() {
onShowValidationDialogChange: setShowValidationDialog,
});
// Get default AI profile for task creation
const defaultProfile = useMemo(() => {
if (!defaultAIProfileId) return null;
return aiProfiles.find((p) => p.id === defaultAIProfileId) ?? null;
}, [defaultAIProfileId, aiProfiles]);
// Get current branch from selected worktree
const currentBranch = useMemo(() => {
if (!currentProject?.path) return '';
@@ -99,9 +92,6 @@ export function GitHubIssuesView() {
.filter(Boolean)
.join('\n');
// Use profile default model
const featureModel = defaultProfile?.model ?? 'opus';
const feature = {
id: `issue-${issue.number}-${crypto.randomUUID()}`,
title: issue.title,
@@ -110,8 +100,8 @@ export function GitHubIssuesView() {
status: 'backlog' as const,
passes: false,
priority: getFeaturePriority(validation.estimatedComplexity),
model: featureModel,
thinkingLevel: defaultProfile?.thinkingLevel ?? 'none',
model: 'opus',
thinkingLevel: 'none' as const,
branchName: currentBranch,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -129,7 +119,7 @@ export function GitHubIssuesView() {
toast.error(err instanceof Error ? err.message : 'Failed to create task');
}
},
[currentProject?.path, defaultProfile, currentBranch]
[currentProject?.path, currentBranch]
);
if (loading) {

View File

@@ -0,0 +1,318 @@
// @ts-nocheck
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useAppStore, Feature } from '@/store/app-store';
import { GraphView } from './graph-view';
import { EditFeatureDialog, AddFeatureDialog, AgentOutputModal } from './board-view/dialogs';
import {
useBoardFeatures,
useBoardActions,
useBoardBackground,
useBoardPersistence,
} from './board-view/hooks';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { pathsEqual } from '@/lib/utils';
import { RefreshCw } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('GraphViewPage');
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
export function GraphViewPage() {
const {
currentProject,
updateFeature,
getCurrentWorktree,
getWorktrees,
setWorktrees,
setCurrentWorktree,
defaultSkipTests,
} = useAppStore();
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
const worktrees = useMemo(
() =>
currentProject
? (worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES)
: EMPTY_WORKTREES,
[currentProject, worktreesByProject]
);
// Load features
const {
features: hookFeatures,
isLoading,
persistedCategories,
loadFeatures,
saveCategory,
} = useBoardFeatures({ currentProject });
// Auto mode hook
const autoMode = useAutoMode();
const runningAutoTasks = autoMode.runningTasks;
// Search state
const [searchQuery, setSearchQuery] = useState('');
// Dialog states
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
const [showAddDialog, setShowAddDialog] = useState(false);
const [spawnParentFeature, setSpawnParentFeature] = useState<Feature | null>(null);
const [showOutputModal, setShowOutputModal] = useState(false);
const [outputFeature, setOutputFeature] = useState<Feature | null>(null);
// Worktree refresh key
const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0);
// Get current worktree info
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
// Get the branch for the currently selected worktree
const selectedWorktree = useMemo(() => {
if (currentWorktreePath === null) {
return worktrees.find((w) => w.isMain);
} else {
return worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
}
}, [worktrees, currentWorktreePath]);
const currentWorktreeBranch = selectedWorktree?.branch ?? null;
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
// Branch suggestions
const [branchSuggestions, setBranchSuggestions] = useState<string[]>([]);
useEffect(() => {
const fetchBranches = async () => {
if (!currentProject) {
setBranchSuggestions([]);
return;
}
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
setBranchSuggestions([]);
return;
}
const result = await api.worktree.listBranches(currentProject.path);
if (result.success && result.result?.branches) {
const localBranches = result.result.branches
.filter((b) => !b.isRemote)
.map((b) => b.name);
setBranchSuggestions(localBranches);
}
} catch (error) {
logger.error('Error fetching branches:', error);
setBranchSuggestions([]);
}
};
fetchBranches();
}, [currentProject, worktreeRefreshKey]);
// Branch card counts
const branchCardCounts = useMemo(() => {
return hookFeatures.reduce(
(counts, feature) => {
if (feature.status !== 'completed') {
const branch = feature.branchName ?? 'main';
counts[branch] = (counts[branch] || 0) + 1;
}
return counts;
},
{} as Record<string, number>
);
}, [hookFeatures]);
// Category suggestions
const categorySuggestions = useMemo(() => {
const featureCategories = hookFeatures.map((f) => f.category).filter(Boolean);
const allCategories = [...featureCategories, ...persistedCategories];
return [...new Set(allCategories)].sort();
}, [hookFeatures, persistedCategories]);
// Use persistence hook
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } = useBoardPersistence({
currentProject,
});
// Follow-up state (simplified for graph view)
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
const [followUpPrompt, setFollowUpPrompt] = useState('');
const [followUpImagePaths, setFollowUpImagePaths] = useState<any[]>([]);
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<Map<string, string>>(new Map());
// In-progress features for shortcuts
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === 'in_progress';
});
}, [hookFeatures, runningAutoTasks]);
// Board actions hook
const {
handleAddFeature,
handleUpdateFeature,
handleDeleteFeature,
handleStartImplementation,
handleResumeFeature,
handleViewOutput,
handleForceStopFeature,
handleOutputModalNumberKeyPress,
} = useBoardActions({
currentProject,
features: hookFeatures,
runningAutoTasks,
loadFeatures,
persistFeatureCreate,
persistFeatureUpdate,
persistFeatureDelete,
saveCategory,
setEditingFeature,
setShowOutputModal,
setOutputFeature,
followUpFeature,
followUpPrompt,
followUpImagePaths,
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
setShowFollowUpDialog: () => {},
inProgressFeaturesForShortcuts,
outputFeature,
projectPath: currentProject?.path || null,
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
onWorktreeAutoSelect: (newWorktree) => {
if (!currentProject) return;
const currentWorktrees = getWorktrees(currentProject.path);
const existingWorktree = currentWorktrees.find((w) => w.branch === newWorktree.branch);
if (!existingWorktree) {
const newWorktreeInfo = {
path: newWorktree.path,
branch: newWorktree.branch,
isMain: false,
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
}
setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch);
},
currentWorktreeBranch,
});
// Handle add and start feature
const handleAddAndStartFeature = useCallback(
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
if (newFeature) {
await handleStartImplementation(newFeature);
}
},
[handleAddFeature, handleStartImplementation]
);
if (!currentProject) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="graph-view-no-project">
<p className="text-muted-foreground">No project selected</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="graph-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg relative"
data-testid="graph-view-page"
>
{/* Graph View Content */}
<GraphView
features={hookFeatures}
runningAutoTasks={runningAutoTasks}
currentWorktreePath={currentWorktreePath}
currentWorktreeBranch={currentWorktreeBranch}
projectPath={currentProject?.path || null}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
onEditFeature={(feature) => setEditingFeature(feature)}
onViewOutput={handleViewOutput}
onStartTask={handleStartImplementation}
onStopTask={handleForceStopFeature}
onResumeTask={handleResumeFeature}
onUpdateFeature={updateFeature}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
/>
{/* Edit Feature Dialog */}
<EditFeatureDialog
feature={editingFeature}
onClose={() => setEditingFeature(null)}
onUpdate={handleUpdateFeature}
categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={false}
allFeatures={hookFeatures}
/>
{/* Add Feature Dialog (for spawning) */}
<AddFeatureDialog
open={showAddDialog}
onOpenChange={(open) => {
setShowAddDialog(open);
if (!open) {
setSpawnParentFeature(null);
}
}}
onAdd={handleAddFeature}
onAddAndStart={handleAddAndStartFeature}
categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
defaultSkipTests={defaultSkipTests}
defaultBranch={selectedWorktreeBranch}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={false}
parentFeature={spawnParentFeature}
allFeatures={hookFeatures}
/>
{/* Agent Output Modal */}
<AgentOutputModal
open={showOutputModal}
onClose={() => setShowOutputModal(false)}
featureDescription={outputFeature?.description || ''}
featureId={outputFeature?.id || ''}
featureStatus={outputFeature?.status}
onNumberKeyPress={handleOutputModalNumberKeyPress}
/>
</div>
);
}

View File

@@ -31,7 +31,10 @@ export function GraphControls({
return (
<Panel position="bottom-left" className="flex flex-col gap-2">
<TooltipProvider delayDuration={200}>
<div className="flex flex-col gap-1 p-1.5 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
<div
className="flex flex-col gap-1 p-1.5 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
>
{/* Zoom controls */}
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -110,7 +110,10 @@ export function GraphFilterControls({
return (
<Panel position="top-left" className="flex items-center gap-2">
<TooltipProvider delayDuration={200}>
<div className="flex items-center gap-2 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
<div
className="flex items-center gap-2 p-2 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
>
{/* Category Filter Dropdown */}
<Popover>
<Tooltip>

View File

@@ -44,7 +44,10 @@ const legendItems = [
export function GraphLegend() {
return (
<Panel position="bottom-right" className="pointer-events-none">
<div className="flex flex-wrap gap-3 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg pointer-events-auto text-popover-foreground">
<div
className="flex flex-wrap gap-3 p-2 rounded-lg backdrop-blur-sm border border-border shadow-lg pointer-events-auto text-popover-foreground"
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
>
{legendItems.map((item) => {
const Icon = item.icon;
return (

View File

@@ -75,6 +75,24 @@ const priorityConfig = {
3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' },
};
// Helper function to get border style with opacity (like KanbanCard does)
function getCardBorderStyle(
enabled: boolean,
opacity: number,
borderColor: string
): React.CSSProperties {
if (!enabled) {
return { borderWidth: '0px', borderColor: 'transparent' };
}
if (opacity !== 100) {
return {
borderWidth: '2px',
borderColor: `color-mix(in oklch, ${borderColor} ${opacity}%, transparent)`,
};
}
return { borderWidth: '2px' };
}
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
// Handle pipeline statuses by treating them like in_progress
const status = data.status || 'backlog';
@@ -91,6 +109,28 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
// Task is stopped if it's in_progress but not actively running
const isStopped = data.status === 'in_progress' && !data.isRunning;
// Background/theme settings with defaults
const cardOpacity = data.cardOpacity ?? 100;
const glassmorphism = data.cardGlassmorphism ?? true;
const cardBorderEnabled = data.cardBorderEnabled ?? true;
const cardBorderOpacity = data.cardBorderOpacity ?? 100;
// Get the border color based on status and error state
const borderColor = data.error
? 'var(--status-error)'
: config.borderClass.includes('border-border')
? 'var(--border)'
: config.borderClass.includes('status-in-progress')
? 'var(--status-in-progress)'
: config.borderClass.includes('status-waiting')
? 'var(--status-waiting)'
: config.borderClass.includes('status-success')
? 'var(--status-success)'
: 'var(--border)';
// Get computed border style
const borderStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity, borderColor);
return (
<>
{/* Target handle (left side - receives dependencies) */}
@@ -109,22 +149,26 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
<div
className={cn(
'min-w-[240px] max-w-[280px] rounded-xl border-2 bg-card shadow-md',
'min-w-[240px] max-w-[280px] rounded-xl shadow-md relative',
'transition-all duration-300',
config.borderClass,
selected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
data.isRunning && 'animate-pulse-subtle',
data.error && 'border-[var(--status-error)]',
// Filter highlight states
isMatched && 'graph-node-matched',
isHighlighted && !isMatched && 'graph-node-highlighted',
isDimmed && 'graph-node-dimmed'
)}
style={borderStyle}
>
{/* Background layer with opacity control - like KanbanCard */}
<div
className={cn('absolute inset-0 rounded-xl bg-card', glassmorphism && 'backdrop-blur-sm')}
style={{ opacity: cardOpacity / 100 }}
/>
{/* Header with status and actions */}
<div
className={cn(
'flex items-center justify-between px-3 py-2 rounded-t-[10px]',
'relative flex items-center justify-between px-3 py-2 rounded-t-[10px]',
config.bgClass
)}
>
@@ -301,7 +345,7 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
</div>
{/* Content */}
<div className="px-3 py-2">
<div className="relative px-3 py-2">
{/* Category */}
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wide">
{data.category}

View File

@@ -15,7 +15,8 @@ import {
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Feature } from '@/store/app-store';
import { Feature, useAppStore } from '@/store/app-store';
import { themeOptions } from '@/config/theme-options';
import {
TaskNode,
DependencyEdge,
@@ -47,6 +48,13 @@ const edgeTypes: any = {
dependency: DependencyEdge,
};
interface BackgroundSettings {
cardOpacity: number;
cardGlassmorphism: boolean;
cardBorderEnabled: boolean;
cardBorderOpacity: number;
}
interface GraphCanvasProps {
features: Feature[];
runningAutoTasks: string[];
@@ -56,6 +64,7 @@ interface GraphCanvasProps {
nodeActionCallbacks?: NodeActionCallbacks;
onCreateDependency?: (sourceId: string, targetId: string) => Promise<boolean>;
backgroundStyle?: React.CSSProperties;
backgroundSettings?: BackgroundSettings;
className?: string;
}
@@ -68,11 +77,42 @@ function GraphCanvasInner({
nodeActionCallbacks,
onCreateDependency,
backgroundStyle,
backgroundSettings,
className,
}: GraphCanvasProps) {
const [isLocked, setIsLocked] = useState(false);
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
// Determine React Flow color mode based on current theme
const effectiveTheme = useAppStore((state) => state.getEffectiveTheme());
const [systemColorMode, setSystemColorMode] = useState<'dark' | 'light'>(() => {
if (typeof window === 'undefined') return 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
useEffect(() => {
if (effectiveTheme !== 'system') return;
if (typeof window === 'undefined') return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const update = () => setSystemColorMode(mql.matches ? 'dark' : 'light');
update();
// Safari < 14 fallback
if (mql.addEventListener) {
mql.addEventListener('change', update);
return () => mql.removeEventListener('change', update);
}
// eslint-disable-next-line deprecation/deprecation
mql.addListener(update);
// eslint-disable-next-line deprecation/deprecation
return () => mql.removeListener(update);
}, [effectiveTheme]);
const themeOption = themeOptions.find((t) => t.value === effectiveTheme);
const colorMode =
effectiveTheme === 'system' ? systemColorMode : themeOption?.isDark ? 'dark' : 'light';
// Filter state (category, status, and negative toggle are local to graph view)
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
@@ -98,6 +138,7 @@ function GraphCanvasInner({
runningAutoTasks,
filterResult,
actionCallbacks: nodeActionCallbacks,
backgroundSettings,
});
// Apply layout
@@ -234,6 +275,7 @@ function GraphCanvasInner({
isValidConnection={isValidConnection}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
colorMode={colorMode}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
@@ -256,7 +298,8 @@ function GraphCanvasInner({
nodeStrokeWidth={3}
zoomable
pannable
className="!bg-popover/90 !border-border rounded-lg shadow-lg"
className="border-border! rounded-lg shadow-lg"
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
/>
<GraphControls
@@ -281,7 +324,10 @@ function GraphCanvasInner({
{/* Empty state when all nodes are filtered out */}
{filterResult.hasActiveFilter && filterResult.matchedNodeIds.size === 0 && (
<Panel position="top-center" className="mt-20">
<div className="flex flex-col items-center gap-3 p-6 rounded-lg bg-popover/95 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
<div
className="flex flex-col items-center gap-3 p-6 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 95%, transparent)' }}
>
<SearchX className="w-10 h-10 text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium">No matching tasks</p>

View File

@@ -44,7 +44,7 @@ export function GraphView({
const { currentProject } = useAppStore();
// Use the same background hook as the board view
const { backgroundImageStyle } = useBoardBackground({ currentProject });
const { backgroundImageStyle, backgroundSettings } = useBoardBackground({ currentProject });
// Filter features by current worktree (same logic as board view)
const filteredFeatures = useMemo(() => {
@@ -213,6 +213,7 @@ export function GraphView({
nodeActionCallbacks={nodeActionCallbacks}
onCreateDependency={handleCreateDependency}
backgroundStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
className="h-full"
/>
</div>

View File

@@ -18,6 +18,11 @@ export interface TaskNodeData extends Feature {
isMatched?: boolean;
isHighlighted?: boolean;
isDimmed?: boolean;
// Background/theme settings
cardOpacity?: number;
cardGlassmorphism?: boolean;
cardBorderEnabled?: boolean;
cardBorderOpacity?: number;
// Action callbacks
onViewLogs?: () => void;
onViewDetails?: () => void;
@@ -48,11 +53,19 @@ export interface NodeActionCallbacks {
onDeleteDependency?: (sourceId: string, targetId: string) => void;
}
interface BackgroundSettings {
cardOpacity: number;
cardGlassmorphism: boolean;
cardBorderEnabled: boolean;
cardBorderOpacity: number;
}
interface UseGraphNodesProps {
features: Feature[];
runningAutoTasks: string[];
filterResult?: GraphFilterResult;
actionCallbacks?: NodeActionCallbacks;
backgroundSettings?: BackgroundSettings;
}
/**
@@ -64,6 +77,7 @@ export function useGraphNodes({
runningAutoTasks,
filterResult,
actionCallbacks,
backgroundSettings,
}: UseGraphNodesProps) {
const { nodes, edges } = useMemo(() => {
const nodeList: TaskNode[] = [];
@@ -102,6 +116,11 @@ export function useGraphNodes({
isMatched,
isHighlighted,
isDimmed,
// Background/theme settings
cardOpacity: backgroundSettings?.cardOpacity,
cardGlassmorphism: backgroundSettings?.cardGlassmorphism,
cardBorderEnabled: backgroundSettings?.cardBorderEnabled,
cardBorderOpacity: backgroundSettings?.cardBorderOpacity,
// Action callbacks (bound to this feature's ID)
onViewLogs: actionCallbacks?.onViewLogs
? () => actionCallbacks.onViewLogs!(feature.id)
@@ -163,7 +182,7 @@ export function useGraphNodes({
});
return { nodes: nodeList, edges: edgeList };
}, [features, runningAutoTasks, filterResult, actionCallbacks]);
}, [features, runningAutoTasks, filterResult, actionCallbacks, backgroundSettings]);
return { nodes, edges };
}

View File

@@ -3,7 +3,7 @@
* First page users see - shows all ideas ready for accept/reject
*/
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect, useCallback } from 'react';
import { Loader2, AlertCircle, Plus, X, Sparkles, Lightbulb } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -17,6 +17,7 @@ import type { AnalysisSuggestion } from '@automaker/types';
interface IdeationDashboardProps {
onGenerateIdeas: () => void;
onAcceptAllReady?: (isReady: boolean, count: number, handler: () => Promise<void>) => void;
}
function SuggestionCard({
@@ -37,14 +38,16 @@ function SuggestionCard({
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium">{suggestion.title}</h4>
<Badge variant="outline" className="text-xs">
{suggestion.priority}
</Badge>
<Badge variant="secondary" className="text-xs">
{job.prompt.title}
</Badge>
<div className="flex items-start gap-2 mb-1">
<h4 className="font-medium shrink-0">{suggestion.title}</h4>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="text-xs whitespace-nowrap">
{suggestion.priority}
</Badge>
<Badge variant="secondary" className="text-xs whitespace-nowrap">
{job.prompt.title}
</Badge>
</div>
</div>
<p className="text-sm text-muted-foreground">{suggestion.description}</p>
{suggestion.rationale && (
@@ -166,11 +169,12 @@ function TagFilter({
);
}
export function IdeationDashboard({ onGenerateIdeas }: IdeationDashboardProps) {
export function IdeationDashboard({ onGenerateIdeas, onAcceptAllReady }: IdeationDashboardProps) {
const currentProject = useAppStore((s) => s.currentProject);
const generationJobs = useIdeationStore((s) => s.generationJobs);
const removeSuggestionFromJob = useIdeationStore((s) => s.removeSuggestionFromJob);
const [addingId, setAddingId] = useState<string | null>(null);
const [isAcceptingAll, setIsAcceptingAll] = useState(false);
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
// Get jobs for current project only (memoized to prevent unnecessary re-renders)
@@ -270,6 +274,54 @@ export function IdeationDashboard({ onGenerateIdeas }: IdeationDashboardProps) {
toast.info('Idea removed');
};
// Accept all filtered suggestions
const handleAcceptAll = useCallback(async () => {
if (!currentProject?.path || filteredSuggestions.length === 0) {
return;
}
setIsAcceptingAll(true);
const api = getElectronAPI();
let successCount = 0;
let failCount = 0;
// Process all filtered suggestions
for (const { suggestion, job } of filteredSuggestions) {
try {
const result = await api.ideation?.addSuggestionToBoard(currentProject.path, suggestion);
if (result?.success) {
removeSuggestionFromJob(job.id, suggestion.id);
successCount++;
} else {
failCount++;
}
} catch (error) {
console.error('Failed to add suggestion to board:', error);
failCount++;
}
}
setIsAcceptingAll(false);
if (successCount > 0 && failCount === 0) {
toast.success(`Added ${successCount} idea${successCount > 1 ? 's' : ''} to board`);
} else if (successCount > 0 && failCount > 0) {
toast.warning(
`Added ${successCount} idea${successCount > 1 ? 's' : ''}, ${failCount} failed`
);
} else {
toast.error('Failed to add ideas to board');
}
}, [currentProject?.path, filteredSuggestions, removeSuggestionFromJob]);
// Notify parent about accept all readiness
useEffect(() => {
if (onAcceptAllReady) {
const isReady = filteredSuggestions.length > 0 && !isAcceptingAll && !addingId;
onAcceptAllReady(isReady, filteredSuggestions.length, handleAcceptAll);
}
}, [filteredSuggestions.length, isAcceptingAll, addingId, handleAcceptAll, onAcceptAllReady]);
const isEmpty = allSuggestions.length === 0 && activeJobs.length === 0;
return (

View File

@@ -3,7 +3,7 @@
* Dashboard-first design with Generate Ideas flow
*/
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { useIdeationStore } from '@/store/ideation-store';
import { useAppStore } from '@/store/app-store';
import { PromptCategoryGrid } from './components/prompt-category-grid';
@@ -11,7 +11,7 @@ import { PromptList } from './components/prompt-list';
import { IdeationDashboard } from './components/ideation-dashboard';
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
import { Button } from '@/components/ui/button';
import { ArrowLeft, ChevronRight, Lightbulb } from 'lucide-react';
import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Loader2 } from 'lucide-react';
import type { IdeaCategory } from '@automaker/types';
import type { IdeationMode } from '@/store/ideation-store';
@@ -67,12 +67,20 @@ function IdeationHeader({
onNavigate,
onGenerateIdeas,
onBack,
acceptAllReady,
acceptAllCount,
onAcceptAll,
isAcceptingAll,
}: {
currentMode: IdeationMode;
selectedCategory: IdeaCategory | null;
onNavigate: (mode: IdeationMode, category?: IdeaCategory | null) => void;
onGenerateIdeas: () => void;
onBack: () => void;
acceptAllReady: boolean;
acceptAllCount: number;
onAcceptAll: () => void;
isAcceptingAll: boolean;
}) {
const { getCategoryById } = useGuidedPrompts();
const showBackButton = currentMode === 'prompts';
@@ -120,6 +128,21 @@ function IdeationHeader({
</div>
<div className="flex gap-2 items-center">
{currentMode === 'dashboard' && acceptAllReady && (
<Button
onClick={onAcceptAll}
variant="outline"
className="gap-2"
disabled={isAcceptingAll}
>
{isAcceptingAll ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCheck className="w-4 h-4" />
)}
Accept All ({acceptAllCount})
</Button>
)}
<Button onClick={onGenerateIdeas} className="gap-2">
<Lightbulb className="w-4 h-4" />
Generate Ideas
@@ -133,6 +156,32 @@ export function IdeationView() {
const currentProject = useAppStore((s) => s.currentProject);
const { currentMode, selectedCategory, setMode, setCategory } = useIdeationStore();
// Accept all state
const [acceptAllReady, setAcceptAllReady] = useState(false);
const [acceptAllCount, setAcceptAllCount] = useState(0);
const [acceptAllHandler, setAcceptAllHandler] = useState<(() => Promise<void>) | null>(null);
const [isAcceptingAll, setIsAcceptingAll] = useState(false);
const handleAcceptAllReady = useCallback(
(isReady: boolean, count: number, handler: () => Promise<void>) => {
setAcceptAllReady(isReady);
setAcceptAllCount(count);
setAcceptAllHandler(() => handler);
},
[]
);
const handleAcceptAll = useCallback(async () => {
if (acceptAllHandler) {
setIsAcceptingAll(true);
try {
await acceptAllHandler();
} finally {
setIsAcceptingAll(false);
}
}
}, [acceptAllHandler]);
const handleNavigate = useCallback(
(mode: IdeationMode, category?: IdeaCategory | null) => {
setMode(mode);
@@ -192,10 +241,19 @@ export function IdeationView() {
onNavigate={handleNavigate}
onGenerateIdeas={handleGenerateIdeas}
onBack={handleBackFromPrompts}
acceptAllReady={acceptAllReady}
acceptAllCount={acceptAllCount}
onAcceptAll={handleAcceptAll}
isAcceptingAll={isAcceptingAll}
/>
{/* Dashboard - main view */}
{currentMode === 'dashboard' && <IdeationDashboard onGenerateIdeas={handleGenerateIdeas} />}
{currentMode === 'dashboard' && (
<IdeationDashboard
onGenerateIdeas={handleGenerateIdeas}
onAcceptAllReady={handleAcceptAllReady}
/>
)}
{/* Prompts - category selection */}
{currentMode === 'prompts' && !selectedCategory && (

View File

@@ -13,7 +13,15 @@
import { useReducer, useEffect, useRef } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { login, getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client';
import {
login,
getHttpApiClient,
getServerUrlSync,
getApiKey,
getSessionToken,
initApiKey,
waitForApiKeyInit,
} from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react';
@@ -92,6 +100,7 @@ function reducer(state: State, action: Action): State {
const MAX_RETRIES = 5;
const BACKOFF_BASE_MS = 400;
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
// =============================================================================
// Imperative Flow Logic (runs once on mount)
@@ -102,7 +111,9 @@ const BACKOFF_BASE_MS = 400;
* Unlike the httpClient methods, this does NOT call handleUnauthorized()
* which would navigate us away to /logged-out.
*
* Relies on HTTP-only session cookie being sent via credentials: 'include'.
* Supports both:
* - Electron mode: Uses X-API-Key header (API key from IPC)
* - Web mode: Uses HTTP-only session cookie
*
* Returns: { authenticated: true } or { authenticated: false }
* Throws: on network errors (for retry logic)
@@ -110,9 +121,31 @@ const BACKOFF_BASE_MS = 400;
async function checkAuthStatusSafe(): Promise<{ authenticated: boolean }> {
const serverUrl = getServerUrlSync();
// Wait for API key to be initialized before checking auth
// This ensures we have a valid API key to send in the header
await waitForApiKeyInit();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Electron mode: use API key header
const apiKey = getApiKey();
if (apiKey) {
headers['X-API-Key'] = apiKey;
}
// Add session token header if available (web mode)
const sessionToken = getSessionToken();
if (sessionToken) {
headers['X-Session-Token'] = sessionToken;
}
const response = await fetch(`${serverUrl}/api/auth/status`, {
credentials: 'include', // Send HTTP-only session cookie
headers,
credentials: 'include',
signal: AbortSignal.timeout(5000),
cache: NO_STORE_CACHE_MODE,
});
// Any response means server is reachable
@@ -246,6 +279,14 @@ export function LoginView() {
const [state, dispatch] = useReducer(reducer, initialState);
const retryControllerRef = useRef<AbortController | null>(null);
// Initialize API key before checking session
// This ensures getApiKey() returns a valid value in checkAuthStatusSafe()
useEffect(() => {
initApiKey().catch((error) => {
console.warn('Failed to initialize API key:', error);
});
}, []);
// Run initial server/session check on mount.
// IMPORTANT: Do not "run once" via a ref guard here.
// In React StrictMode (dev), effects mount -> cleanup -> mount.

View File

@@ -0,0 +1,624 @@
import { useEffect, useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import {
RefreshCw,
FileText,
Trash2,
Save,
Brain,
Eye,
Pencil,
FilePlus,
MoreVertical,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { Markdown } from '../ui/markdown';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
const logger = createLogger('MemoryView');
interface MemoryFile {
name: string;
content?: string;
path: string;
}
export function MemoryView() {
const { currentProject } = useAppStore();
const [memoryFiles, setMemoryFiles] = useState<MemoryFile[]>([]);
const [selectedFile, setSelectedFile] = useState<MemoryFile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [editedContent, setEditedContent] = useState('');
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renameFileName, setRenameFileName] = useState('');
const [isPreviewMode, setIsPreviewMode] = useState(true);
// Create Memory file modal state
const [isCreateMemoryOpen, setIsCreateMemoryOpen] = useState(false);
const [newMemoryName, setNewMemoryName] = useState('');
const [newMemoryContent, setNewMemoryContent] = useState('');
// Get memory directory path
const getMemoryPath = useCallback(() => {
if (!currentProject) return null;
return `${currentProject.path}/.automaker/memory`;
}, [currentProject]);
const isMarkdownFile = (filename: string): boolean => {
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return ext === '.md' || ext === '.markdown';
};
// Load memory files
const loadMemoryFiles = useCallback(async () => {
const memoryPath = getMemoryPath();
if (!memoryPath) return;
setIsLoading(true);
try {
const api = getElectronAPI();
// Ensure memory directory exists
await api.mkdir(memoryPath);
// Read directory contents
const result = await api.readdir(memoryPath);
if (result.success && result.entries) {
const files: MemoryFile[] = result.entries
.filter((entry) => entry.isFile && isMarkdownFile(entry.name))
.map((entry) => ({
name: entry.name,
path: `${memoryPath}/${entry.name}`,
}));
setMemoryFiles(files);
}
} catch (error) {
logger.error('Failed to load memory files:', error);
} finally {
setIsLoading(false);
}
}, [getMemoryPath]);
useEffect(() => {
loadMemoryFiles();
}, [loadMemoryFiles]);
// Load selected file content
const loadFileContent = useCallback(async (file: MemoryFile) => {
try {
const api = getElectronAPI();
const result = await api.readFile(file.path);
if (result.success && result.content !== undefined) {
setEditedContent(result.content);
setSelectedFile({ ...file, content: result.content });
setHasChanges(false);
}
} catch (error) {
logger.error('Failed to load file content:', error);
}
}, []);
// Select a file
const handleSelectFile = (file: MemoryFile) => {
if (hasChanges) {
// Could add a confirmation dialog here
}
loadFileContent(file);
setIsPreviewMode(true);
};
// Save current file
const saveFile = async () => {
if (!selectedFile) return;
setIsSaving(true);
try {
const api = getElectronAPI();
await api.writeFile(selectedFile.path, editedContent);
setSelectedFile({ ...selectedFile, content: editedContent });
setHasChanges(false);
} catch (error) {
logger.error('Failed to save file:', error);
} finally {
setIsSaving(false);
}
};
// Handle content change
const handleContentChange = (value: string) => {
setEditedContent(value);
setHasChanges(true);
};
// Handle create memory file
const handleCreateMemory = async () => {
const memoryPath = getMemoryPath();
if (!memoryPath || !newMemoryName.trim()) return;
try {
const api = getElectronAPI();
let filename = newMemoryName.trim();
// Add .md extension if not provided
if (!filename.includes('.')) {
filename += '.md';
}
const filePath = `${memoryPath}/${filename}`;
// Write memory file
await api.writeFile(filePath, newMemoryContent);
// Reload files
await loadMemoryFiles();
// Reset and close modal
setIsCreateMemoryOpen(false);
setNewMemoryName('');
setNewMemoryContent('');
} catch (error) {
logger.error('Failed to create memory file:', error);
setIsCreateMemoryOpen(false);
setNewMemoryName('');
setNewMemoryContent('');
}
};
// Delete selected file
const handleDeleteFile = async () => {
if (!selectedFile) return;
try {
const api = getElectronAPI();
await api.deleteFile(selectedFile.path);
setIsDeleteDialogOpen(false);
setSelectedFile(null);
setEditedContent('');
setHasChanges(false);
await loadMemoryFiles();
} catch (error) {
logger.error('Failed to delete file:', error);
}
};
// Rename selected file
const handleRenameFile = async () => {
const memoryPath = getMemoryPath();
if (!selectedFile || !memoryPath || !renameFileName.trim()) return;
let newName = renameFileName.trim();
// Add .md extension if not provided
if (!newName.includes('.')) {
newName += '.md';
}
if (newName === selectedFile.name) {
setIsRenameDialogOpen(false);
return;
}
try {
const api = getElectronAPI();
const newPath = `${memoryPath}/${newName}`;
// Check if file with new name already exists
const exists = await api.exists(newPath);
if (exists) {
logger.error('A file with this name already exists');
return;
}
// Read current file content
const result = await api.readFile(selectedFile.path);
if (!result.success || result.content === undefined) {
logger.error('Failed to read file for rename');
return;
}
// Write to new path
await api.writeFile(newPath, result.content);
// Delete old file
await api.deleteFile(selectedFile.path);
setIsRenameDialogOpen(false);
setRenameFileName('');
// Reload files and select the renamed file
await loadMemoryFiles();
// Update selected file with new name and path
const renamedFile: MemoryFile = {
name: newName,
path: newPath,
content: result.content,
};
setSelectedFile(renamedFile);
} catch (error) {
logger.error('Failed to rename file:', error);
}
};
// Delete file from list (used by dropdown)
const handleDeleteFromList = async (file: MemoryFile) => {
try {
const api = getElectronAPI();
await api.deleteFile(file.path);
// Clear selection if this was the selected file
if (selectedFile?.path === file.path) {
setSelectedFile(null);
setEditedContent('');
setHasChanges(false);
}
await loadMemoryFiles();
} catch (error) {
logger.error('Failed to delete file:', error);
}
};
if (!currentProject) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="memory-view-no-project">
<p className="text-muted-foreground">No project selected</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="memory-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="memory-view">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
<Brain className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">Memory Layer</h1>
<p className="text-sm text-muted-foreground">
View and edit AI memory files for this project
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={loadMemoryFiles}
data-testid="refresh-memory-button"
>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
<Button
size="sm"
onClick={() => setIsCreateMemoryOpen(true)}
data-testid="create-memory-button"
>
<FilePlus className="w-4 h-4 mr-2" />
Create Memory File
</Button>
</div>
</div>
{/* Main content area with file list and editor */}
<div className="flex-1 flex overflow-hidden">
{/* Left Panel - File List */}
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
<div className="p-3 border-b border-border">
<h2 className="text-sm font-semibold text-muted-foreground">
Memory Files ({memoryFiles.length})
</h2>
</div>
<div className="flex-1 overflow-y-auto p-2" data-testid="memory-file-list">
{memoryFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-4">
<Brain className="w-8 h-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
No memory files yet.
<br />
Create a memory file to get started.
</p>
</div>
) : (
<div className="space-y-1">
{memoryFiles.map((file) => (
<div
key={file.path}
onClick={() => handleSelectFile(file)}
className={cn(
'group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors cursor-pointer',
selectedFile?.path === file.path
? 'bg-primary/20 text-foreground border border-primary/30'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
)}
data-testid={`memory-file-${file.name}`}
>
<FileText className="w-4 h-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate text-sm block">{file.name}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-accent rounded transition-opacity"
data-testid={`memory-file-menu-${file.name}`}
>
<MoreVertical className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setRenameFileName(file.name);
setSelectedFile(file);
setIsRenameDialogOpen(true);
}}
data-testid={`rename-memory-file-${file.name}`}
>
<Pencil className="w-4 h-4 mr-2" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteFromList(file)}
className="text-red-500 focus:text-red-500"
data-testid={`delete-memory-file-${file.name}`}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
)}
</div>
</div>
{/* Right Panel - Editor/Preview */}
<div className="flex-1 flex flex-col overflow-hidden">
{selectedFile ? (
<>
{/* File toolbar */}
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium truncate">{selectedFile.name}</span>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsPreviewMode(!isPreviewMode)}
data-testid="toggle-preview-mode"
>
{isPreviewMode ? (
<>
<Pencil className="w-4 h-4 mr-2" />
Edit
</>
) : (
<>
<Eye className="w-4 h-4 mr-2" />
Preview
</>
)}
</Button>
<Button
size="sm"
onClick={saveFile}
disabled={!hasChanges || isSaving}
data-testid="save-memory-file"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsDeleteDialogOpen(true)}
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
data-testid="delete-memory-file"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{/* Content area */}
<div className="flex-1 overflow-hidden p-4">
{isPreviewMode ? (
<Card className="h-full overflow-auto p-4" data-testid="markdown-preview">
<Markdown>{editedContent}</Markdown>
</Card>
) : (
<Card className="h-full overflow-hidden">
<textarea
className="w-full h-full p-4 font-mono text-sm bg-transparent resize-none focus:outline-none"
value={editedContent}
onChange={(e) => handleContentChange(e.target.value)}
placeholder="Enter memory content here..."
spellCheck={false}
data-testid="memory-editor"
/>
</Card>
)}
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<Brain className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
<p className="text-foreground-secondary">Select a file to view or edit</p>
<p className="text-muted-foreground text-sm mt-1">
Memory files help AI agents learn from past interactions
</p>
</div>
</div>
)}
</div>
</div>
{/* Create Memory Dialog */}
<Dialog open={isCreateMemoryOpen} onOpenChange={setIsCreateMemoryOpen}>
<DialogContent
data-testid="create-memory-dialog"
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
>
<DialogHeader>
<DialogTitle>Create Memory File</DialogTitle>
<DialogDescription>
Create a new memory file to store learnings and patterns for AI agents.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4 flex-1 overflow-auto">
<div className="space-y-2">
<Label htmlFor="memory-filename">File Name</Label>
<Input
id="memory-filename"
value={newMemoryName}
onChange={(e) => setNewMemoryName(e.target.value)}
placeholder="my-learnings.md"
data-testid="new-memory-name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="memory-content">Content</Label>
<textarea
id="memory-content"
value={newMemoryContent}
onChange={(e) => setNewMemoryContent(e.target.value)}
placeholder="Enter your memory content here..."
className="w-full h-60 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
spellCheck={false}
data-testid="new-memory-content"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsCreateMemoryOpen(false);
setNewMemoryName('');
setNewMemoryContent('');
}}
>
Cancel
</Button>
<Button
onClick={handleCreateMemory}
disabled={!newMemoryName.trim()}
data-testid="confirm-create-memory"
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent data-testid="delete-memory-dialog">
<DialogHeader>
<DialogTitle>Delete Memory File</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{selectedFile?.name}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteFile}
className="bg-red-600 hover:bg-red-700"
data-testid="confirm-delete-file"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rename Dialog */}
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
<DialogContent data-testid="rename-memory-dialog">
<DialogHeader>
<DialogTitle>Rename Memory File</DialogTitle>
<DialogDescription>Enter a new name for "{selectedFile?.name}".</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="space-y-2">
<Label htmlFor="rename-filename">File Name</Label>
<Input
id="rename-filename"
value={renameFileName}
onChange={(e) => setRenameFileName(e.target.value)}
placeholder="Enter new filename"
data-testid="rename-file-input"
onKeyDown={(e) => {
if (e.key === 'Enter' && renameFileName.trim()) {
handleRenameFile();
}
}}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsRenameDialogOpen(false);
setRenameFileName('');
}}
>
Cancel
</Button>
<Button
onClick={handleRenameFile}
disabled={!renameFileName.trim() || renameFileName === selectedFile?.name}
data-testid="confirm-rename-file"
>
Rename
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,275 +0,0 @@
import { useState, useMemo, useCallback } from 'react';
import { useAppStore, AIProfile } from '@/store/app-store';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from '@/hooks/use-keyboard-shortcuts';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
import {
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { SortableProfileCard, ProfileForm, ProfilesHeader } from './profiles-view/components';
export function ProfilesView() {
const {
aiProfiles,
addAIProfile,
updateAIProfile,
removeAIProfile,
reorderAIProfiles,
resetAIProfiles,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingProfile, setEditingProfile] = useState<AIProfile | null>(null);
const [profileToDelete, setProfileToDelete] = useState<AIProfile | null>(null);
// Sensors for drag-and-drop
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
})
);
// Separate built-in and custom profiles
const builtInProfiles = useMemo(() => aiProfiles.filter((p) => p.isBuiltIn), [aiProfiles]);
const customProfiles = useMemo(() => aiProfiles.filter((p) => !p.isBuiltIn), [aiProfiles]);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = aiProfiles.findIndex((p) => p.id === active.id);
const newIndex = aiProfiles.findIndex((p) => p.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderAIProfiles(oldIndex, newIndex);
}
}
},
[aiProfiles, reorderAIProfiles]
);
const handleAddProfile = (profile: Omit<AIProfile, 'id'>) => {
addAIProfile(profile);
setShowAddDialog(false);
toast.success('Profile created', {
description: `Created "${profile.name}" profile`,
});
};
const handleUpdateProfile = (profile: Omit<AIProfile, 'id'>) => {
if (editingProfile) {
updateAIProfile(editingProfile.id, profile);
setEditingProfile(null);
toast.success('Profile updated', {
description: `Updated "${profile.name}" profile`,
});
}
};
const confirmDeleteProfile = () => {
if (!profileToDelete) return;
removeAIProfile(profileToDelete.id);
toast.success('Profile deleted', {
description: `Deleted "${profileToDelete.name}" profile`,
});
setProfileToDelete(null);
};
const handleResetProfiles = () => {
resetAIProfiles();
toast.success('Profiles refreshed', {
description: 'Default profiles have been updated to the latest version',
});
};
// Build keyboard shortcuts for profiles view
const profilesShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcutsList: KeyboardShortcut[] = [];
// Add profile shortcut - when in profiles view
shortcutsList.push({
key: shortcuts.addProfile,
action: () => setShowAddDialog(true),
description: 'Create new profile',
});
return shortcutsList;
}, [shortcuts]);
// Register keyboard shortcuts for profiles view
useKeyboardShortcuts(profilesShortcuts);
return (
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="profiles-view">
{/* Header Section */}
<ProfilesHeader
onResetProfiles={handleResetProfiles}
onAddProfile={() => setShowAddDialog(true)}
addProfileHotkey={shortcuts.addProfile}
/>
{/* Content */}
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-4xl mx-auto space-y-8">
{/* Custom Profiles Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-foreground">Custom Profiles</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{customProfiles.length}
</span>
</div>
{customProfiles.length === 0 ? (
<div
className="group rounded-xl border border-dashed border-border p-8 text-center transition-all duration-300 hover:border-primary hover:bg-primary/5 cursor-pointer"
onClick={() => setShowAddDialog(true)}
>
<Sparkles className="w-10 h-10 text-muted-foreground mx-auto mb-3 opacity-50 transition-all duration-300 group-hover:text-primary group-hover:opacity-100 group-hover:scale-110 group-hover:rotate-12" />
<p className="text-muted-foreground transition-colors duration-300 group-hover:text-foreground">
No custom profiles yet. Create one to get started!
</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={customProfiles.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{customProfiles.map((profile) => (
<SortableProfileCard
key={profile.id}
profile={profile}
onEdit={() => setEditingProfile(profile)}
onDelete={() => setProfileToDelete(profile)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
{/* Built-in Profiles Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-foreground">Built-in Profiles</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{builtInProfiles.length}
</span>
</div>
<p className="text-sm text-muted-foreground mb-4">
Pre-configured profiles for common use cases. These cannot be edited or deleted.
</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={builtInProfiles.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{builtInProfiles.map((profile) => (
<SortableProfileCard
key={profile.id}
profile={profile}
onEdit={() => {}}
onDelete={() => {}}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
</div>
</div>
{/* Add Profile Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent
data-testid="add-profile-dialog"
className="flex flex-col max-h-[calc(100vh-4rem)]"
>
<DialogHeader className="shrink-0">
<DialogTitle>Create New Profile</DialogTitle>
<DialogDescription>Define a reusable model configuration preset.</DialogDescription>
</DialogHeader>
<ProfileForm
profile={{}}
onSave={handleAddProfile}
onCancel={() => setShowAddDialog(false)}
isEditing={false}
hotkeyActive={showAddDialog}
/>
</DialogContent>
</Dialog>
{/* Edit Profile Dialog */}
<Dialog open={!!editingProfile} onOpenChange={() => setEditingProfile(null)}>
<DialogContent
data-testid="edit-profile-dialog"
className="flex flex-col max-h-[calc(100vh-4rem)]"
>
<DialogHeader className="shrink-0">
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Modify your profile settings.</DialogDescription>
</DialogHeader>
{editingProfile && (
<ProfileForm
profile={editingProfile}
onSave={handleUpdateProfile}
onCancel={() => setEditingProfile(null)}
isEditing={true}
hotkeyActive={!!editingProfile}
/>
)}
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<DeleteConfirmDialog
open={!!profileToDelete}
onOpenChange={(open) => !open && setProfileToDelete(null)}
onConfirm={confirmDeleteProfile}
title="Delete Profile"
description={
profileToDelete
? `Are you sure you want to delete "${profileToDelete.name}"? This action cannot be undone.`
: ''
}
confirmText="Delete Profile"
testId="delete-profile-confirm-dialog"
confirmTestId="confirm-delete-profile-button"
/>
</div>
);
}

View File

@@ -1,3 +0,0 @@
export { SortableProfileCard } from './sortable-profile-card';
export { ProfileForm } from './profile-form';
export { ProfilesHeader } from './profiles-header';

View File

@@ -1,560 +0,0 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { cn, modelSupportsThinking } from '@/lib/utils';
import { DialogFooter } from '@/components/ui/dialog';
import { Brain } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import { toast } from 'sonner';
import type {
AIProfile,
ModelAlias,
ThinkingLevel,
ModelProvider,
CursorModelId,
CodexModelId,
OpencodeModelId,
} from '@automaker/types';
import {
CURSOR_MODEL_MAP,
cursorModelHasThinking,
CODEX_MODEL_MAP,
OPENCODE_MODELS,
DEFAULT_OPENCODE_MODEL,
} from '@automaker/types';
import { useAppStore } from '@/store/app-store';
import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from '../constants';
interface ProfileFormProps {
profile: Partial<AIProfile>;
onSave: (profile: Omit<AIProfile, 'id'>) => void;
onCancel: () => void;
isEditing: boolean;
hotkeyActive: boolean;
}
export function ProfileForm({
profile,
onSave,
onCancel,
isEditing,
hotkeyActive,
}: ProfileFormProps) {
const { enabledCursorModels } = useAppStore();
const [formData, setFormData] = useState({
name: profile.name || '',
description: profile.description || '',
provider: (profile.provider || 'claude') as ModelProvider,
// Claude-specific
model: profile.model || ('sonnet' as ModelAlias),
thinkingLevel: profile.thinkingLevel || ('none' as ThinkingLevel),
// Cursor-specific
cursorModel: profile.cursorModel || ('auto' as CursorModelId),
// Codex-specific - use a valid CodexModelId from CODEX_MODEL_MAP
codexModel: profile.codexModel || (CODEX_MODEL_MAP.gpt52Codex as CodexModelId),
// OpenCode-specific
opencodeModel: profile.opencodeModel || (DEFAULT_OPENCODE_MODEL as OpencodeModelId),
icon: profile.icon || 'Brain',
});
// Sync formData with profile prop when it changes
useEffect(() => {
setFormData({
name: profile.name || '',
description: profile.description || '',
provider: (profile.provider || 'claude') as ModelProvider,
// Claude-specific
model: profile.model || ('sonnet' as ModelAlias),
thinkingLevel: profile.thinkingLevel || ('none' as ThinkingLevel),
// Cursor-specific
cursorModel: profile.cursorModel || ('auto' as CursorModelId),
// Codex-specific - use a valid CodexModelId from CODEX_MODEL_MAP
codexModel: profile.codexModel || (CODEX_MODEL_MAP.gpt52Codex as CodexModelId),
// OpenCode-specific
opencodeModel: profile.opencodeModel || (DEFAULT_OPENCODE_MODEL as OpencodeModelId),
icon: profile.icon || 'Brain',
});
}, [profile]);
const supportsThinking = formData.provider === 'claude' && modelSupportsThinking(formData.model);
const handleProviderChange = (provider: ModelProvider) => {
setFormData({
...formData,
provider,
// Only reset Claude fields when switching TO Claude; preserve otherwise
model: provider === 'claude' ? 'sonnet' : formData.model,
thinkingLevel: provider === 'claude' ? 'none' : formData.thinkingLevel,
// Reset cursor/codex/opencode models when switching to that provider
cursorModel: provider === 'cursor' ? 'auto' : formData.cursorModel,
codexModel:
provider === 'codex' ? (CODEX_MODEL_MAP.gpt52Codex as CodexModelId) : formData.codexModel,
opencodeModel:
provider === 'opencode'
? (DEFAULT_OPENCODE_MODEL as OpencodeModelId)
: formData.opencodeModel,
});
};
const handleModelChange = (model: ModelAlias) => {
setFormData({
...formData,
model,
});
};
const handleCursorModelChange = (cursorModel: CursorModelId) => {
setFormData({
...formData,
cursorModel,
});
};
const handleCodexModelChange = (codexModel: CodexModelId) => {
setFormData({
...formData,
codexModel,
});
};
const handleOpencodeModelChange = (opencodeModel: OpencodeModelId) => {
setFormData({
...formData,
opencodeModel,
});
};
const handleSubmit = () => {
if (!formData.name.trim()) {
toast.error('Please enter a profile name');
return;
}
// Ensure model is always set for Claude profiles
const validModels: ModelAlias[] = ['haiku', 'sonnet', 'opus'];
const finalModel =
formData.provider === 'claude'
? validModels.includes(formData.model)
? formData.model
: 'sonnet'
: undefined;
const baseProfile = {
name: formData.name.trim(),
description: formData.description.trim(),
provider: formData.provider,
isBuiltIn: false,
icon: formData.icon,
};
if (formData.provider === 'cursor') {
onSave({
...baseProfile,
cursorModel: formData.cursorModel,
});
} else if (formData.provider === 'codex') {
onSave({
...baseProfile,
codexModel: formData.codexModel,
});
} else if (formData.provider === 'opencode') {
onSave({
...baseProfile,
opencodeModel: formData.opencodeModel,
});
} else {
onSave({
...baseProfile,
model: finalModel as ModelAlias,
thinkingLevel: supportsThinking ? formData.thinkingLevel : 'none',
});
}
};
return (
<>
<div className="overflow-y-auto flex-1 min-h-0 space-y-4 pr-3 -mr-3 pl-1">
{/* Name */}
<div className="mt-2 space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Heavy Task, Quick Fix"
data-testid="profile-name-input"
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="profile-description">Description</Label>
<Textarea
id="profile-description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Describe when to use this profile..."
rows={2}
data-testid="profile-description-input"
/>
</div>
{/* Icon Selection */}
<div className="space-y-2">
<Label>Icon</Label>
<div className="flex gap-2 flex-wrap">
{ICON_OPTIONS.map(({ name, icon: Icon }) => (
<button
key={name}
type="button"
onClick={() => setFormData({ ...formData, icon: name })}
className={cn(
'w-10 h-10 rounded-lg flex items-center justify-center border transition-colors',
formData.icon === name
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`icon-select-${name}`}
>
<Icon className="w-5 h-5" />
</button>
))}
</div>
</div>
{/* Provider Selection */}
<div className="space-y-2">
<Label>AI Provider</Label>
<div className="grid grid-cols-4 gap-2">
<button
type="button"
onClick={() => handleProviderChange('claude')}
className={cn(
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
formData.provider === 'claude'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid="provider-select-claude"
>
<AnthropicIcon className="w-4 h-4" />
Claude
</button>
<button
type="button"
onClick={() => handleProviderChange('cursor')}
className={cn(
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
formData.provider === 'cursor'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid="provider-select-cursor"
>
<CursorIcon className="w-4 h-4" />
Cursor
</button>
<button
type="button"
onClick={() => handleProviderChange('codex')}
className={cn(
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
formData.provider === 'codex'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid="provider-select-codex"
>
<OpenAIIcon className="w-4 h-4" />
Codex
</button>
<button
type="button"
onClick={() => handleProviderChange('opencode')}
className={cn(
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
formData.provider === 'opencode'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid="provider-select-opencode"
>
<OpenCodeIcon className="w-4 h-4" />
OpenCode
</button>
</div>
</div>
{/* Claude Model Selection */}
{formData.provider === 'claude' && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" />
Model
</Label>
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleModelChange(id)}
className={cn(
'flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
formData.model === id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`model-select-${id}`}
>
{label.replace('Claude ', '')}
</button>
))}
</div>
</div>
)}
{/* Cursor Model Selection */}
{formData.provider === 'cursor' && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<CursorIcon className="w-4 h-4 text-primary" />
Cursor Model
</Label>
<div className="flex flex-col gap-2">
{enabledCursorModels.length === 0 ? (
<div className="text-sm text-muted-foreground p-3 border border-dashed rounded-md text-center">
No Cursor models enabled. Enable models in Settings AI Providers.
</div>
) : (
Object.entries(CURSOR_MODEL_MAP)
.filter(([id]) => enabledCursorModels.includes(id as CursorModelId))
.map(([id, config]) => (
<button
key={id}
type="button"
onClick={() => handleCursorModelChange(id as CursorModelId)}
className={cn(
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
formData.cursorModel === id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`cursor-model-select-${id}`}
>
<span>{config.label}</span>
<div className="flex gap-1">
{config.hasThinking && (
<Badge
variant="outline"
className={cn(
'text-xs',
formData.cursorModel === id
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-amber-500/50 text-amber-600 dark:text-amber-400'
)}
>
Thinking
</Badge>
)}
<Badge
variant="secondary"
className={cn(
'text-xs',
formData.cursorModel === id && 'bg-primary-foreground/20'
)}
>
Tier
</Badge>
</div>
</button>
))
)}
</div>
{formData.cursorModel && cursorModelHasThinking(formData.cursorModel) && (
<p className="text-xs text-muted-foreground">
This model has built-in extended thinking capabilities.
</p>
)}
</div>
)}
{/* Codex Model Selection */}
{formData.provider === 'codex' && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<OpenAIIcon className="w-4 h-4 text-primary" />
Codex Model
</Label>
<div className="flex flex-col gap-2">
{Object.entries(CODEX_MODEL_MAP).map(([_, modelId]) => {
const modelConfig = {
label: modelId,
badge: 'Standard' as const,
hasReasoning: false,
};
return (
<button
key={modelId}
type="button"
onClick={() => handleCodexModelChange(modelId)}
className={cn(
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
formData.codexModel === modelId
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`codex-model-select-${modelId}`}
>
<span>{modelConfig.label}</span>
<div className="flex gap-1">
{modelConfig.hasReasoning && (
<Badge
variant="outline"
className={cn(
'text-xs',
formData.codexModel === modelId
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-amber-500/50 text-amber-600 dark:text-amber-400'
)}
>
Reasoning
</Badge>
)}
<Badge
variant="outline"
className={cn(
'text-xs',
formData.codexModel === modelId
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-muted-foreground/50 text-muted-foreground'
)}
>
{modelConfig.badge}
</Badge>
</div>
</button>
);
})}
</div>
</div>
)}
{/* OpenCode Model Selection */}
{formData.provider === 'opencode' && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<OpenCodeIcon className="w-4 h-4 text-primary" />
OpenCode Model
</Label>
<div className="flex flex-col gap-2 max-h-[300px] overflow-y-auto">
{OPENCODE_MODELS.map((model) => (
<button
key={model.id}
type="button"
onClick={() => handleOpencodeModelChange(model.id)}
className={cn(
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
formData.opencodeModel === model.id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`opencode-model-select-${model.id}`}
>
<div className="flex flex-col items-start gap-0.5">
<span>{model.label}</span>
<span
className={cn(
'text-xs',
formData.opencodeModel === model.id
? 'text-primary-foreground/70'
: 'text-muted-foreground'
)}
>
{model.description}
</span>
</div>
<Badge
variant="outline"
className={cn(
'text-xs capitalize shrink-0',
formData.opencodeModel === model.id
? 'border-primary-foreground/50 text-primary-foreground'
: model.tier === 'free'
? 'border-green-500/50 text-green-600 dark:text-green-400'
: model.tier === 'premium'
? 'border-amber-500/50 text-amber-600 dark:text-amber-400'
: 'border-muted-foreground/50 text-muted-foreground'
)}
>
{model.tier}
</Badge>
</button>
))}
</div>
</div>
)}
{/* Claude Thinking Level */}
{formData.provider === 'claude' && supportsThinking && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-amber-500" />
Thinking Level
</Label>
<div className="flex gap-2 flex-wrap">
{THINKING_LEVELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => {
setFormData({ ...formData, thinkingLevel: id });
if (id === 'ultrathink') {
toast.warning('Ultrathink uses extensive reasoning', {
description:
'Best for complex architecture, migrations, or deep debugging (~$0.48/task).',
duration: 4000,
});
}
}}
className={cn(
'flex-1 min-w-[70px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
formData.thinkingLevel === id
? 'bg-amber-500 text-white border-amber-400'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`thinking-select-${id}`}
>
{label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
Higher levels give more time to reason through complex problems.
</p>
</div>
)}
</div>
{/* Actions */}
<DialogFooter className="pt-4 border-t border-border mt-4 shrink-0">
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<HotkeyButton
onClick={handleSubmit}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={hotkeyActive}
data-testid="save-profile-button"
>
{isEditing ? 'Save Changes' : 'Create Profile'}
</HotkeyButton>
</DialogFooter>
</>
);
}

View File

@@ -1,55 +0,0 @@
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { UserCircle, Plus, RefreshCw } from 'lucide-react';
interface ProfilesHeaderProps {
onResetProfiles: () => void;
onAddProfile: () => void;
addProfileHotkey: string;
}
export function ProfilesHeader({
onResetProfiles,
onAddProfile,
addProfileHotkey,
}: ProfilesHeaderProps) {
return (
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
<div className="px-8 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
<UserCircle className="w-5 h-5 text-primary-foreground" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">AI Profiles</h1>
<p className="text-sm text-muted-foreground">
Create and manage model configuration presets
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={onResetProfiles}
data-testid="refresh-profiles-button"
className="gap-2"
>
<RefreshCw className="w-4 h-4" />
Refresh Defaults
</Button>
<HotkeyButton
onClick={onAddProfile}
hotkey={addProfileHotkey}
hotkeyActive={false}
data-testid="add-profile-button"
>
<Plus className="w-4 h-4 mr-2" />
New Profile
</HotkeyButton>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,142 +0,0 @@
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { GripVertical, Lock, Pencil, Trash2 } from 'lucide-react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { AIProfile } from '@automaker/types';
import { CURSOR_MODEL_MAP, profileHasThinking, getCodexModelLabel } from '@automaker/types';
import { PROFILE_ICONS } from '../constants';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
interface SortableProfileCardProps {
profile: AIProfile;
onEdit: () => void;
onDelete: () => void;
}
export function SortableProfileCard({ profile, onEdit, onDelete }: SortableProfileCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: profile.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const getDefaultIcon = () => {
if (profile.provider === 'cursor') return CursorIcon;
if (profile.provider === 'codex') return OpenAIIcon;
return AnthropicIcon;
};
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : getDefaultIcon();
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'group relative flex items-start gap-4 p-4 rounded-xl border bg-card transition-all',
isDragging && 'shadow-lg',
profile.isBuiltIn
? 'border-border/50'
: 'border-border hover:border-primary/50 hover:shadow-sm'
)}
data-testid={`profile-card-${profile.id}`}
>
{/* Drag Handle */}
<button
{...attributes}
{...listeners}
className="p-1 rounded hover:bg-accent cursor-grab active:cursor-grabbing flex-shrink-0 mt-1"
data-testid={`profile-drag-handle-${profile.id}`}
onClick={(e) => e.stopPropagation()}
aria-label={`Reorder ${profile.name} profile`}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
{/* Icon */}
<div className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center bg-primary/10">
{IconComponent && <IconComponent className="w-5 h-5 text-primary" />}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-foreground">{profile.name}</h3>
{profile.isBuiltIn && (
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
<Lock className="w-2.5 h-2.5" />
Built-in
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{profile.description}</p>
<div className="flex items-center gap-2 mt-2 flex-wrap">
{/* Provider badge */}
<span className="text-xs px-2 py-0.5 rounded-full border border-border text-muted-foreground bg-muted/50 flex items-center gap-1">
{profile.provider === 'cursor' ? (
<CursorIcon className="w-3 h-3" />
) : profile.provider === 'codex' ? (
<OpenAIIcon className="w-3 h-3" />
) : (
<AnthropicIcon className="w-3 h-3" />
)}
{profile.provider === 'cursor'
? 'Cursor'
: profile.provider === 'codex'
? 'Codex'
: 'Claude'}
</span>
{/* Model badge */}
<span className="text-xs px-2 py-0.5 rounded-full border border-primary/30 text-primary bg-primary/10">
{profile.provider === 'cursor'
? CURSOR_MODEL_MAP[profile.cursorModel || 'auto']?.label ||
profile.cursorModel ||
'auto'
: profile.provider === 'codex'
? getCodexModelLabel(profile.codexModel || 'codex-gpt-5.2-codex')
: profile.model || 'sonnet'}
</span>
{/* Thinking badge - works for both providers */}
{profileHasThinking(profile) && (
<span className="text-xs px-2 py-0.5 rounded-full border border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/10">
{profile.provider === 'cursor' ? 'Thinking' : profile.thinkingLevel}
</span>
)}
</div>
</div>
{/* Actions */}
{!profile.isBuiltIn && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={onEdit}
className="h-8 w-8 p-0"
data-testid={`edit-profile-${profile.id}`}
aria-label={`Edit ${profile.name} profile`}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
data-testid={`delete-profile-${profile.id}`}
aria-label={`Delete ${profile.name} profile`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
</div>
);
}

View File

@@ -1,44 +0,0 @@
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
import type { ModelAlias, ThinkingLevel } from '@/store/app-store';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
// Icon mapping for profiles
export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
Anthropic: AnthropicIcon,
Cursor: CursorIcon,
Codex: OpenAIIcon,
};
// Available icons for selection
export const ICON_OPTIONS = [
{ name: 'Brain', icon: Brain },
{ name: 'Zap', icon: Zap },
{ name: 'Scale', icon: Scale },
{ name: 'Cpu', icon: Cpu },
{ name: 'Rocket', icon: Rocket },
{ name: 'Sparkles', icon: Sparkles },
{ name: 'Anthropic', icon: AnthropicIcon },
{ name: 'Cursor', icon: CursorIcon },
{ name: 'Codex', icon: OpenAIIcon },
];
// Model options for the form
export const CLAUDE_MODELS: { id: ModelAlias; label: string }[] = [
{ id: 'haiku', label: 'Claude Haiku' },
{ id: 'sonnet', label: 'Claude Sonnet' },
{ id: 'opus', label: 'Claude Opus' },
];
export const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
{ id: 'none', label: 'None' },
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'ultrathink', label: 'Ultrathink' },
];

View File

@@ -1,48 +0,0 @@
import type { ModelAlias, ModelProvider, AIProfile } from '@automaker/types';
import { CURSOR_MODEL_MAP } from '@automaker/types';
// Helper to determine provider from model (legacy, always returns 'claude')
export function getProviderFromModel(model: ModelAlias): ModelProvider {
return 'claude';
}
/**
* Validate an AI profile for completeness and correctness
*/
export function validateProfile(profile: Partial<AIProfile>): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
// Name is required
if (!profile.name?.trim()) {
errors.push('Profile name is required');
}
// Provider must be valid
if (!profile.provider || !['claude', 'cursor'].includes(profile.provider)) {
errors.push('Invalid provider');
}
// Claude-specific validation
if (profile.provider === 'claude') {
if (!profile.model) {
errors.push('Claude model is required');
} else if (!['haiku', 'sonnet', 'opus'].includes(profile.model)) {
errors.push('Invalid Claude model');
}
}
// Cursor-specific validation
if (profile.provider === 'cursor') {
if (profile.cursorModel && !(profile.cursorModel in CURSOR_MODEL_MAP)) {
errors.push('Invalid Cursor model');
}
}
return {
valid: errors.length === 0,
errors,
};
}

View File

@@ -42,8 +42,6 @@ export function SettingsView() {
setSkipVerificationInAutoMode,
useWorktrees,
setUseWorktrees,
showProfilesOnly,
setShowProfilesOnly,
muteDoneSound,
setMuteDoneSound,
currentProject,
@@ -52,9 +50,6 @@ export function SettingsView() {
setDefaultPlanningMode,
defaultRequirePlanApproval,
setDefaultRequirePlanApproval,
defaultAIProfileId,
setDefaultAIProfileId,
aiProfiles,
autoLoadClaudeMd,
setAutoLoadClaudeMd,
promptCustomization,
@@ -151,23 +146,18 @@ export function SettingsView() {
case 'defaults':
return (
<FeatureDefaultsSection
showProfilesOnly={showProfilesOnly}
defaultSkipTests={defaultSkipTests}
enableDependencyBlocking={enableDependencyBlocking}
skipVerificationInAutoMode={skipVerificationInAutoMode}
useWorktrees={useWorktrees}
defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval}
defaultAIProfileId={defaultAIProfileId}
aiProfiles={aiProfiles}
onShowProfilesOnlyChange={setShowProfilesOnly}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
onUseWorktreesChange={setUseWorktrees}
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
onDefaultAIProfileIdChange={setDefaultAIProfileId}
/>
);
case 'account':

View File

@@ -1,7 +1,8 @@
import { Button } from '@/components/ui/button';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle, Bot } from 'lucide-react';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
import { OpenCodeIcon } from '@/components/ui/provider-icon';
export type OpencodeAuthMethod =
| 'api_key_env' // ANTHROPIC_API_KEY or other provider env vars
@@ -169,7 +170,7 @@ export function OpencodeCliStatus({
<div className="flex items-center justify-between mb-2">
<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">
<Bot className="w-5 h-5 text-brand-500" />
<OpenCodeIcon className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">OpenCode CLI</h2>
</div>

View File

@@ -6,7 +6,6 @@ import { OpenAIIcon } from '@/components/ui/provider-icon';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import {
formatCodexCredits,
formatCodexPlanType,
formatCodexResetTime,
getCodexWindowLabel,
@@ -25,7 +24,6 @@ const UPDATED_LABEL = 'Updated';
const CODEX_FETCH_ERROR = 'Failed to fetch usage';
const CODEX_REFRESH_LABEL = 'Refresh Codex usage';
const PLAN_LABEL = 'Plan';
const CREDITS_LABEL = 'Credits';
const WARNING_THRESHOLD = 75;
const CAUTION_THRESHOLD = 50;
const MAX_PERCENTAGE = 100;
@@ -49,7 +47,6 @@ export function CodexUsageSection() {
const rateLimits = codexUsage?.rateLimits ?? null;
const primary = rateLimits?.primary ?? null;
const secondary = rateLimits?.secondary ?? null;
const credits = rateLimits?.credits ?? null;
const planType = rateLimits?.planType ?? null;
const rateLimitWindows = [primary, secondary].filter(isRateLimitWindow);
const hasMetrics = rateLimitWindows.length > 0;
@@ -206,20 +203,11 @@ export function CodexUsageSection() {
})}
</div>
)}
{(planType || credits) && (
{planType && (
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
{planType && (
<div>
{PLAN_LABEL}:{' '}
<span className="text-foreground">{formatCodexPlanType(planType)}</span>
</div>
)}
{credits && (
<div>
{CREDITS_LABEL}:{' '}
<span className="text-foreground">{formatCodexCredits(credits)}</span>
</div>
)}
<div>
{PLAN_LABEL}: <span className="text-foreground">{formatCodexPlanType(planType)}</span>
</div>
</div>
)}
{!hasMetrics && !error && canFetchUsage && !isLoading && (

View File

@@ -14,9 +14,8 @@ import {
MessageSquareText,
User,
Shield,
Cpu,
} from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import type { SettingsViewId } from '../hooks/use-settings-view';
export interface NavigationItem {
@@ -48,7 +47,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
{ id: 'claude-provider', label: 'Claude', icon: AnthropicIcon },
{ id: 'cursor-provider', label: 'Cursor', icon: CursorIcon },
{ id: 'codex-provider', label: 'Codex', icon: OpenAIIcon },
{ id: 'opencode-provider', label: 'OpenCode', icon: Cpu },
{ id: 'opencode-provider', label: 'OpenCode', icon: OpenCodeIcon },
],
},
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },

View File

@@ -2,7 +2,6 @@ import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
FlaskConical,
Settings2,
TestTube,
GitBranch,
AlertCircle,
@@ -11,7 +10,6 @@ import {
FileText,
ScrollText,
ShieldCheck,
User,
FastForward,
} from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -22,53 +20,38 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { AIProfile } from '@/store/app-store';
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
interface FeatureDefaultsSectionProps {
showProfilesOnly: boolean;
defaultSkipTests: boolean;
enableDependencyBlocking: boolean;
skipVerificationInAutoMode: boolean;
useWorktrees: boolean;
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
defaultAIProfileId: string | null;
aiProfiles: AIProfile[];
onShowProfilesOnlyChange: (value: boolean) => void;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
onSkipVerificationInAutoModeChange: (value: boolean) => void;
onUseWorktreesChange: (value: boolean) => void;
onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
onDefaultAIProfileIdChange: (value: string | null) => void;
}
export function FeatureDefaultsSection({
showProfilesOnly,
defaultSkipTests,
enableDependencyBlocking,
skipVerificationInAutoMode,
useWorktrees,
defaultPlanningMode,
defaultRequirePlanApproval,
defaultAIProfileId,
aiProfiles,
onShowProfilesOnlyChange,
onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
onSkipVerificationInAutoModeChange,
onUseWorktreesChange,
onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange,
onDefaultAIProfileIdChange,
}: FeatureDefaultsSectionProps) {
// Find the selected profile name for display
const selectedProfile = defaultAIProfileId
? aiProfiles.find((p) => p.id === defaultAIProfileId)
: null;
return (
<div
className={cn(
@@ -194,71 +177,6 @@ export function FeatureDefaultsSection({
{/* Separator */}
{defaultPlanningMode === 'skip' && <div className="border-t border-border/30" />}
{/* Default AI Profile */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-brand-500/10">
<User className="w-5 h-5 text-brand-500" />
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Default AI Profile</Label>
<Select
value={defaultAIProfileId ?? 'none'}
onValueChange={(v: string) => onDefaultAIProfileIdChange(v === 'none' ? null : v)}
>
<SelectTrigger className="w-[180px] h-8" data-testid="default-ai-profile-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">None (pick manually)</span>
</SelectItem>
{aiProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
<span>{profile.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
{selectedProfile
? `New features will use the "${selectedProfile.name}" profile (${selectedProfile.model}, ${selectedProfile.thinkingLevel} thinking).`
: 'Pre-select an AI profile when creating new features. Choose "None" to pick manually each time.'}
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Profiles Only Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="show-profiles-only"
checked={showProfilesOnly}
onCheckedChange={(checked) => onShowProfilesOnlyChange(checked === true)}
className="mt-1"
data-testid="show-profiles-only-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="show-profiles-only"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Settings2 className="w-4 h-4 text-brand-500" />
Show profiles only by default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, the Add Feature dialog will show only AI profiles and hide advanced
model tweaking options. This creates a cleaner, less overwhelming UI.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Automated Testing Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox

View File

@@ -1,4 +1,4 @@
import * as React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type {
@@ -8,8 +8,6 @@ import type {
OpencodeModelId,
GroupedModel,
PhaseModelEntry,
ThinkingLevel,
ReasoningEffort,
} from '@automaker/types';
import {
stripProviderPrefix,
@@ -17,13 +15,11 @@ import {
getModelGroup,
isGroupSelected,
getSelectedVariant,
isCursorModel,
codexModelHasThinking,
} from '@automaker/types';
import {
CLAUDE_MODELS,
CURSOR_MODELS,
CODEX_MODELS,
OPENCODE_MODELS,
THINKING_LEVELS,
THINKING_LEVEL_LABELS,
@@ -31,7 +27,18 @@ import {
REASONING_EFFORT_LABELS,
} from '@/components/views/board-view/shared/model-constants';
import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import {
AnthropicIcon,
CursorIcon,
OpenAIIcon,
OpenCodeIcon,
DeepSeekIcon,
NovaIcon,
QwenIcon,
MistralIcon,
MetaIcon,
getProviderIconForModel,
} from '@/components/ui/provider-icon';
import { Button } from '@/components/ui/button';
import {
Command,
@@ -73,23 +80,39 @@ export function PhaseModelSelector({
align = 'end',
disabled = false,
}: PhaseModelSelectorProps) {
const [open, setOpen] = React.useState(false);
const [expandedGroup, setExpandedGroup] = React.useState<string | null>(null);
const [expandedClaudeModel, setExpandedClaudeModel] = React.useState<ModelAlias | null>(null);
const [expandedCodexModel, setExpandedCodexModel] = React.useState<CodexModelId | null>(null);
const commandListRef = React.useRef<HTMLDivElement>(null);
const expandedTriggerRef = React.useRef<HTMLDivElement>(null);
const expandedClaudeTriggerRef = React.useRef<HTMLDivElement>(null);
const expandedCodexTriggerRef = React.useRef<HTMLDivElement>(null);
const { enabledCursorModels, favoriteModels, toggleFavoriteModel } = useAppStore();
const [open, setOpen] = useState(false);
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
const [expandedClaudeModel, setExpandedClaudeModel] = useState<ModelAlias | null>(null);
const [expandedCodexModel, setExpandedCodexModel] = useState<CodexModelId | null>(null);
const commandListRef = useRef<HTMLDivElement>(null);
const expandedTriggerRef = useRef<HTMLDivElement>(null);
const expandedClaudeTriggerRef = useRef<HTMLDivElement>(null);
const expandedCodexTriggerRef = useRef<HTMLDivElement>(null);
const {
enabledCursorModels,
favoriteModels,
toggleFavoriteModel,
codexModels,
codexModelsLoading,
fetchCodexModels,
} = useAppStore();
// Extract model and thinking/reasoning levels from value
const selectedModel = value.model;
const selectedThinkingLevel = value.thinkingLevel || 'none';
const selectedReasoningEffort = value.reasoningEffort || 'none';
// Fetch Codex models on mount
useEffect(() => {
if (codexModels.length === 0 && !codexModelsLoading) {
fetchCodexModels().catch(() => {
// Silently fail - user will see empty Codex section
});
}
}, [codexModels.length, codexModelsLoading, fetchCodexModels]);
// Close expanded group when trigger scrolls out of view
React.useEffect(() => {
useEffect(() => {
const triggerElement = expandedTriggerRef.current;
const listElement = commandListRef.current;
if (!triggerElement || !listElement || !expandedGroup) return;
@@ -112,7 +135,7 @@ export function PhaseModelSelector({
}, [expandedGroup]);
// Close expanded Claude model popover when trigger scrolls out of view
React.useEffect(() => {
useEffect(() => {
const triggerElement = expandedClaudeTriggerRef.current;
const listElement = commandListRef.current;
if (!triggerElement || !listElement || !expandedClaudeModel) return;
@@ -135,7 +158,7 @@ export function PhaseModelSelector({
}, [expandedClaudeModel]);
// Close expanded Codex model popover when trigger scrolls out of view
React.useEffect(() => {
useEffect(() => {
const triggerElement = expandedCodexTriggerRef.current;
const listElement = commandListRef.current;
if (!triggerElement || !listElement || !expandedCodexModel) return;
@@ -157,6 +180,17 @@ export function PhaseModelSelector({
return () => observer.disconnect();
}, [expandedCodexModel]);
// Transform dynamic Codex models from store to component format
const transformedCodexModels = useMemo(() => {
return codexModels.map((model) => ({
id: model.id,
label: model.label,
description: model.description,
provider: 'codex' as const,
badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Speed' : undefined,
}));
}, [codexModels]);
// Filter Cursor models to only show enabled ones
const availableCursorModels = CURSOR_MODELS.filter((model) => {
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
@@ -164,7 +198,7 @@ export function PhaseModelSelector({
});
// Helper to find current selected model details
const currentModel = React.useMemo(() => {
const currentModel = useMemo(() => {
const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel);
if (claudeModel) {
// Add thinking level to label if not 'none'
@@ -198,7 +232,7 @@ export function PhaseModelSelector({
}
// Check Codex models
const codexModel = CODEX_MODELS.find((m) => m.id === selectedModel);
const codexModel = transformedCodexModels.find((m) => m.id === selectedModel);
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
// Check OpenCode models
@@ -206,10 +240,10 @@ export function PhaseModelSelector({
if (opencodeModel) return { ...opencodeModel, icon: OpenCodeIcon };
return null;
}, [selectedModel, selectedThinkingLevel, availableCursorModels]);
}, [selectedModel, selectedThinkingLevel, availableCursorModels, transformedCodexModels]);
// Compute grouped vs standalone Cursor models
const { groupedModels, standaloneCursorModels } = React.useMemo(() => {
const { groupedModels, standaloneCursorModels } = useMemo(() => {
const grouped: GroupedModel[] = [];
const standalone: typeof CURSOR_MODELS = [];
const seenGroups = new Set<string>();
@@ -242,11 +276,11 @@ export function PhaseModelSelector({
}, [availableCursorModels, enabledCursorModels]);
// Group models
const { favorites, claude, cursor, codex, opencode } = React.useMemo(() => {
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
const favs: typeof CLAUDE_MODELS = [];
const cModels: typeof CLAUDE_MODELS = [];
const curModels: typeof CURSOR_MODELS = [];
const codModels: typeof CODEX_MODELS = [];
const codModels: typeof transformedCodexModels = [];
const ocModels: typeof OPENCODE_MODELS = [];
// Process Claude Models
@@ -268,7 +302,7 @@ export function PhaseModelSelector({
});
// Process Codex Models
CODEX_MODELS.forEach((model) => {
transformedCodexModels.forEach((model) => {
if (favoriteModels.includes(model.id)) {
favs.push(model);
} else {
@@ -292,10 +326,10 @@ export function PhaseModelSelector({
codex: codModels,
opencode: ocModels,
};
}, [favoriteModels, availableCursorModels]);
}, [favoriteModels, availableCursorModels, transformedCodexModels]);
// Render Codex model item with secondary popover for reasoning effort (only for models that support it)
const renderCodexModelItem = (model: (typeof CODEX_MODELS)[0]) => {
const renderCodexModelItem = (model: (typeof transformedCodexModels)[0]) => {
const isSelected = selectedModel === model.id;
const isFavorite = favoriteModels.includes(model.id);
const hasReasoning = codexModelHasThinking(model.id as CodexModelId);
@@ -480,6 +514,9 @@ export function PhaseModelSelector({
const isSelected = selectedModel === model.id;
const isFavorite = favoriteModels.includes(model.id);
// Get the appropriate icon based on the specific model ID
const ProviderIcon = getProviderIconForModel(model.id);
return (
<CommandItem
key={model.id}
@@ -491,7 +528,7 @@ export function PhaseModelSelector({
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<OpenCodeIcon
<ProviderIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
@@ -919,7 +956,7 @@ export function PhaseModelSelector({
}
// Codex model
if (model.provider === 'codex') {
return renderCodexModelItem(model);
return renderCodexModelItem(model as (typeof transformedCodexModels)[0]);
}
// OpenCode model
if (model.provider === 'opencode') {

View File

@@ -431,6 +431,16 @@ export function PromptCustomizationSection({
updatePrompt('enhancement', 'acceptanceSystemPrompt', value)
}
/>
<PromptField
label="User Experience Mode"
description="Review and enhance from a user experience and design perspective"
defaultValue={DEFAULT_ENHANCEMENT_PROMPTS.uxReviewerSystemPrompt}
customValue={promptCustomization?.enhancement?.uxReviewerSystemPrompt}
onCustomValueChange={(value) =>
updatePrompt('enhancement', 'uxReviewerSystemPrompt', value)
}
/>
</div>
</TabsContent>
</Tabs>

View File

@@ -8,11 +8,19 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Terminal, Cloud, Cpu, Brain, Sparkles, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { OpencodeModelId, OpencodeProvider, OpencodeModelConfig } from '@automaker/types';
import { OPENCODE_MODELS, OPENCODE_MODEL_CONFIG_MAP } from '@automaker/types';
import { AnthropicIcon } from '@/components/ui/provider-icon';
import {
OpenCodeIcon,
DeepSeekIcon,
QwenIcon,
NovaIcon,
AnthropicIcon,
MistralIcon,
MetaIcon,
getProviderIconForModel,
} from '@/components/ui/provider-icon';
import type { ComponentType } from 'react';
interface OpencodeModelConfigurationProps {
@@ -24,27 +32,10 @@ interface OpencodeModelConfigurationProps {
}
/**
* Returns the appropriate icon component for a given OpenCode provider
* Returns the appropriate icon component for a given OpenCode model ID
*/
function getProviderIcon(provider: OpencodeProvider): ComponentType<{ className?: string }> {
switch (provider) {
case 'opencode':
return Terminal;
case 'amazon-bedrock-anthropic':
return AnthropicIcon;
case 'amazon-bedrock-deepseek':
return Brain;
case 'amazon-bedrock-amazon':
return Cloud;
case 'amazon-bedrock-meta':
return Cpu;
case 'amazon-bedrock-mistral':
return Sparkles;
case 'amazon-bedrock-qwen':
return Zap;
default:
return Terminal;
}
function getModelIcon(modelId: OpencodeModelId): ComponentType<{ className?: string }> {
return getProviderIconForModel(modelId);
}
/**
@@ -113,7 +104,7 @@ export function OpencodeModelConfiguration({
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Terminal className="w-5 h-5 text-brand-500" />
<OpenCodeIcon className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Model Configuration
@@ -139,11 +130,11 @@ export function OpencodeModelConfiguration({
{enabledOpencodeModels.map((modelId) => {
const model = OPENCODE_MODEL_CONFIG_MAP[modelId];
if (!model) return null;
const ProviderIconComponent = getProviderIcon(model.provider);
const ModelIconComponent = getModelIcon(modelId);
return (
<SelectItem key={modelId} value={modelId}>
<div className="flex items-center gap-2">
<ProviderIconComponent className="w-4 h-4" />
<ModelIconComponent className="w-4 h-4" />
<span>{model.label}</span>
</div>
</SelectItem>
@@ -160,7 +151,9 @@ export function OpencodeModelConfiguration({
const models = modelsByProvider[provider];
if (!models || models.length === 0) return null;
const ProviderIconComponent = getProviderIcon(provider);
// Use the first model's icon as the provider icon
const ProviderIconComponent =
models.length > 0 ? getModelIcon(models[0].id) : OpenCodeIcon;
return (
<div key={provider} className="space-y-2">

View File

@@ -86,8 +86,8 @@ export function SetupView() {
const handleFinish = () => {
logger.debug('[Setup Flow] handleFinish called - completing setup');
completeSetup();
logger.debug('[Setup Flow] Setup completed, redirecting to welcome view');
navigate({ to: '/' });
logger.debug('[Setup Flow] Setup completed, redirecting to dashboard');
navigate({ to: '/dashboard' });
};
return (

View File

@@ -81,7 +81,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
return;
}
const status = await api.specRegeneration.status();
const status = await api.specRegeneration.status(currentProject.path);
logger.debug(
'[useSpecGeneration] Status check on mount:',
status,
@@ -90,9 +90,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
);
if (status.success && status.isRunning) {
logger.debug(
'[useSpecGeneration] Spec generation is running globally. Tentatively showing loader.'
);
logger.debug('[useSpecGeneration] Spec generation is running for this project.');
setIsCreating(true);
setIsRegenerating(true);
@@ -143,7 +141,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const status = await api.specRegeneration.status();
const status = await api.specRegeneration.status(currentProject.path);
logger.debug('[useSpecGeneration] Visibility change - status check:', status);
if (!status.isRunning) {
@@ -180,7 +178,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const status = await api.specRegeneration.status();
const status = await api.specRegeneration.status(currentProject.path);
if (!status.isRunning) {
logger.debug(

View File

@@ -21,9 +21,9 @@ export function useSpecLoading() {
// Check if spec generation is running before trying to load
// This prevents showing "No App Specification Found" during generation
if (api.specRegeneration) {
const status = await api.specRegeneration.status();
const status = await api.specRegeneration.status(currentProject.path);
if (status.success && status.isRunning) {
logger.debug('Spec generation is running, skipping load');
logger.debug('Spec generation is running for this project, skipping load');
setIsGenerationRunning(true);
setIsLoading(false);
return;

View File

@@ -44,6 +44,7 @@ import { getElectronAPI } from '@/lib/electron';
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
const logger = createLogger('Terminal');
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
// Font size constraints
const MIN_FONT_SIZE = 8;
@@ -504,6 +505,7 @@ export function TerminalPanel({
const response = await fetch(`${serverUrl}/api/auth/token`, {
headers,
credentials: 'include',
cache: NO_STORE_CACHE_MODE,
});
if (!response.ok) {

View File

@@ -20,7 +20,6 @@ import {
Image,
TestTube,
Brain,
Users,
} from 'lucide-react';
interface WikiSection {
@@ -236,12 +235,6 @@ export function WikiView() {
description:
'Each feature runs in its own git worktree for safe parallel development.',
},
{
icon: Users,
title: 'AI Profiles',
description:
'Pre-configured model + thinking level combinations for different task types.',
},
{
icon: Terminal,
title: 'Integrated Terminal',
@@ -424,10 +417,6 @@ export function WikiView() {
file: 'views/terminal-view/',
desc: 'Integrated terminal with splits and tabs',
},
{
file: 'views/profiles-view.tsx',
desc: 'AI profile management (model + thinking presets)',
},
{
file: 'store/app-store.ts',
desc: 'Central Zustand state management',
@@ -528,12 +517,6 @@ export function WikiView() {
steps
</p>
</li>
<li className="text-foreground">
<strong>Configure AI Profile</strong>
<p className="text-muted-foreground ml-5 mt-1">
Choose an AI profile or customize model/thinking settings per feature
</p>
</li>
<li className="text-foreground">
<strong>Start Implementation</strong>
<p className="text-muted-foreground ml-5 mt-1">
@@ -555,9 +538,6 @@ export function WikiView() {
<code className="px-1 py-0.5 bg-brand-500/20 rounded">?</code> to see all)
</li>
<li>Enable git worktree isolation for parallel feature development</li>
<li>
Start with "Quick Edit" profile for simple tasks, use "Heavy Task" for complex work
</li>
<li>Keep your app spec up to date as your project evolves</li>
</ul>
</div>