mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
working on improving the app spec page
This commit is contained in:
@@ -381,8 +381,6 @@ export function Sidebar() {
|
||||
toast.success("App specification created", {
|
||||
description: "Your project is now set up and ready to go!",
|
||||
});
|
||||
// Navigate to spec view to show the new spec
|
||||
setCurrentView("spec");
|
||||
} else if (event.type === "spec_regeneration_error") {
|
||||
setSpecCreatingForProject(null);
|
||||
toast.error("Failed to create specification", {
|
||||
|
||||
@@ -31,6 +31,8 @@ import { getHttpApiClient } from "@/lib/http-api-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFileBrowser } from "@/contexts/file-browser-context";
|
||||
|
||||
const LAST_PROJECT_DIR_KEY = "automaker:lastProjectDir";
|
||||
|
||||
interface ValidationErrors {
|
||||
projectName?: boolean;
|
||||
workspaceDir?: boolean;
|
||||
@@ -80,6 +82,14 @@ export function NewProjectModal({
|
||||
// Fetch workspace directory when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// First, check localStorage for last used directory
|
||||
const lastUsedDir = localStorage.getItem(LAST_PROJECT_DIR_KEY);
|
||||
if (lastUsedDir) {
|
||||
setWorkspaceDir(lastUsedDir);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to server config if no saved directory
|
||||
setIsLoadingWorkspace(true);
|
||||
const httpClient = getHttpApiClient();
|
||||
httpClient.workspace
|
||||
@@ -201,6 +211,8 @@ export function NewProjectModal({
|
||||
});
|
||||
if (selectedPath) {
|
||||
setWorkspaceDir(selectedPath);
|
||||
// Save to localStorage for next time
|
||||
localStorage.setItem(LAST_PROJECT_DIR_KEY, selectedPath);
|
||||
// Clear any workspace error when a valid directory is selected
|
||||
if (errors.workspaceDir) {
|
||||
setErrors((prev) => ({ ...prev, workspaceDir: false }));
|
||||
|
||||
@@ -1554,6 +1554,13 @@ export function BoardView() {
|
||||
}
|
||||
});
|
||||
|
||||
// Sort backlog by priority: 1 (high) -> 2 (medium) -> 3 (low) -> no priority
|
||||
map.backlog.sort((a, b) => {
|
||||
const aPriority = a.priority ?? 999; // Features without priority go last
|
||||
const bPriority = b.priority ?? 999;
|
||||
return aPriority - bPriority;
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [features, runningAutoTasks, searchQuery]);
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Brain,
|
||||
Flag,
|
||||
} from "lucide-react";
|
||||
import { CountUpTimer } from "@/components/ui/count-up-timer";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
@@ -89,6 +90,33 @@ function formatThinkingLevel(level: ThinkingLevel | undefined): string {
|
||||
return labels[level];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats priority for display
|
||||
*/
|
||||
function formatPriority(priority: number | undefined): string | null {
|
||||
if (!priority) return null;
|
||||
const labels: Record<number, string> = {
|
||||
1: "High",
|
||||
2: "Medium",
|
||||
3: "Low",
|
||||
};
|
||||
return labels[priority] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets priority badge color classes
|
||||
*/
|
||||
function getPriorityBadgeClasses(priority: number | undefined): string {
|
||||
if (priority === 1) {
|
||||
return "bg-red-500/20 border border-red-500/50 text-red-400";
|
||||
} else if (priority === 2) {
|
||||
return "bg-yellow-500/20 border border-yellow-500/50 text-yellow-400";
|
||||
} else if (priority === 3) {
|
||||
return "bg-blue-500/20 border border-blue-500/50 text-blue-400";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
interface KanbanCardProps {
|
||||
feature: Feature;
|
||||
onEdit: () => void;
|
||||
@@ -198,6 +226,34 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
return () => clearInterval(interval);
|
||||
}, [feature.justFinishedAt, feature.status, currentTime]);
|
||||
|
||||
// Calculate priority badge position
|
||||
const priorityLabel = formatPriority(feature.priority);
|
||||
const hasPriority = !!priorityLabel;
|
||||
|
||||
// Calculate top position for badges (stacking vertically)
|
||||
const getBadgeTopPosition = (badgeIndex: number) => {
|
||||
return badgeIndex === 0
|
||||
? "top-2"
|
||||
: badgeIndex === 1
|
||||
? "top-8"
|
||||
: badgeIndex === 2
|
||||
? "top-14"
|
||||
: "top-20";
|
||||
};
|
||||
|
||||
// Determine badge positions (must be after isJustFinished is defined)
|
||||
let badgeIndex = 0;
|
||||
const priorityBadgeIndex = hasPriority ? badgeIndex++ : -1;
|
||||
const skipTestsBadgeIndex =
|
||||
feature.skipTests && !feature.error ? badgeIndex++ : -1;
|
||||
const errorBadgeIndex = feature.error ? badgeIndex++ : -1;
|
||||
const justFinishedBadgeIndex = isJustFinished ? badgeIndex++ : -1;
|
||||
const branchBadgeIndex =
|
||||
hasWorktree && !isCurrentAutoTask ? badgeIndex++ : -1;
|
||||
|
||||
// Total number of badges displayed
|
||||
const totalBadgeCount = badgeIndex;
|
||||
|
||||
// Load context file for in_progress, waiting_approval, and verified features
|
||||
useEffect(() => {
|
||||
const loadContext = async () => {
|
||||
@@ -353,12 +409,29 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
style={{ opacity: opacity / 100 }}
|
||||
/>
|
||||
)}
|
||||
{/* Priority badge */}
|
||||
{hasPriority && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
|
||||
getBadgeTopPosition(priorityBadgeIndex),
|
||||
"left-2",
|
||||
getPriorityBadgeClasses(feature.priority)
|
||||
)}
|
||||
data-testid={`priority-badge-${feature.id}`}
|
||||
title={`Priority: ${priorityLabel}`}
|
||||
>
|
||||
<Flag className="w-3 h-3" />
|
||||
<span>{priorityLabel}</span>
|
||||
</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",
|
||||
"top-2 left-2",
|
||||
getBadgeTopPosition(skipTestsBadgeIndex),
|
||||
"left-2",
|
||||
"bg-orange-500/20 border border-orange-500/50 text-orange-400"
|
||||
)}
|
||||
data-testid={`skip-tests-badge-${feature.id}`}
|
||||
@@ -373,7 +446,8 @@ 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",
|
||||
"top-2 left-2",
|
||||
getBadgeTopPosition(errorBadgeIndex),
|
||||
"left-2",
|
||||
"bg-red-500/20 border border-red-500/50 text-red-400"
|
||||
)}
|
||||
data-testid={`error-badge-${feature.id}`}
|
||||
@@ -388,7 +462,8 @@ 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",
|
||||
feature.skipTests ? "top-8 left-2" : "top-2 left-2",
|
||||
getBadgeTopPosition(justFinishedBadgeIndex),
|
||||
"left-2",
|
||||
"bg-green-500/20 border border-green-500/50 text-green-400 animate-pulse"
|
||||
)}
|
||||
data-testid={`just-finished-badge-${feature.id}`}
|
||||
@@ -407,10 +482,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
className={cn(
|
||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default",
|
||||
"bg-purple-500/20 border border-purple-500/50 text-purple-400",
|
||||
// Position below other badges if present, otherwise use normal position
|
||||
feature.error || feature.skipTests || isJustFinished
|
||||
? "top-8 left-2"
|
||||
: "top-2 left-2"
|
||||
getBadgeTopPosition(branchBadgeIndex),
|
||||
"left-2"
|
||||
)}
|
||||
data-testid={`branch-badge-${feature.id}`}
|
||||
>
|
||||
@@ -432,11 +505,11 @@ 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 || isJustFinished) && "pt-10",
|
||||
// Add even more top padding when both badges and branch are shown
|
||||
hasWorktree &&
|
||||
(feature.skipTests || feature.error || isJustFinished) &&
|
||||
"pt-14"
|
||||
// Calculate padding based on number of badges
|
||||
totalBadgeCount === 1 && "pt-10",
|
||||
totalBadgeCount === 2 && "pt-14",
|
||||
totalBadgeCount === 3 && "pt-20",
|
||||
totalBadgeCount >= 4 && "pt-24"
|
||||
)}
|
||||
>
|
||||
{isCurrentAutoTask && (
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
Loader2,
|
||||
FilePlus2,
|
||||
AlertCircle,
|
||||
ListPlus,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -47,12 +46,17 @@ export function SpecView() {
|
||||
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
|
||||
const [projectDefinition, setProjectDefinition] = useState("");
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [generateFeaturesOnRegenerate, setGenerateFeaturesOnRegenerate] =
|
||||
useState(true);
|
||||
const [analyzeProjectOnRegenerate, setAnalyzeProjectOnRegenerate] =
|
||||
useState(true);
|
||||
|
||||
// Create spec state
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [projectOverview, setProjectOverview] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [generateFeatures, setGenerateFeatures] = useState(true);
|
||||
const [analyzeProjectOnCreate, setAnalyzeProjectOnCreate] = useState(true);
|
||||
|
||||
// Generate features only state
|
||||
const [isGeneratingFeatures, setIsGeneratingFeatures] = useState(false);
|
||||
@@ -66,6 +70,7 @@ export function SpecView() {
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
const statusCheckRef = useRef<boolean>(false);
|
||||
const stateRestoredRef = useRef<boolean>(false);
|
||||
const pendingStatusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Load spec from file
|
||||
const loadSpec = useCallback(async () => {
|
||||
@@ -99,6 +104,26 @@ export function SpecView() {
|
||||
loadSpec();
|
||||
}, [loadSpec]);
|
||||
|
||||
// Reset all spec regeneration state when project changes
|
||||
useEffect(() => {
|
||||
// Clear all state when switching projects
|
||||
setIsCreating(false);
|
||||
setIsRegenerating(false);
|
||||
setIsGeneratingFeatures(false);
|
||||
setCurrentPhase("");
|
||||
setErrorMessage("");
|
||||
setLogs("");
|
||||
logsRef.current = "";
|
||||
stateRestoredRef.current = false;
|
||||
statusCheckRef.current = false;
|
||||
|
||||
// Clear any pending timeout
|
||||
if (pendingStatusTimeoutRef.current) {
|
||||
clearTimeout(pendingStatusTimeoutRef.current);
|
||||
pendingStatusTimeoutRef.current = null;
|
||||
}
|
||||
}, [currentProject?.path]);
|
||||
|
||||
// Check if spec regeneration is running when component mounts or project changes
|
||||
useEffect(() => {
|
||||
const checkStatus = async () => {
|
||||
@@ -113,40 +138,44 @@ export function SpecView() {
|
||||
}
|
||||
|
||||
const status = await api.specRegeneration.status();
|
||||
console.log("[SpecView] Status check on mount:", status);
|
||||
console.log(
|
||||
"[SpecView] Status check on mount:",
|
||||
status,
|
||||
"for project:",
|
||||
currentProject.path
|
||||
);
|
||||
|
||||
if (status.success && status.isRunning) {
|
||||
// Something is running - restore state using backend's authoritative phase
|
||||
// Something is running globally, but we can't verify it's for this project
|
||||
// since the backend doesn't track projectPath in status
|
||||
// Tentatively show loader - events will confirm if it's for this project
|
||||
console.log(
|
||||
"[SpecView] Spec generation is running - restoring state",
|
||||
{ phase: status.currentPhase }
|
||||
"[SpecView] Spec generation is running globally. Tentatively showing loader, waiting for events to confirm project match."
|
||||
);
|
||||
|
||||
if (!stateRestoredRef.current) {
|
||||
setIsCreating(true);
|
||||
setIsRegenerating(true);
|
||||
stateRestoredRef.current = true;
|
||||
}
|
||||
|
||||
// Use the backend's currentPhase directly - single source of truth
|
||||
// Tentatively set state - events will confirm or clear it
|
||||
setIsCreating(true);
|
||||
setIsRegenerating(true);
|
||||
if (status.currentPhase) {
|
||||
setCurrentPhase(status.currentPhase);
|
||||
} else {
|
||||
setCurrentPhase("in progress");
|
||||
setCurrentPhase("initialization");
|
||||
}
|
||||
|
||||
// Add resume message to logs if needed
|
||||
if (!logsRef.current) {
|
||||
const resumeMessage =
|
||||
"[Status] Resumed monitoring existing spec generation process...\n";
|
||||
logsRef.current = resumeMessage;
|
||||
setLogs(resumeMessage);
|
||||
} else if (!logsRef.current.includes("Resumed monitoring")) {
|
||||
const resumeMessage =
|
||||
"\n[Status] Resumed monitoring existing spec generation process...\n";
|
||||
logsRef.current = logsRef.current + resumeMessage;
|
||||
setLogs(logsRef.current);
|
||||
// Set a timeout to clear state if no events arrive for this project within 3 seconds
|
||||
if (pendingStatusTimeoutRef.current) {
|
||||
clearTimeout(pendingStatusTimeoutRef.current);
|
||||
}
|
||||
pendingStatusTimeoutRef.current = setTimeout(() => {
|
||||
// If no events confirmed this is for current project, clear state
|
||||
console.log(
|
||||
"[SpecView] No events received for current project - clearing tentative state"
|
||||
);
|
||||
setIsCreating(false);
|
||||
setIsRegenerating(false);
|
||||
setCurrentPhase("");
|
||||
pendingStatusTimeoutRef.current = null;
|
||||
}, 3000);
|
||||
} else if (status.success && !status.isRunning) {
|
||||
// Not running - clear all state
|
||||
setIsCreating(false);
|
||||
@@ -274,6 +303,8 @@ export function SpecView() {
|
||||
|
||||
// Subscribe to spec regeneration events
|
||||
useEffect(() => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) return;
|
||||
|
||||
@@ -283,7 +314,9 @@ export function SpecView() {
|
||||
"[SpecView] Regeneration event:",
|
||||
event.type,
|
||||
"for project:",
|
||||
event.projectPath
|
||||
event.projectPath,
|
||||
"current project:",
|
||||
currentProject?.path
|
||||
);
|
||||
|
||||
// Only handle events for the current project
|
||||
@@ -292,7 +325,20 @@ export function SpecView() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending timeout since we received an event for this project
|
||||
if (pendingStatusTimeoutRef.current) {
|
||||
clearTimeout(pendingStatusTimeoutRef.current);
|
||||
pendingStatusTimeoutRef.current = null;
|
||||
console.log(
|
||||
"[SpecView] Event confirmed this is for current project - clearing timeout"
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === "spec_regeneration_progress") {
|
||||
// Ensure state is set when we receive events for this project
|
||||
setIsCreating(true);
|
||||
setIsRegenerating(true);
|
||||
|
||||
// Extract phase from content if present
|
||||
const phaseMatch = event.content.match(/\[Phase:\s*([^\]]+)\]/);
|
||||
if (phaseMatch) {
|
||||
@@ -475,7 +521,7 @@ export function SpecView() {
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [loadSpec]);
|
||||
}, [currentProject?.path, loadSpec, errorMessage, currentPhase]);
|
||||
|
||||
// Save spec to file
|
||||
const saveSpec = async () => {
|
||||
@@ -505,12 +551,16 @@ export function SpecView() {
|
||||
if (!currentProject || !projectDefinition.trim()) return;
|
||||
|
||||
setIsRegenerating(true);
|
||||
setShowRegenerateDialog(false);
|
||||
setCurrentPhase("initialization");
|
||||
setErrorMessage("");
|
||||
// Reset logs when starting new regeneration
|
||||
logsRef.current = "";
|
||||
setLogs("");
|
||||
console.log("[SpecView] Starting spec regeneration");
|
||||
console.log(
|
||||
"[SpecView] Starting spec regeneration, generateFeatures:",
|
||||
generateFeaturesOnRegenerate
|
||||
);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) {
|
||||
@@ -520,7 +570,9 @@ export function SpecView() {
|
||||
}
|
||||
const result = await api.specRegeneration.generate(
|
||||
currentProject.path,
|
||||
projectDefinition.trim()
|
||||
projectDefinition.trim(),
|
||||
generateFeaturesOnRegenerate,
|
||||
analyzeProjectOnRegenerate
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
@@ -570,7 +622,8 @@ export function SpecView() {
|
||||
const result = await api.specRegeneration.create(
|
||||
currentProject.path,
|
||||
projectOverview.trim(),
|
||||
generateFeatures
|
||||
generateFeatures,
|
||||
analyzeProjectOnCreate
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
@@ -839,6 +892,33 @@ export function SpecView() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 pt-2">
|
||||
<Checkbox
|
||||
id="analyze-project-create"
|
||||
checked={analyzeProjectOnCreate}
|
||||
onCheckedChange={(checked) =>
|
||||
setAnalyzeProjectOnCreate(checked === true)
|
||||
}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="analyze-project-create"
|
||||
className={`text-sm font-medium ${
|
||||
isCreating ? "" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
Analyze current project for additional context
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If checked, the agent will research your existing codebase
|
||||
to understand the tech stack. If unchecked, defaults to
|
||||
TanStack Start, Drizzle ORM, PostgreSQL, shadcn/ui, Tailwind
|
||||
CSS, and React.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 pt-2">
|
||||
<Checkbox
|
||||
id="generate-features"
|
||||
@@ -1052,27 +1132,61 @@ export function SpecView() {
|
||||
disabled={isRegenerating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 pt-2">
|
||||
<Checkbox
|
||||
id="analyze-project-regenerate"
|
||||
checked={analyzeProjectOnRegenerate}
|
||||
onCheckedChange={(checked) =>
|
||||
setAnalyzeProjectOnRegenerate(checked === true)
|
||||
}
|
||||
disabled={isRegenerating}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="analyze-project-regenerate"
|
||||
className={`text-sm font-medium ${
|
||||
isRegenerating ? "" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
Analyze current project for additional context
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If checked, the agent will research your existing codebase to
|
||||
understand the tech stack. If unchecked, defaults to TanStack
|
||||
Start, Drizzle ORM, PostgreSQL, shadcn/ui, Tailwind CSS, and
|
||||
React.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 pt-2">
|
||||
<Checkbox
|
||||
id="generate-features-regenerate"
|
||||
checked={generateFeaturesOnRegenerate}
|
||||
onCheckedChange={(checked) =>
|
||||
setGenerateFeaturesOnRegenerate(checked === true)
|
||||
}
|
||||
disabled={isRegenerating}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="generate-features-regenerate"
|
||||
className={`text-sm font-medium ${
|
||||
isRegenerating ? "" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
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 regenerated.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-between sm:justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleGenerateFeatures}
|
||||
disabled={isRegenerating || isGeneratingFeatures}
|
||||
title="Generate features from the existing app_spec.txt without regenerating the spec"
|
||||
>
|
||||
{isGeneratingFeatures ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ListPlus className="w-4 h-4 mr-2" />
|
||||
Generate Features
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<DialogFooter>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
Reference in New Issue
Block a user