feat: implement running agents view and enhance auto mode functionality

- Added a new `RunningAgentsView` component to display currently active agents working on features.
- Implemented auto-refresh functionality for the running agents list every 2 seconds.
- Enhanced the auto mode service to support project-specific operations, including starting and stopping auto mode for individual projects.
- Updated IPC handlers to manage auto mode status and running agents more effectively.
- Introduced audio settings to mute notifications when agents complete tasks.
- Refactored existing components to accommodate new features and improve overall user experience.
This commit is contained in:
Cody Seibert
2025-12-10 21:51:00 -05:00
parent 5ac81ce5a9
commit d08f922631
24 changed files with 1450 additions and 405 deletions

View File

@@ -12,6 +12,7 @@ import { InterviewView } from "@/components/views/interview-view";
import { ContextView } from "@/components/views/context-view";
import { ProfilesView } from "@/components/views/profiles-view";
import { SetupView } from "@/components/views/setup-view";
import { RunningAgentsView } from "@/components/views/running-agents-view";
import { useAppStore } from "@/store/app-store";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI, isElectron } from "@/lib/electron";
@@ -178,6 +179,8 @@ export default function Home() {
return <ContextView />;
case "profiles":
return <ProfilesView />;
case "running-agents":
return <RunningAgentsView />;
default:
return <WelcomeView />;
}

View File

