working on improving the app spec page

This commit is contained in:
Cody Seibert
2025-12-14 17:38:12 -05:00
parent fa47264c76
commit b3ea506a73
26 changed files with 1283 additions and 871 deletions

View File

@@ -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", {

View File

@@ -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 }));

View File

@@ -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]);

View File

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

View File

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