mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
210
app/src/components/views/running-agents-view.tsx
Normal file
210
app/src/components/views/running-agents-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 "ding" 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"
|
||||
|
||||
Reference in New Issue
Block a user