@@ -40,6 +40,8 @@ import {
Radio,
Monitor,
Search,
Bug,
Activity,
} from "lucide-react";
import {
DropdownMenu,
@@ -394,7 +396,8 @@ export function Sidebar() {
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
// Extract folder name from path (works on both Windows and Mac/Linux)
const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
const name =
path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
try {
// Check if this is a brand new project (no .automaker directory)
@@ -572,7 +575,10 @@ export function Sidebar() {
// Handle selecting the currently highlighted project
const selectHighlightedProject = useCallback(() => {
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
if (
filteredProjects.length > 0 &&
selectedProjectIndex < filteredProjects.length
) {
setCurrentProject(filteredProjects[selectedProjectIndex]);
setIsProjectPickerOpen(false);
}
@@ -596,7 +602,11 @@ export function Sidebar() {
} else if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedProjectIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (event.key.toLowerCase() === "p" && !event.metaKey && !event.ctrlKey) {
} else if (
event.key.toLowerCase() === "p" &&
!event.metaKey &&
!event.ctrlKey
) {
// Toggle off when P is pressed (not with modifiers) while dropdown is open
// Only if not typing in the search input
if (document.activeElement !== projectSearchInputRef.current) {
@@ -913,7 +923,10 @@ export function Sidebar() {
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-48" data-testid="project-theme-menu">
<DropdownMenuSubContent
className="w-48"
data-testid="project-theme-menu"
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Select theme for this project
</DropdownMenuLabel>
@@ -922,7 +935,10 @@ export function Sidebar() {
value={currentProject.theme || ""}
onValueChange={(value) => {
if (currentProject) {
setProjectTheme(currentProject.id, value === "" ? null : value as any);
setProjectTheme(
currentProject.id,
value === "" ? null : (value as any)
);
}
}}
>
@@ -932,7 +948,9 @@ export function Sidebar() {
<DropdownMenuRadioItem
key={option.value}
value={option.value}
data-testid={`project-theme-${option.value || 'global'}`}
data-testid={`project-theme-${
option.value || "global"
}`}
>
<Icon className="w-4 h-4 mr-2" />
<span>{option.label}</span>
@@ -955,21 +973,30 @@ export function Sidebar() {
<DropdownMenuLabel className="text-xs text-muted-foreground">
Project History
</DropdownMenuLabel>
<DropdownMenuItem onClick={cyclePrevProject} data-testid="cycle-prev-project">
<DropdownMenuItem
onClick={cyclePrevProject}
data-testid="cycle-prev-project"
>
<Undo2 className="w-4 h-4 mr-2" />
<span className="flex-1">Previous</span>
<span className="text-[10px] font-mono text-muted-foreground ml-2">
{shortcuts.cyclePrevProject}
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={cycleNextProject} data-testid="cycle-next-project">
<DropdownMenuItem
onClick={cycleNextProject}
data-testid="cycle-next-project"
>
<Redo2 className="w-4 h-4 mr-2" />
<span className="flex-1">Next</span>
<span className="text-[10px] font-mono text-muted-foreground ml-2">
{shortcuts.cycleNextProject}
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={clearProjectHistory} data-testid="clear-project-history">
<DropdownMenuItem
onClick={clearProjectHistory}
data-testid="clear-project-history"
>
<RotateCcw className="w-4 h-4 mr-2" />
<span>Clear history</span>
</DropdownMenuItem>
@@ -1078,8 +1105,79 @@ export function Sidebar() {
</nav>
</div>
{/* Bottom Section - User / Settings */}
{/* Bottom Section - Running Agents / Bug Report / Settings */}
<div className="border-t border-sidebar-border bg-sidebar-accent/10 shrink-0">
{/* Running Agents Link */}
<div className="p-2 pb-0">
<button
onClick={() => setCurrentView("running-agents")}
className={cn(
"group flex items-center w-full px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all titlebar-no-drag",
isActiveRoute("running-agents")
? "bg-sidebar-accent/50 text-foreground border border-sidebar-border"
: "text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50",
sidebarOpen ? "justify-start" : "justify-center"
)}
title={!sidebarOpen ? "Running Agents" : undefined}
data-testid="running-agents-link"
>
{isActiveRoute("running-agents") && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
)}
<Activity
className={cn(
"w-4 h-4 shrink-0 transition-colors",
isActiveRoute("running-agents")
? "text-brand-500"
: "group-hover:text-brand-400"
)}
/>
<span
className={cn(
"ml-2.5 font-medium text-sm flex-1 text-left",
sidebarOpen ? "hidden lg:block" : "hidden"
)}
>
Running Agents
</span>
{!sidebarOpen && (
<span className="absolute left-full ml-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-border">
Running Agents
</span>
)}
</button>
</div>
{/* Bug Report Link */}
<div className="p-2 pb-0 pt-0">
<button
onClick={() => {
const api = getElectronAPI();
api.openExternalLink("https://github.com/AutoMaker-Org/automaker/issues");
}}
className={cn(
"group flex items-center w-full px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all titlebar-no-drag",
"text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50",
sidebarOpen ? "justify-start" : "justify-center"
)}
title={!sidebarOpen ? "Report Bug / Feature Request" : undefined}
data-testid="bug-report-link"
>
<Bug className="w-4 h-4 shrink-0 transition-colors group-hover:text-brand-400" />
<span
className={cn(
"ml-2.5 font-medium text-sm flex-1 text-left",
sidebarOpen ? "hidden lg:block" : "hidden"
)}
>
Report Bug / Feature Request
</span>
{!sidebarOpen && (
<span className="absolute left-full ml-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-border">
Report Bug / Feature Request
</span>
)}
</button>
</div>
{/* Settings Link */}
<div className="p-2">
<button
@@ -1272,8 +1370,8 @@ export function Sidebar() {
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically create features in the features folder
from the implementation roadmap after the spec is generated.
Automatically create features in the features folder from the
implementation roadmap after the spec is generated.
</p>
</div>
</div>

View File

@@ -82,7 +82,7 @@ function DialogContent({
data-slot="dialog-close"
className={cn(
"ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute rounded-xs opacity-70 transition-opacity cursor-pointer hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
compact ? "top-2 right-2" : "top-4 right-4"
compact ? "top-2 right-3" : "top-3 right-5"
)}
>
<XIcon />

View File

@@ -56,7 +56,10 @@ function parseHotkeyConfig(hotkey: string | HotkeyConfig): HotkeyConfig {
/**
* Generate the display label for the hotkey
*/
function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.ReactNode {
function getHotkeyDisplayLabel(
config: HotkeyConfig,
isMac: boolean
): React.ReactNode {
if (config.label) {
return config.label;
}
@@ -73,7 +76,10 @@ function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.Reac
if (config.shift) {
parts.push(
<span key="shift" className="leading-none flex items-center justify-center">
<span
key="shift"
className="leading-none flex items-center justify-center"
>
</span>
);
@@ -134,11 +140,7 @@ function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.Reac
</span>
);
return (
<span className="inline-flex items-center gap-1.5">
{parts}
</span>
);
return <span className="inline-flex items-center gap-1.5">{parts}</span>;
}
/**
@@ -205,7 +207,11 @@ export function HotkeyButton({
// Don't trigger when typing in inputs (unless explicitly scoped or using cmdCtrl modifier)
// cmdCtrl shortcuts like Cmd+Enter should work even in inputs as they're intentional submit actions
if (!scopeRef && !config.cmdCtrl && isInputElement(document.activeElement)) {
if (
!scopeRef &&
!config.cmdCtrl &&
isInputElement(document.activeElement)
) {
return;
}
@@ -228,7 +234,8 @@ export function HotkeyButton({
// If scoped, check that the scope element is visible
if (scopeRef && scopeRef.current) {
const scopeEl = scopeRef.current;
const isVisible = scopeEl.offsetParent !== null ||
const isVisible =
scopeEl.offsetParent !== null ||
getComputedStyle(scopeEl).display !== "none";
if (!isVisible) return;
}
@@ -259,14 +266,15 @@ export function HotkeyButton({
}, [config, hotkeyActive, handleKeyDown]);
// Render the hotkey indicator
const hotkeyIndicator = config && showHotkeyIndicator ? (
<span
className="ml-3 px-2 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/10 border border-primary-foreground/20 inline-flex items-center gap-1.5"
data-testid="hotkey-indicator"
>
{getHotkeyDisplayLabel(config, isMac)}
</span>
) : null;
const hotkeyIndicator =
config && showHotkeyIndicator ? (
<span
className="px-2 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/10 border border-primary-foreground/20 inline-flex items-center gap-1.5"
data-testid="hotkey-indicator"
>
{getHotkeyDisplayLabel(config, isMac)}
</span>
) : null;
return (
<Button

View File

@@ -592,7 +592,7 @@ export function AgentView() {
</div>
<Card
className={cn(
"max-w-[80%]",
"max-w-[80%] py-0",
message.role === "user"
? "bg-transparent border border-primary text-foreground"
: "border-l-4 border-primary bg-card"
@@ -628,7 +628,7 @@ export function AgentView() {
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<Bot className="w-4 h-4 text-primary" />
</div>
<Card className="border-l-4 border-primary bg-card">
<Card className="border-l-4 border-primary bg-card py-0">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-primary" />

View File

@@ -357,7 +357,10 @@ ${Object.entries(projectAnalysis.filesByExtension)
)
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
.slice(0, 5)
.map(([ext, count]: [string, number]) => ` <language ext=".${ext}" count="${count}" />`)
.map(
([ext, count]: [string, number]) =>
` <language ext=".${ext}" count="${count}" />`
)
.join("\n")}
</languages>
<frameworks>
@@ -756,6 +759,10 @@ ${Object.entries(projectAnalysis.filesByExtension)
}
// Create each feature using the features API
if (!api.features) {
throw new Error("Features API not available");
}
for (const feature of detectedFeatures) {
await api.features.create(currentProject.path, feature);
}
@@ -829,7 +836,9 @@ ${Object.entries(projectAnalysis.filesByExtension)
</div>
{node.isDirectory && isExpanded && node.children && (
<div>
{node.children.map((child: FileTreeNode) => renderNode(child, depth + 1))}
{node.children.map((child: FileTreeNode) =>
renderNode(child, depth + 1)
)}
</div>
)}
</div>
@@ -953,7 +962,10 @@ ${Object.entries(projectAnalysis.filesByExtension)
<CardContent>
<div className="space-y-2">
{Object.entries(projectAnalysis.filesByExtension)
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
.sort(
(a: [string, number], b: [string, number]) =>
b[1] - a[1]
)
.slice(0, 15)
.map(([ext, count]: [string, number]) => (
<div key={ext} className="flex justify-between text-sm">
@@ -1096,7 +1108,9 @@ ${Object.entries(projectAnalysis.filesByExtension)
data-testid="analysis-file-tree"
>
<div className="p-2">
{projectAnalysis.fileTree.map((node: FileTreeNode) => renderNode(node))}
{projectAnalysis.fileTree.map((node: FileTreeNode) =>
renderNode(node)
)}
</div>
</CardContent>
</Card>

View File

@@ -529,16 +529,22 @@ export function BoardView() {
const projectId = currentProject.id;
const unsubscribe = api.autoMode.onEvent((event) => {
// Use event's projectId if available, otherwise use current project
const eventProjectId = event.projectId || projectId;
// Use event's projectPath or projectId if available, otherwise use current project
// Board view only reacts to events for the currently selected project
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
if (event.type === "auto_mode_feature_complete") {
// Reload features when a feature is completed
console.log("[Board] Feature completed, reloading features...");
loadFeatures();
// Play ding sound when feature is done
const audio = new Audio("/sounds/ding.mp3");
audio.play().catch((err) => console.warn("Could not play ding sound:", err));
// Play ding sound when feature is done (unless muted)
const { muteDoneSound } = useAppStore.getState();
if (!muteDoneSound) {
const audio = new Audio("/sounds/ding.mp3");
audio
.play()
.catch((err) => console.warn("Could not play ding sound:", err));
}
} else if (event.type === "auto_mode_error") {
// Reload features when an error occurs (feature moved to waiting_approval)
console.log(
@@ -580,22 +586,36 @@ export function BoardView() {
const api = getElectronAPI();
if (!api?.autoMode?.status) return;
const status = await api.autoMode.status();
if (status.success && status.runningFeatures) {
console.log(
"[Board] Syncing running tasks from backend:",
status.runningFeatures
);
// Clear existing running tasks for this project and add the actual running ones
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
const status = await api.autoMode.status(currentProject.path);
if (status.success) {
const projectId = currentProject.id;
clearRunningTasks(projectId);
const { clearRunningTasks, addRunningTask, setAutoModeRunning } =
useAppStore.getState();
// Add each running feature to the store
status.runningFeatures.forEach((featureId: string) => {
addRunningTask(projectId, featureId);
});
// Sync running features if available
if (status.runningFeatures) {
console.log(
"[Board] Syncing running tasks from backend:",
status.runningFeatures
);
// Clear existing running tasks for this project and add the actual running ones
clearRunningTasks(projectId);
// Add each running feature to the store
status.runningFeatures.forEach((featureId: string) => {
addRunningTask(projectId, featureId);
});
}
// Sync auto mode running state (backend returns autoLoopRunning, mock returns isRunning)
const isAutoModeRunning =
status.autoLoopRunning ?? status.isRunning ?? false;
console.log(
"[Board] Syncing auto mode running state:",
isAutoModeRunning
);
setAutoModeRunning(projectId, isAutoModeRunning);
}
} catch (error) {
console.error("[Board] Failed to sync running tasks:", error);
@@ -1899,7 +1919,7 @@ export function BoardView() {
data-testid="start-next-button"
>
<FastForward className="w-3 h-3 mr-1" />
Start Next
Pull Top
</HotkeyButton>
)}
</div>

View File

@@ -20,8 +20,11 @@ import {
StopCircle,
ChevronDown,
ChevronRight,
RefreshCw,
Shield,
Zap,
} from "lucide-react";
import { getElectronAPI, FeatureSuggestion, SuggestionsEvent } from "@/lib/electron";
import { getElectronAPI, FeatureSuggestion, SuggestionsEvent, SuggestionType } from "@/lib/electron";
import { useAppStore, Feature } from "@/store/app-store";
import { toast } from "sonner";
@@ -36,6 +39,39 @@ interface FeatureSuggestionsDialogProps {
setIsGenerating: (generating: boolean) => void;
}
// Configuration for each suggestion type
const suggestionTypeConfig: Record<SuggestionType, {
label: string;
icon: React.ComponentType<{ className?: string }>;
description: string;
color: string;
}> = {
features: {
label: "Feature Suggestions",
icon: Lightbulb,
description: "Discover missing features and improvements",
color: "text-yellow-500",
},
refactoring: {
label: "Refactoring Suggestions",
icon: RefreshCw,
description: "Find code smells and refactoring opportunities",
color: "text-blue-500",
},
security: {
label: "Security Suggestions",
icon: Shield,
description: "Identify security vulnerabilities and issues",
color: "text-red-500",
},
performance: {
label: "Performance Suggestions",
icon: Zap,
description: "Discover performance bottlenecks and optimizations",
color: "text-green-500",
},
};
export function FeatureSuggestionsDialog({
open,
onClose,
@@ -49,6 +85,7 @@ export function FeatureSuggestionsDialog({
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [isImporting, setIsImporting] = useState(false);
const [currentSuggestionType, setCurrentSuggestionType] = useState<SuggestionType | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
@@ -87,7 +124,8 @@ export function FeatureSuggestionsDialog({
setSuggestions(event.suggestions);
// Select all by default
setSelectedIds(new Set(event.suggestions.map((s) => s.id)));
toast.success(`Generated ${event.suggestions.length} feature suggestions!`);
const typeLabel = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType].label.toLowerCase() : "suggestions";
toast.success(`Generated ${event.suggestions.length} ${typeLabel}!`);
} else {
toast.info("No suggestions generated. Try again.");
}
@@ -100,10 +138,10 @@ export function FeatureSuggestionsDialog({
return () => {
unsubscribe();
};
}, [open, setSuggestions, setIsGenerating]);
}, [open, setSuggestions, setIsGenerating, currentSuggestionType]);
// Start generating suggestions
const handleGenerate = useCallback(async () => {
// Start generating suggestions for a specific type
const handleGenerate = useCallback(async (suggestionType: SuggestionType) => {
const api = getElectronAPI();
if (!api?.suggestions) {
toast.error("Suggestions API not available");
@@ -114,9 +152,10 @@ export function FeatureSuggestionsDialog({
setProgress([]);
setSuggestions([]);
setSelectedIds(new Set());
setCurrentSuggestionType(suggestionType);
try {
const result = await api.suggestions.generate(projectPath);
const result = await api.suggestions.generate(projectPath, suggestionType);
if (!result.success) {
toast.error(result.error || "Failed to start generation");
setIsGenerating(false);
@@ -203,8 +242,10 @@ export function FeatureSuggestionsDialog({
}));
// Create each new feature using the features API
for (const feature of newFeatures) {
await api.features.create(projectPath, feature);
if (api.features) {
for (const feature of newFeatures) {
await api.features.create(projectPath, feature);
}
}
// Merge with existing features for store update
@@ -219,6 +260,7 @@ export function FeatureSuggestionsDialog({
setSuggestions([]);
setSelectedIds(new Set());
setProgress([]);
setCurrentSuggestionType(null);
onClose();
} catch (error) {
@@ -238,16 +280,17 @@ export function FeatureSuggestionsDialog({
autoScrollRef.current = isAtBottom;
};
// Reset state when dialog closes
useEffect(() => {
if (!open) {
// Don't reset immediately - allow re-open to see results
// Only reset if explicitly closed without importing
}
}, [open]);
// Go back to type selection
const handleBackToSelection = useCallback(() => {
setSuggestions([]);
setSelectedIds(new Set());
setProgress([]);
setCurrentSuggestionType(null);
}, [setSuggestions]);
const hasStarted = progress.length > 0 || suggestions.length > 0;
const hasSuggestions = suggestions.length > 0;
const currentConfig = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType] : null;
return (
<Dialog open={open} onOpenChange={onClose}>
@@ -257,31 +300,56 @@ export function FeatureSuggestionsDialog({
>
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<Lightbulb className="w-5 h-5 text-yellow-500" />
Feature Suggestions
{currentConfig ? (
<>
<currentConfig.icon className={`w-5 h-5 ${currentConfig.color}`} />
{currentConfig.label}
</>
) : (
<>
<Lightbulb className="w-5 h-5 text-yellow-500" />
AI Suggestions
</>
)}
</DialogTitle>
<DialogDescription>
Analyze your project to discover missing features and improvements.
The AI will scan your codebase and suggest features ordered by priority.
{currentConfig
? currentConfig.description
: "Analyze your project to discover improvements. Choose a suggestion type below."}
</DialogDescription>
</DialogHeader>
{!hasStarted ? (
// Initial state - show explanation and generate button
<div className="flex-1 flex flex-col items-center justify-center py-8 text-center">
<Lightbulb className="w-16 h-16 text-yellow-500/50 mb-4" />
<h3 className="text-lg font-semibold mb-2">
Discover Missing Features
</h3>
<p className="text-muted-foreground max-w-md mb-6">
Our AI will analyze your project structure, code patterns, and
existing features to generate a prioritized list of suggestions
for new features you could add.
// Initial state - show suggestion type buttons
<div className="flex-1 flex flex-col items-center justify-center py-8">
<p className="text-muted-foreground text-center max-w-lg mb-8">
Our AI will analyze your project and generate actionable suggestions.
Choose what type of analysis you want to perform:
</p>
<Button onClick={handleGenerate} size="lg">
<Lightbulb className="w-4 h-4 mr-2" />
Generate Suggestions
</Button>
<div className="grid grid-cols-2 gap-4 w-full max-w-2xl">
{(Object.entries(suggestionTypeConfig) as [SuggestionType, typeof suggestionTypeConfig[SuggestionType]][]).map(
([type, config]) => {
const Icon = config.icon;
return (
<Button
key={type}
variant="outline"
className="h-auto py-6 px-6 flex flex-col items-center gap-3 hover:border-primary/50 transition-colors"
onClick={() => handleGenerate(type)}
data-testid={`generate-${type}-btn`}
>
<Icon className={`w-8 h-8 ${config.color}`} />
<div className="text-center">
<div className="font-semibold">{config.label.replace(" Suggestions", "")}</div>
<div className="text-xs text-muted-foreground mt-1">
{config.description}
</div>
</div>
</Button>
);
}
)}
</div>
</div>
) : isGenerating ? (
// Generating state - show progress
@@ -410,20 +478,34 @@ export function FeatureSuggestionsDialog({
<p className="text-muted-foreground mb-4">
No suggestions were generated. Try running the analysis again.
</p>
<Button onClick={handleGenerate}>
<Lightbulb className="w-4 h-4 mr-2" />
Try Again
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={handleBackToSelection}>
Back to Selection
</Button>
{currentSuggestionType && (
<Button onClick={() => handleGenerate(currentSuggestionType)}>
<Lightbulb className="w-4 h-4 mr-2" />
Try Again
</Button>
)}
</div>
</div>
)}
<DialogFooter className="flex-shrink-0">
{hasSuggestions && (
<div className="flex gap-2 w-full justify-between">
<Button variant="outline" onClick={handleGenerate}>
<Lightbulb className="w-4 h-4 mr-2" />
Regenerate
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={handleBackToSelection}>
Back
</Button>
{currentSuggestionType && (
<Button variant="outline" onClick={() => handleGenerate(currentSuggestionType)}>
{currentConfig && <currentConfig.icon className="w-4 h-4 mr-2" />}
Regenerate
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel

View File

@@ -248,21 +248,12 @@ export const KanbanCard = memo(function KanbanCard({
{...attributes}
{...(isDraggable ? listeners : {})}
>
{/* Shortcut key badge for in-progress cards */}
{shortcutKey && (
<div
className="absolute top-2 left-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70 z-10"
data-testid={`shortcut-key-${feature.id}`}
>
{shortcutKey}
</div>
)}
{/* Skip Tests indicator badge */}
{feature.skipTests && !feature.error && (
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
shortcutKey ? "top-2 left-10" : "top-2 left-2",
"top-2 left-2",
"bg-orange-500/20 border border-orange-500/50 text-orange-400"
)}
data-testid={`skip-tests-badge-${feature.id}`}
@@ -277,7 +268,7 @@ export const KanbanCard = memo(function KanbanCard({
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
shortcutKey ? "top-2 left-10" : "top-2 left-2",
"top-2 left-2",
"bg-red-500/20 border border-red-500/50 text-red-400"
)}
data-testid={`error-badge-${feature.id}`}
@@ -299,9 +290,7 @@ export const KanbanCard = memo(function KanbanCard({
// Position below error badge if present, otherwise use normal position
feature.error || feature.skipTests
? "top-8 left-2"
: shortcutKey
? "top-2 left-10"
: "top-2 left-2"
: "top-2 left-2"
)}
data-testid={`branch-badge-${feature.id}`}
>
@@ -319,7 +308,7 @@ export const KanbanCard = memo(function KanbanCard({
className={cn(
"p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout
// Add extra top padding when badges are present to prevent text overlap
(feature.skipTests || feature.error || shortcutKey) && "pt-10",
(feature.skipTests || feature.error) && "pt-10",
// Add even more top padding when both badges and branch are shown
hasWorktree && (feature.skipTests || feature.error) && "pt-14"
)}
@@ -613,6 +602,14 @@ export const KanbanCard = memo(function KanbanCard({
>
<FileText className="w-3 h-3 mr-1" />
Logs
{shortcutKey && (
<span
className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/10 border border-primary-foreground/20"
data-testid={`shortcut-key-${feature.id}`}
>
{shortcutKey}
</span>
)}
</Button>
)}
{onForceStop && (

View File

@@ -0,0 +1,210 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Bot, Folder, Loader2, RefreshCw, Square, Activity } from "lucide-react";
import { getElectronAPI, RunningAgent } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function RunningAgentsView() {
const [runningAgents, setRunningAgents] = useState<RunningAgent[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const { setCurrentProject, projects, setCurrentView } = useAppStore();
const fetchRunningAgents = useCallback(async () => {
try {
const api = getElectronAPI();
if (api.runningAgents) {
const result = await api.runningAgents.getAll();
if (result.success && result.runningAgents) {
setRunningAgents(result.runningAgents);
}
}
} catch (error) {
console.error("[RunningAgentsView] Error fetching running agents:", error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
// Initial fetch
useEffect(() => {
fetchRunningAgents();
}, [fetchRunningAgents]);
// Auto-refresh every 2 seconds
useEffect(() => {
const interval = setInterval(() => {
fetchRunningAgents();
}, 2000);
return () => clearInterval(interval);
}, [fetchRunningAgents]);
// Subscribe to auto-mode events to update in real-time
useEffect(() => {
const api = getElectronAPI();
if (!api.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event) => {
// When a feature completes or errors, refresh the list
if (
event.type === "auto_mode_feature_complete" ||
event.type === "auto_mode_error"
) {
fetchRunningAgents();
}
});
return () => {
unsubscribe();
};
}, [fetchRunningAgents]);
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchRunningAgents();
}, [fetchRunningAgents]);
const handleStopAgent = useCallback(async (featureId: string) => {
try {
const api = getElectronAPI();
if (api.autoMode) {
await api.autoMode.stopFeature(featureId);
// Refresh list after stopping
fetchRunningAgents();
}
} catch (error) {
console.error("[RunningAgentsView] Error stopping agent:", error);
}
}, [fetchRunningAgents]);
const handleNavigateToProject = useCallback((agent: RunningAgent) => {
// Find the project by path
const project = projects.find((p) => p.path === agent.projectPath);
if (project) {
setCurrentProject(project);
setCurrentView("board");
}
}, [projects, setCurrentProject, setCurrentView]);
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex-1 flex flex-col overflow-hidden p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-brand-500/10">
<Activity className="h-6 w-6 text-brand-500" />
</div>
<div>
<h1 className="text-2xl font-bold">Running Agents</h1>
<p className="text-sm text-muted-foreground">
{runningAgents.length === 0
? "No agents currently running"
: `${runningAgents.length} agent${runningAgents.length === 1 ? "" : "s"} running across all projects`}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={refreshing}
>
<RefreshCw
className={cn("h-4 w-4 mr-2", refreshing && "animate-spin")}
/>
Refresh
</Button>
</div>
{/* Content */}
{runningAgents.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<Bot className="h-12 w-12 text-muted-foreground" />
</div>
<h2 className="text-lg font-medium mb-2">No Running Agents</h2>
<p className="text-muted-foreground max-w-md">
Agents will appear here when they are actively working on features.
Start an agent from the Kanban board by dragging a feature to "In Progress".
</p>
</div>
) : (
<div className="flex-1 overflow-auto">
<div className="space-y-3">
{runningAgents.map((agent) => (
<div
key={`${agent.projectPath}-${agent.featureId}`}
className="flex items-center justify-between p-4 rounded-lg border border-border bg-card hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-4 min-w-0">
{/* Status indicator */}
<div className="relative">
<Bot className="h-8 w-8 text-brand-500" />
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
</span>
</div>
{/* Agent info */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">
{agent.featureId}
</span>
{agent.isAutoMode && (
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-brand-500/10 text-brand-500 border border-brand-500/30">
AUTO
</span>
)}
</div>
<button
onClick={() => handleNavigateToProject(agent)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Folder className="h-3.5 w-3.5" />
<span className="truncate">{agent.projectName}</span>
</button>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handleNavigateToProject(agent)}
className="text-muted-foreground hover:text-foreground"
>
View Project
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleStopAgent(agent.featureId)}
>
<Square className="h-3.5 w-3.5 mr-1.5" />
Stop
</Button>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -42,6 +42,8 @@ import {
RefreshCw,
Info,
RotateCcw,
Volume2,
VolumeX,
} from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { Checkbox } from "@/components/ui/checkbox";
@@ -62,6 +64,7 @@ const NAV_ITEMS = [
{ id: "codex", label: "Codex", icon: Atom },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "kanban", label: "Kanban Display", icon: LayoutGrid },
{ id: "audio", label: "Audio", icon: Volume2 },
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
{ id: "danger", label: "Danger Zone", icon: Trash2 },
@@ -83,6 +86,8 @@ export function SettingsView() {
setUseWorktrees,
showProfilesOnly,
setShowProfilesOnly,
muteDoneSound,
setMuteDoneSound,
currentProject,
moveProjectToTrash,
keyboardShortcuts,
@@ -2132,6 +2137,55 @@ export function SettingsView() {
</div>
</div>
{/* Audio Section */}
<div
id="audio"
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
>
<div className="p-6 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<Volume2 className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-semibold text-foreground">
Audio
</h2>
</div>
<p className="text-sm text-muted-foreground">
Configure audio and notification settings.
</p>
</div>
<div className="p-6 space-y-4">
{/* Mute Done Sound Setting */}
<div className="space-y-3">
<div className="flex items-start space-x-3">
<Checkbox
id="mute-done-sound"
checked={muteDoneSound}
onCheckedChange={(checked) =>
setMuteDoneSound(checked === true)
}
className="mt-0.5"
data-testid="mute-done-sound-checkbox"
/>
<div className="space-y-1">
<Label
htmlFor="mute-done-sound"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<VolumeX className="w-4 h-4 text-brand-500" />
Mute notification sound when agents complete
</Label>
<p className="text-xs text-muted-foreground">
When enabled, disables the &quot;ding&quot; sound that
plays when an agent completes a feature. The feature
will still move to the completed column, but without
audio notification.
</p>
</div>
</div>
</div>
</div>
</div>
{/* Feature Defaults Section */}
<div
id="defaults"

View File

@@ -1,7 +1,8 @@
import { useEffect, useCallback, useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI, type AutoModeEvent } from "@/lib/electron";
import { getElectronAPI } from "@/lib/electron";
import type { AutoModeEvent } from "@/types/electron";
/**
* Hook for managing auto mode (scoped per project)
@@ -16,6 +17,7 @@ export function useAutoMode() {
currentProject,
addAutoModeActivity,
maxConcurrency,
projects,
} = useAppStore(
useShallow((state) => ({
autoModeByProject: state.autoModeByProject,
@@ -26,9 +28,16 @@ export function useAutoMode() {
currentProject: state.currentProject,
addAutoModeActivity: state.addAutoModeActivity,
maxConcurrency: state.maxConcurrency,
projects: state.projects,
}))
);
// Helper to look up project ID from path
const getProjectIdFromPath = useCallback((path: string): string | undefined => {
const project = projects.find(p => p.path === path);
return project?.id;
}, [projects]);
// Get project-specific auto mode state
const projectId = currentProject?.id;
const projectAutoModeState = useMemo(() => {
@@ -42,17 +51,32 @@ export function useAutoMode() {
// Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
// Handle auto mode events
// Handle auto mode events - listen globally for all projects
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode || !projectId) return;
if (!api?.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
console.log("[AutoMode Event]", event);
// Events include projectId from backend, use it to scope updates
// Events include projectPath from backend - use it to look up project ID
// Fall back to current projectId if not provided in event
const eventProjectId = event.projectId ?? projectId;
let eventProjectId: string | undefined;
if ('projectPath' in event && event.projectPath) {
eventProjectId = getProjectIdFromPath(event.projectPath);
}
if (!eventProjectId && 'projectId' in event && event.projectId) {
eventProjectId = event.projectId;
}
if (!eventProjectId) {
eventProjectId = projectId;
}
// Skip event if we couldn't determine the project
if (!eventProjectId) {
console.warn("[AutoMode] Could not determine project for event:", event);
return;
}
switch (event.type) {
case "auto_mode_feature_start":
@@ -153,8 +177,47 @@ export function useAutoMode() {
clearRunningTasks,
setAutoModeRunning,
addAutoModeActivity,
getProjectIdFromPath,
]);
// Restore auto mode for all projects that were running when app was closed
// This runs once on mount to restart auto loops for persisted running states
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode) return;
// Find all projects that have auto mode marked as running
const projectsToRestart: Array<{ projectId: string; projectPath: string }> = [];
for (const [projectId, state] of Object.entries(autoModeByProject)) {
if (state.isRunning) {
// Find the project path for this project ID
const project = projects.find(p => p.id === projectId);
if (project) {
projectsToRestart.push({ projectId, projectPath: project.path });
}
}
}
// Restart auto mode for each project
for (const { projectId, projectPath } of projectsToRestart) {
console.log(`[AutoMode] Restoring auto mode for project: ${projectPath}`);
api.autoMode.start(projectPath, maxConcurrency).then(result => {
if (!result.success) {
console.error(`[AutoMode] Failed to restore auto mode for ${projectPath}:`, result.error);
// Mark as not running if we couldn't restart
setAutoModeRunning(projectId, false);
} else {
console.log(`[AutoMode] Restored auto mode for ${projectPath}`);
}
}).catch(error => {
console.error(`[AutoMode] Error restoring auto mode for ${projectPath}:`, error);
setAutoModeRunning(projectId, false);
});
}
// Only run once on mount - intentionally empty dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Start auto mode
const start = useCallback(async () => {
if (!currentProject) {
@@ -199,7 +262,7 @@ export function useAutoMode() {
throw new Error("Auto mode API not available");
}
const result = await api.autoMode.stop();
const result = await api.autoMode.stop(currentProject.path);
if (result.success) {
setAutoModeRunning(currentProject.id, false);

View File

@@ -58,6 +58,26 @@ import type {
// Feature type - Import from app-store
import type { Feature } from "@/store/app-store";
// Running Agent type
export interface RunningAgent {
featureId: string;
projectPath: string;
projectName: string;
isAutoMode: boolean;
}
export interface RunningAgentsResult {
success: boolean;
runningAgents?: RunningAgent[];
totalCount?: number;
autoLoopRunning?: boolean;
error?: string;
}
export interface RunningAgentsAPI {
getAll: () => Promise<RunningAgentsResult>;
}
// Feature Suggestions types
export interface FeatureSuggestion {
id: string;
@@ -81,9 +101,12 @@ export interface SuggestionsEvent {
error?: string;
}
export type SuggestionType = "features" | "refactoring" | "security" | "performance";
export interface SuggestionsAPI {
generate: (
projectPath: string
projectPath: string,
suggestionType?: SuggestionType
) => Promise<{ success: boolean; error?: string }>;
stop: () => Promise<{ success: boolean; error?: string }>;
status: () => Promise<{
@@ -153,15 +176,18 @@ export interface AutoModeAPI {
projectPath: string,
maxConcurrency?: number
) => Promise<{ success: boolean; error?: string }>;
stop: () => Promise<{ success: boolean; error?: string }>;
stop: (projectPath: string) => Promise<{ success: boolean; error?: string; runningFeatures?: number }>;
stopFeature: (
featureId: string
) => Promise<{ success: boolean; error?: string }>;
status: () => Promise<{
status: (projectPath?: string) => Promise<{
success: boolean;
isRunning?: boolean;
autoLoopRunning?: boolean; // Backend uses this name instead of isRunning
currentFeatureId?: string | null;
runningFeatures?: string[];
runningProjects?: string[];
runningCount?: number;
error?: string;
}>;
runFeature: (
@@ -205,6 +231,7 @@ export interface SaveImageResult {
export interface ElectronAPI {
ping: () => Promise<string>;
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
openDirectory: () => Promise<DialogResult>;
openFile: (options?: object) => Promise<DialogResult>;
readFile: (filePath: string) => Promise<FileResult>;
@@ -276,6 +303,7 @@ export interface ElectronAPI {
specRegeneration?: SpecRegenerationAPI;
autoMode?: AutoModeAPI;
features?: FeaturesAPI;
runningAgents?: RunningAgentsAPI;
setup?: {
getClaudeStatus: () => Promise<{
success: boolean;
@@ -392,6 +420,12 @@ export const getElectronAPI = (): ElectronAPI => {
return {
ping: async () => "pong (mock)",
openExternalLink: async (url: string) => {
// In web mode, open in a new tab
window.open(url, "_blank", "noopener,noreferrer");
return { success: true };
},
openDirectory: async () => {
// In web mode, we'll use a prompt to simulate directory selection
const path = prompt(
@@ -670,6 +704,9 @@ export const getElectronAPI = (): ElectronAPI => {
// Mock Features API
features: createMockFeaturesAPI(),
// Mock Running Agents API
runningAgents: createMockRunningAgentsAPI(),
};
};
@@ -1010,13 +1047,14 @@ function createMockAutoModeAPI(): AutoModeAPI {
return { success: true };
},
stop: async () => {
stop: async (_projectPath: string) => {
mockAutoModeRunning = false;
const runningCount = mockRunningFeatures.size;
mockRunningFeatures.clear();
// Clear all timeouts
mockAutoModeTimeouts.forEach((timeout) => clearTimeout(timeout));
mockAutoModeTimeouts.clear();
return { success: true };
return { success: true, runningFeatures: runningCount };
},
stopFeature: async (featureId: string) => {
@@ -1045,12 +1083,14 @@ function createMockAutoModeAPI(): AutoModeAPI {
return { success: true };
},
status: async () => {
status: async (_projectPath?: string) => {
return {
success: true,
isRunning: mockAutoModeRunning,
autoLoopRunning: mockAutoModeRunning,
currentFeatureId: mockAutoModeRunning ? "feature-0" : null,
runningFeatures: Array.from(mockRunningFeatures),
runningCount: mockRunningFeatures.size,
};
},
@@ -1431,7 +1471,7 @@ let mockSuggestionsTimeout: NodeJS.Timeout | null = null;
function createMockSuggestionsAPI(): SuggestionsAPI {
return {
generate: async (projectPath: string) => {
generate: async (projectPath: string, suggestionType: SuggestionType = "features") => {
if (mockSuggestionsRunning) {
return {
success: false,
@@ -1440,10 +1480,10 @@ function createMockSuggestionsAPI(): SuggestionsAPI {
}
mockSuggestionsRunning = true;
console.log(`[Mock] Generating suggestions for: ${projectPath}`);
console.log(`[Mock] Generating ${suggestionType} suggestions for: ${projectPath}`);
// Simulate async suggestion generation
simulateSuggestionsGeneration();
simulateSuggestionsGeneration(suggestionType);
return { success: true };
},
@@ -1479,11 +1519,18 @@ function emitSuggestionsEvent(event: SuggestionsEvent) {
mockSuggestionsCallbacks.forEach((cb) => cb(event));
}
async function simulateSuggestionsGeneration() {
async function simulateSuggestionsGeneration(suggestionType: SuggestionType = "features") {
const typeLabels: Record<SuggestionType, string> = {
features: "feature suggestions",
refactoring: "refactoring opportunities",
security: "security vulnerabilities",
performance: "performance issues",
};
// Emit progress events
emitSuggestionsEvent({
type: "suggestions_progress",
content: "Starting project analysis...\n",
content: `Starting project analysis for ${typeLabels[suggestionType]}...\n`,
});
await new Promise((resolve) => {
@@ -1514,7 +1561,7 @@ async function simulateSuggestionsGeneration() {
emitSuggestionsEvent({
type: "suggestions_progress",
content: "Identifying missing features...\n",
content: `Identifying ${typeLabels[suggestionType]}...\n`,
});
await new Promise((resolve) => {
@@ -1522,75 +1569,184 @@ async function simulateSuggestionsGeneration() {
});
if (!mockSuggestionsRunning) return;
// Generate mock suggestions
const mockSuggestions: FeatureSuggestion[] = [
{
id: `suggestion-${Date.now()}-0`,
category: "User Experience",
description: "Add dark mode toggle with system preference detection",
steps: [
"Create a ThemeProvider context to manage theme state",
"Add a toggle component in the settings or header",
"Implement CSS variables for theme colors",
"Add localStorage persistence for user preference",
],
priority: 1,
reasoning:
"Dark mode is a standard feature that improves accessibility and user comfort",
},
{
id: `suggestion-${Date.now()}-1`,
category: "Performance",
description: "Implement lazy loading for heavy components",
steps: [
"Identify components that are heavy or rarely used",
"Use React.lazy() and Suspense for code splitting",
"Add loading states for lazy-loaded components",
],
priority: 2,
reasoning: "Improves initial load time and reduces bundle size",
},
{
id: `suggestion-${Date.now()}-2`,
category: "Accessibility",
description: "Add keyboard navigation support throughout the app",
steps: [
"Implement focus management for modals and dialogs",
"Add keyboard shortcuts for common actions",
"Ensure all interactive elements are focusable",
"Add ARIA labels and roles where needed",
],
priority: 3,
reasoning:
"Improves accessibility for users who rely on keyboard navigation",
},
{
id: `suggestion-${Date.now()}-3`,
category: "Testing",
description: "Add comprehensive unit test coverage",
steps: [
"Set up Jest and React Testing Library",
"Create tests for all utility functions",
"Add component tests for critical UI elements",
"Set up CI pipeline for automated testing",
],
priority: 4,
reasoning: "Ensures code quality and prevents regressions",
},
{
id: `suggestion-${Date.now()}-4`,
category: "Developer Experience",
description: "Add Storybook for component documentation",
steps: [
"Install and configure Storybook",
"Create stories for all UI components",
"Add interaction tests using play functions",
"Set up Chromatic for visual regression testing",
],
priority: 5,
reasoning: "Improves component development workflow and documentation",
},
];
// Generate mock suggestions based on type
let mockSuggestions: FeatureSuggestion[];
switch (suggestionType) {
case "refactoring":
mockSuggestions = [
{
id: `suggestion-${Date.now()}-0`,
category: "Code Smell",
description: "Extract duplicate validation logic into reusable utility",
steps: [
"Identify all files with similar validation patterns",
"Create a validation utilities module",
"Replace duplicate code with utility calls",
"Add unit tests for the new utilities",
],
priority: 1,
reasoning: "Reduces code duplication and improves maintainability",
},
{
id: `suggestion-${Date.now()}-1`,
category: "Complexity",
description: "Break down large handleSubmit function into smaller functions",
steps: [
"Identify the handleSubmit function in form components",
"Extract validation logic into separate function",
"Extract API call logic into separate function",
"Extract success/error handling into separate functions",
],
priority: 2,
reasoning: "Function is too long and handles multiple responsibilities",
},
{
id: `suggestion-${Date.now()}-2`,
category: "Architecture",
description: "Move business logic out of React components into hooks",
steps: [
"Identify business logic in component files",
"Create custom hooks for reusable logic",
"Update components to use the new hooks",
"Add tests for the extracted hooks",
],
priority: 3,
reasoning: "Improves separation of concerns and testability",
},
];
break;
case "security":
mockSuggestions = [
{
id: `suggestion-${Date.now()}-0`,
category: "High",
description: "Sanitize user input before rendering to prevent XSS",
steps: [
"Audit all places where user input is rendered",
"Implement input sanitization using DOMPurify",
"Add Content-Security-Policy headers",
"Test with common XSS payloads",
],
priority: 1,
reasoning: "User input is rendered without proper sanitization",
},
{
id: `suggestion-${Date.now()}-1`,
category: "Medium",
description: "Add rate limiting to authentication endpoints",
steps: [
"Implement rate limiting middleware",
"Configure limits for login attempts",
"Add account lockout after failed attempts",
"Log suspicious activity",
],
priority: 2,
reasoning: "Prevents brute force attacks on authentication",
},
{
id: `suggestion-${Date.now()}-2`,
category: "Low",
description: "Remove sensitive information from error messages",
steps: [
"Audit error handling in API routes",
"Create generic error messages for production",
"Log detailed errors server-side only",
"Implement proper error boundaries",
],
priority: 3,
reasoning: "Error messages may leak implementation details",
},
];
break;
case "performance":
mockSuggestions = [
{
id: `suggestion-${Date.now()}-0`,
category: "Rendering",
description: "Add React.memo to prevent unnecessary re-renders",
steps: [
"Profile component renders with React DevTools",
"Identify components that re-render unnecessarily",
"Wrap pure components with React.memo",
"Use useCallback for event handlers passed as props",
],
priority: 1,
reasoning: "Components re-render even when props haven't changed",
},
{
id: `suggestion-${Date.now()}-1`,
category: "Bundle Size",
description: "Implement code splitting for route components",
steps: [
"Use React.lazy for route components",
"Add Suspense boundaries with loading states",
"Analyze bundle with webpack-bundle-analyzer",
"Consider dynamic imports for heavy libraries",
],
priority: 2,
reasoning: "Initial bundle is larger than necessary",
},
{
id: `suggestion-${Date.now()}-2`,
category: "Caching",
description: "Add memoization for expensive computations",
steps: [
"Identify expensive calculations in render",
"Use useMemo for derived data",
"Consider using react-query for server state",
"Add caching headers for static assets",
],
priority: 3,
reasoning: "Expensive computations run on every render",
},
];
break;
default: // "features"
mockSuggestions = [
{
id: `suggestion-${Date.now()}-0`,
category: "User Experience",
description: "Add dark mode toggle with system preference detection",
steps: [
"Create a ThemeProvider context to manage theme state",
"Add a toggle component in the settings or header",
"Implement CSS variables for theme colors",
"Add localStorage persistence for user preference",
],
priority: 1,
reasoning: "Dark mode is a standard feature that improves accessibility and user comfort",
},
{
id: `suggestion-${Date.now()}-1`,
category: "Performance",
description: "Implement lazy loading for heavy components",
steps: [
"Identify components that are heavy or rarely used",
"Use React.lazy() and Suspense for code splitting",
"Add loading states for lazy-loaded components",
],
priority: 2,
reasoning: "Improves initial load time and reduces bundle size",
},
{
id: `suggestion-${Date.now()}-2`,
category: "Accessibility",
description: "Add keyboard navigation support throughout the app",
steps: [
"Implement focus management for modals and dialogs",
"Add keyboard shortcuts for common actions",
"Ensure all interactive elements are focusable",
"Add ARIA labels and roles where needed",
],
priority: 3,
reasoning: "Improves accessibility for users who rely on keyboard navigation",
},
];
}
emitSuggestionsEvent({
type: "suggestions_complete",
@@ -1911,6 +2067,30 @@ function createMockFeaturesAPI(): FeaturesAPI {
};
}
// Mock Running Agents API implementation
function createMockRunningAgentsAPI(): RunningAgentsAPI {
return {
getAll: async () => {
console.log("[Mock] Getting all running agents");
// Return running agents from mock auto mode state
const runningAgents: RunningAgent[] = Array.from(mockRunningFeatures).map(
(featureId) => ({
featureId,
projectPath: "/mock/project",
projectName: "Mock Project",
isAutoMode: mockAutoModeRunning,
})
);
return {
success: true,
runningAgents,
totalCount: runningAgents.length,
autoLoopRunning: mockAutoModeRunning,
};
},
};
}
// Utility functions for project management
export interface Project {

View File

@@ -12,7 +12,8 @@ export type ViewMode =
| "tools"
| "interview"
| "context"
| "profiles";
| "profiles"
| "running-agents";
export type ThemeMode =
| "light"
@@ -260,6 +261,9 @@ export interface AppState {
// Keyboard Shortcuts
keyboardShortcuts: KeyboardShortcuts; // User-defined keyboard shortcuts
// Audio Settings
muteDoneSound: boolean; // When true, mute the notification sound when agents complete (default: false)
// Project Analysis
projectAnalysis: ProjectAnalysis | null;
isAnalyzing: boolean;
@@ -367,6 +371,9 @@ export interface AppActions {
setKeyboardShortcuts: (shortcuts: Partial<KeyboardShortcuts>) => void;
resetKeyboardShortcuts: () => void;
// Audio Settings actions
setMuteDoneSound: (muted: boolean) => void;
// AI Profile actions
addAIProfile: (profile: Omit<AIProfile, "id">) => void;
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
@@ -469,6 +476,7 @@ const initialState: AppState = {
useWorktrees: false, // Default to disabled (worktree feature is experimental)
showProfilesOnly: false, // Default to showing all options (not profiles only)
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
muteDoneSound: false, // Default to sound enabled (not muted)
aiProfiles: DEFAULT_AI_PROFILES,
projectAnalysis: null,
isAnalyzing: false,
@@ -997,6 +1005,9 @@ export const useAppStore = create<AppState & AppActions>()(
set({ keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS });
},
// Audio Settings actions
setMuteDoneSound: (muted) => set({ muteDoneSound: muted }),
// AI Profile actions
addAIProfile: (profile) => {
const id = `profile-${Date.now()}-${Math.random()
@@ -1071,11 +1082,13 @@ export const useAppStore = create<AppState & AppActions>()(
chatSessions: state.chatSessions,
chatHistoryOpen: state.chatHistoryOpen,
maxConcurrency: state.maxConcurrency,
autoModeByProject: state.autoModeByProject,
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
defaultSkipTests: state.defaultSkipTests,
useWorktrees: state.useWorktrees,
showProfilesOnly: state.showProfilesOnly,
keyboardShortcuts: state.keyboardShortcuts,
muteDoneSound: state.muteDoneSound,
aiProfiles: state.aiProfiles,
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
}),

View File

@@ -163,18 +163,21 @@ export type AutoModeEvent =
type: "auto_mode_feature_start";
featureId: string;
projectId?: string;
projectPath?: string;
feature: unknown;
}
| {
type: "auto_mode_progress";
featureId: string;
projectId?: string;
projectPath?: string;
content: string;
}
| {
type: "auto_mode_tool";
featureId: string;
projectId?: string;
projectPath?: string;
tool: string;
input: unknown;
}
@@ -182,6 +185,7 @@ export type AutoModeEvent =
type: "auto_mode_feature_complete";
featureId: string;
projectId?: string;
projectPath?: string;
passes: boolean;
message: string;
}
@@ -190,22 +194,26 @@ export type AutoModeEvent =
error: string;
featureId?: string;
projectId?: string;
projectPath?: string;
}
| {
type: "auto_mode_complete";
message: string;
projectId?: string;
projectPath?: string;
}
| {
type: "auto_mode_phase";
featureId: string;
projectId?: string;
projectPath?: string;
phase: "planning" | "action" | "verification";
message: string;
}
| {
type: "auto_mode_ultrathink_preparation";
featureId: string;
projectPath?: string;
warnings: string[];
recommendations: string[];
estimatedCost?: number;
@@ -264,14 +272,15 @@ export interface SpecRegenerationAPI {
}
export interface AutoModeAPI {
start: (projectPath: string) => Promise<{
start: (projectPath: string, maxConcurrency?: number) => Promise<{
success: boolean;
error?: string;
}>;
stop: () => Promise<{
stop: (projectPath: string) => Promise<{
success: boolean;
error?: string;
runningFeatures?: number;
}>;
stopFeature: (featureId: string) => Promise<{
@@ -279,11 +288,14 @@ export interface AutoModeAPI {
error?: string;
}>;
status: () => Promise<{
status: (projectPath?: string) => Promise<{
success: boolean;
autoLoopRunning?: boolean;
isRunning?: boolean;
currentFeatureId?: string | null;
runningFeatures?: string[];
runningProjects?: string[];
runningCount?: number;
error?: string;
}>;