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

@@ -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>