feat: implement E2E testing workflow and enhance XML syntax editor

- Added a new GitHub Actions workflow for end-to-end (E2E) testing, including setup for Node.js, Playwright, and server initialization.
- Introduced a setup script for E2E test fixtures to create necessary directories and files.
- Integrated CodeMirror for XML syntax editing in the XmlSyntaxEditor component, improving code highlighting and editing experience.
- Updated package dependencies in package.json and package-lock.json to include new libraries for XML handling and theming.
- Refactored various components for improved readability and consistency, including the sidebar and file browser dialog.
- Added tests for spec editor persistence to ensure data integrity across sessions.
This commit is contained in:
Cody Seibert
2025-12-14 14:12:38 -05:00
parent ae13551033
commit 20a7c8b5a8
16 changed files with 2132 additions and 893 deletions

91
.github/workflows/e2e-tests.yml vendored Normal file
View File

@@ -0,0 +1,91 @@
name: E2E Tests
on:
pull_request:
branches:
- "*"
push:
branches:
- main
- master
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: package-lock.json
- name: Install dependencies
# Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
run: npm install
- name: Install Linux native bindings
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Explicitly install Linux bindings needed for build tools
run: |
npm install --no-save --force \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
working-directory: apps/app
- name: Build server
run: npm run build --workspace=apps/server
- name: Start backend server
run: npm run start --workspace=apps/server &
env:
PORT: 3008
NODE_ENV: test
- name: Wait for backend server
run: |
echo "Waiting for backend server to be ready..."
for i in {1..30}; do
if curl -s http://localhost:3008/api/health > /dev/null 2>&1; then
echo "Backend server is ready!"
exit 0
fi
echo "Waiting... ($i/30)"
sleep 1
done
echo "Backend server failed to start!"
exit 1
- name: Run E2E tests
# Playwright automatically starts the Next.js frontend via webServer config
# (see apps/app/playwright.config.ts) - no need to start it manually
run: npm run test --workspace=apps/app
env:
CI: true
NEXT_PUBLIC_SERVER_URL: http://localhost:3008
NEXT_PUBLIC_SKIP_SETUP: "true"
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: apps/app/playwright-report/
retention-days: 7
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results
path: apps/app/test-results/
retention-days: 7

View File

@@ -27,15 +27,19 @@
"postinstall": "electron-builder install-app-deps",
"start": "next start",
"lint": "eslint",
"pretest": "node scripts/setup-e2e-fixtures.js",
"test": "playwright test",
"test:headed": "playwright test --headed",
"dev:electron:wsl": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron . --no-sandbox --disable-gpu\"",
"dev:electron:wsl:gpu": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA electron . --no-sandbox --disable-gpu-sandbox\""
},
"dependencies": {
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.3",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -46,6 +50,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
@@ -122,7 +127,9 @@
{
"from": "../../.env",
"to": ".env",
"filter": ["**/*"]
"filter": [
"**/*"
]
}
],
"mac": {

View File

@@ -30,6 +30,10 @@ export default defineConfig({
url: `http://localhost:${port}`,
reuseExistingServer: !process.env.CI,
timeout: 120000,
env: {
...process.env,
NEXT_PUBLIC_SKIP_SETUP: "true",
},
},
}),
});

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Setup script for E2E test fixtures
* Creates the necessary test fixture directories and files before running Playwright tests
*/
import * as fs from "fs";
import * as path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Resolve workspace root (apps/app/scripts -> workspace root)
const WORKSPACE_ROOT = path.resolve(__dirname, "../../..");
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA");
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, ".automaker/app_spec.txt");
const SPEC_CONTENT = `<app_spec>
<name>Test Project A</name>
<description>A test fixture project for Playwright testing</description>
<tech_stack>
<item>TypeScript</item>
<item>React</item>
</tech_stack>
</app_spec>
`;
function setupFixtures() {
console.log("Setting up E2E test fixtures...");
console.log(`Workspace root: ${WORKSPACE_ROOT}`);
console.log(`Fixture path: ${FIXTURE_PATH}`);
// Create fixture directory
const specDir = path.dirname(SPEC_FILE_PATH);
if (!fs.existsSync(specDir)) {
fs.mkdirSync(specDir, { recursive: true });
console.log(`Created directory: ${specDir}`);
}
// Create app_spec.txt
fs.writeFileSync(SPEC_FILE_PATH, SPEC_CONTENT);
console.log(`Created fixture file: ${SPEC_FILE_PATH}`);
console.log("E2E test fixtures setup complete!");
}
setupFixtures();

View File

@@ -222,12 +222,6 @@ function HomeContent() {
return (
<main className="h-screen overflow-hidden" data-testid="app-container">
<SetupView />
{/* Environment indicator */}
{isMounted && !isElectron() && (
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-blue-500/10 text-blue-500 text-xs rounded-full border border-blue-500/20 pointer-events-none">
Web Mode
</div>
)}
</main>
);
}
@@ -242,13 +236,6 @@ function HomeContent() {
{renderView()}
</div>
{/* Environment indicator - only show after mount to prevent hydration issues */}
{isMounted && !isElectron() && (
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-blue-500/10 text-blue-500 text-xs rounded-full border border-blue-500/20 pointer-events-none">
Web Mode
</div>
)}
{/* Hidden streamer panel - opens with "\" key, pushes content */}
<div
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 ${

View File

@@ -1,7 +1,15 @@
"use client";
import { useState, useEffect } from "react";
import { FolderOpen, Folder, ChevronRight, Home, ArrowLeft, HardDrive } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import {
FolderOpen,
Folder,
ChevronRight,
Home,
ArrowLeft,
HardDrive,
CornerDownLeft,
} from "lucide-react";
import {
Dialog,
DialogContent,
@@ -11,6 +19,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface DirectoryEntry {
name: string;
@@ -39,14 +48,16 @@ export function FileBrowserDialog({
onOpenChange,
onSelect,
title = "Select Project Directory",
description = "Navigate to your project folder",
description = "Navigate to your project folder or paste a path directly",
}: FileBrowserDialogProps) {
const [currentPath, setCurrentPath] = useState<string>("");
const [pathInput, setPathInput] = useState<string>("");
const [parentPath, setParentPath] = useState<string | null>(null);
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
const [drives, setDrives] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const pathInputRef = useRef<HTMLInputElement>(null);
const browseDirectory = async (dirPath?: string) => {
setLoading(true);
@@ -54,7 +65,8 @@ export function FileBrowserDialog({
try {
// Get server URL from environment or default
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const serverUrl =
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const response = await fetch(`${serverUrl}/api/fs/browse`, {
method: "POST",
@@ -66,6 +78,7 @@ export function FileBrowserDialog({
if (result.success) {
setCurrentPath(result.currentPath);
setPathInput(result.currentPath);
setParentPath(result.parentPath);
setDirectories(result.directories);
setDrives(result.drives || []);
@@ -73,7 +86,9 @@ export function FileBrowserDialog({
setError(result.error || "Failed to browse directory");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load directories");
setError(
err instanceof Error ? err.message : "Failed to load directories"
);
} finally {
setLoading(false);
}
@@ -104,6 +119,20 @@ export function FileBrowserDialog({
browseDirectory(drivePath);
};
const handleGoToPath = () => {
const trimmedPath = pathInput.trim();
if (trimmedPath) {
browseDirectory(trimmedPath);
}
};
const handlePathInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleGoToPath();
}
};
const handleSelect = () => {
if (currentPath) {
onSelect(currentPath);
@@ -125,6 +154,31 @@ export function FileBrowserDialog({
</DialogHeader>
<div className="flex flex-col gap-3 min-h-[400px] flex-1 overflow-hidden py-2">
{/* Direct path input */}
<div className="flex items-center gap-2">
<Input
ref={pathInputRef}
type="text"
placeholder="Paste or type a full path (e.g., /home/user/projects/myapp)"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathInputKeyDown}
className="flex-1 font-mono text-sm"
data-testid="path-input"
disabled={loading}
/>
<Button
variant="secondary"
size="sm"
onClick={handleGoToPath}
disabled={loading || !pathInput.trim()}
data-testid="go-to-path-button"
>
<CornerDownLeft className="w-4 h-4 mr-1" />
Go
</Button>
</div>
{/* Drives selector (Windows only) */}
{drives.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
@@ -135,7 +189,9 @@ export function FileBrowserDialog({
{drives.map((drive) => (
<Button
key={drive}
variant={currentPath.startsWith(drive) ? "default" : "outline"}
variant={
currentPath.startsWith(drive) ? "default" : "outline"
}
size="sm"
onClick={() => handleSelectDrive(drive)}
className="h-7 px-3 text-xs"
@@ -178,7 +234,9 @@ export function FileBrowserDialog({
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-lg">
{loading && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-muted-foreground">Loading directories...</div>
<div className="text-sm text-muted-foreground">
Loading directories...
</div>
</div>
)}
@@ -190,7 +248,9 @@ export function FileBrowserDialog({
{!loading && !error && directories.length === 0 && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-muted-foreground">No subdirectories found</div>
<div className="text-sm text-muted-foreground">
No subdirectories found
</div>
</div>
)}
@@ -212,7 +272,8 @@ export function FileBrowserDialog({
</div>
<div className="text-xs text-muted-foreground">
Click on a folder to navigate. Select the current folder or navigate to a subfolder.
Paste a full path above, or click on folders to navigate. Press
Enter or click Go to jump to a path.
</div>
</div>

View File

@@ -207,6 +207,16 @@ export function Sidebar() {
moveProjectToTrash,
} = useAppStore();
// Environment variable flags for hiding sidebar items
// Note: Next.js requires static access to process.env variables (no dynamic keys)
const hideTerminal = process.env.NEXT_PUBLIC_HIDE_TERMINAL === "true";
const hideWiki = process.env.NEXT_PUBLIC_HIDE_WIKI === "true";
const hideRunningAgents =
process.env.NEXT_PUBLIC_HIDE_RUNNING_AGENTS === "true";
const hideContext = process.env.NEXT_PUBLIC_HIDE_CONTEXT === "true";
const hideSpecEditor = process.env.NEXT_PUBLIC_HIDE_SPEC_EDITOR === "true";
const hideAiProfiles = process.env.NEXT_PUBLIC_HIDE_AI_PROFILES === "true";
// Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
@@ -590,54 +600,75 @@ export function Sidebar() {
}
}, [emptyTrash, trashedProjects.length]);
const navSections: NavSection[] = [
{
label: "Project",
items: [
{
id: "board",
label: "Kanban Board",
icon: LayoutGrid,
shortcut: shortcuts.board,
},
{
id: "agent",
label: "Agent Runner",
icon: Bot,
shortcut: shortcuts.agent,
},
],
},
{
label: "Tools",
items: [
{
id: "spec",
label: "Spec Editor",
icon: FileText,
shortcut: shortcuts.spec,
},
{
id: "context",
label: "Context",
icon: BookOpen,
shortcut: shortcuts.context,
},
{
id: "profiles",
label: "AI Profiles",
icon: UserCircle,
shortcut: shortcuts.profiles,
},
{
id: "terminal",
label: "Terminal",
icon: Terminal,
shortcut: shortcuts.terminal,
},
],
},
];
const navSections: NavSection[] = useMemo(() => {
const allToolsItems: NavItem[] = [
{
id: "spec",
label: "Spec Editor",
icon: FileText,
shortcut: shortcuts.spec,
},
{
id: "context",
label: "Context",
icon: BookOpen,
shortcut: shortcuts.context,
},
{
id: "profiles",
label: "AI Profiles",
icon: UserCircle,
shortcut: shortcuts.profiles,
},
{
id: "terminal",
label: "Terminal",
icon: Terminal,
shortcut: shortcuts.terminal,
},
];
// Filter out hidden items
const visibleToolsItems = allToolsItems.filter((item) => {
if (item.id === "spec" && hideSpecEditor) {
return false;
}
if (item.id === "context" && hideContext) {
return false;
}
if (item.id === "profiles" && hideAiProfiles) {
return false;
}
if (item.id === "terminal" && hideTerminal) {
return false;
}
return true;
});
return [
{
label: "Project",
items: [
{
id: "board",
label: "Kanban Board",
icon: LayoutGrid,
shortcut: shortcuts.board,
},
{
id: "agent",
label: "Agent Runner",
icon: Bot,
shortcut: shortcuts.agent,
},
],
},
{
label: "Tools",
items: visibleToolsItems,
},
];
}, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles]);
// Handle selecting the currently highlighted project
const selectHighlightedProject = useCallback(() => {
@@ -1268,108 +1299,112 @@ export function Sidebar() {
{/* Course Promo Badge */}
<CoursePromoBadge sidebarOpen={sidebarOpen} />
{/* Wiki Link */}
<div className="p-2 pb-0">
<button
onClick={() => setCurrentView("wiki")}
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("wiki")
? "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 ? "Wiki" : undefined}
data-testid="wiki-link"
>
{isActiveRoute("wiki") && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
)}
<BookOpen
{!hideWiki && (
<div className="p-2 pb-0">
<button
onClick={() => setCurrentView("wiki")}
className={cn(
"w-4 h-4 shrink-0 transition-colors",
"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("wiki")
? "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"
? "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 ? "Wiki" : undefined}
data-testid="wiki-link"
>
Wiki
</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">
Wiki
</span>
)}
</button>
</div>
{/* 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>
)}
<div className="relative">
<Activity
{isActiveRoute("wiki") && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
)}
<BookOpen
className={cn(
"w-4 h-4 shrink-0 transition-colors",
isActiveRoute("running-agents")
isActiveRoute("wiki")
? "text-brand-500"
: "group-hover:text-brand-400"
)}
/>
{/* Running agents count badge - shown in collapsed state */}
{!sidebarOpen && runningAgentsCount > 0 && (
<span
className={cn(
"ml-2.5 font-medium text-sm flex-1 text-left",
sidebarOpen ? "hidden lg:block" : "hidden"
)}
>
Wiki
</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">
Wiki
</span>
)}
</button>
</div>
)}
{/* Running Agents Link */}
{!hideRunningAgents && (
<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>
)}
<div className="relative">
<Activity
className={cn(
"w-4 h-4 shrink-0 transition-colors",
isActiveRoute("running-agents")
? "text-brand-500"
: "group-hover:text-brand-400"
)}
/>
{/* Running agents count badge - shown in collapsed state */}
{!sidebarOpen && runningAgentsCount > 0 && (
<span
className="absolute -top-1.5 -right-1.5 flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-semibold rounded-full bg-brand-500 text-white"
data-testid="running-agents-count-collapsed"
>
{runningAgentsCount > 99 ? "99" : runningAgentsCount}
</span>
)}
</div>
<span
className={cn(
"ml-2.5 font-medium text-sm flex-1 text-left",
sidebarOpen ? "hidden lg:block" : "hidden"
)}
>
Running Agents
</span>
{/* Running agents count badge - shown in expanded state */}
{sidebarOpen && runningAgentsCount > 0 && (
<span
className="absolute -top-1.5 -right-1.5 flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-semibold rounded-full bg-brand-500 text-white"
data-testid="running-agents-count-collapsed"
className={cn(
"hidden lg:flex items-center justify-center min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full bg-brand-500 text-white",
isActiveRoute("running-agents") && "bg-brand-600"
)}
data-testid="running-agents-count"
>
{runningAgentsCount > 99 ? "99" : runningAgentsCount}
</span>
)}
</div>
<span
className={cn(
"ml-2.5 font-medium text-sm flex-1 text-left",
sidebarOpen ? "hidden lg:block" : "hidden"
{!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>
)}
>
Running Agents
</span>
{/* Running agents count badge - shown in expanded state */}
{sidebarOpen && runningAgentsCount > 0 && (
<span
className={cn(
"hidden lg:flex items-center justify-center min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full bg-brand-500 text-white",
isActiveRoute("running-agents") && "bg-brand-600"
)}
data-testid="running-agents-count"
>
{runningAgentsCount > 99 ? "99" : runningAgentsCount}
</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>
</button>
</div>
)}
{/* Settings Link */}
<div className="p-2">
<button

View File

@@ -41,7 +41,10 @@ interface ValidationErrors {
interface NewProjectModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreateBlankProject: (projectName: string, parentDir: string) => Promise<void>;
onCreateBlankProject: (
projectName: string,
parentDir: string
) => Promise<void>;
onCreateFromTemplate: (
template: StarterTemplate,
projectName: string,
@@ -67,7 +70,8 @@ export function NewProjectModal({
const [projectName, setProjectName] = useState("");
const [workspaceDir, setWorkspaceDir] = useState<string>("");
const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<StarterTemplate | null>(null);
const [selectedTemplate, setSelectedTemplate] =
useState<StarterTemplate | null>(null);
const [useCustomUrl, setUseCustomUrl] = useState(false);
const [customUrl, setCustomUrl] = useState("");
const [errors, setErrors] = useState<ValidationErrors>({});
@@ -78,7 +82,8 @@ export function NewProjectModal({
if (open) {
setIsLoadingWorkspace(true);
const httpClient = getHttpApiClient();
httpClient.workspace.getConfig()
httpClient.workspace
.getConfig()
.then((result) => {
if (result.success && result.workspaceDir) {
setWorkspaceDir(result.workspaceDir);
@@ -113,7 +118,10 @@ export function NewProjectModal({
}, [projectName, errors.projectName]);
useEffect(() => {
if ((selectedTemplate || (useCustomUrl && customUrl)) && errors.templateSelection) {
if (
(selectedTemplate || (useCustomUrl && customUrl)) &&
errors.templateSelection
) {
setErrors((prev) => ({ ...prev, templateSelection: false }));
}
}, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]);
@@ -187,7 +195,8 @@ export function NewProjectModal({
const handleBrowseDirectory = async () => {
const selectedPath = await openFileBrowser({
title: "Select Base Project Directory",
description: "Choose the parent directory where your project will be created",
description:
"Choose the parent directory where your project will be created",
});
if (selectedPath) {
setWorkspaceDir(selectedPath);
@@ -199,9 +208,16 @@ export function NewProjectModal({
};
// Use platform-specific path separator
const pathSep = typeof window !== 'undefined' && (window as any).electronAPI ?
(navigator.platform.indexOf('Win') !== -1 ? '\\' : '/') : '/';
const projectPath = workspaceDir && projectName ? `${workspaceDir}${pathSep}${projectName}` : "";
const pathSep =
typeof window !== "undefined" && (window as any).electronAPI
? navigator.platform.indexOf("Win") !== -1
? "\\"
: "/"
: "/";
const projectPath =
workspaceDir && projectName
? `${workspaceDir}${pathSep}${projectName}`
: "";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -210,7 +226,9 @@ export function NewProjectModal({
data-testid="new-project-modal"
>
<DialogHeader className="pb-2">
<DialogTitle className="text-foreground">Create New Project</DialogTitle>
<DialogTitle className="text-foreground">
Create New Project
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Start with a blank project or choose from a starter template.
</DialogDescription>
@@ -219,8 +237,15 @@ export function NewProjectModal({
{/* Project Name Input - Always visible at top */}
<div className="space-y-3 pb-4 border-b border-border">
<div className="space-y-2">
<Label htmlFor="project-name" className={cn("text-foreground", errors.projectName && "text-red-500")}>
Project Name {errors.projectName && <span className="text-red-500">*</span>}
<Label
htmlFor="project-name"
className={cn(
"text-foreground",
errors.projectName && "text-red-500"
)}
>
Project Name{" "}
{errors.projectName && <span className="text-red-500">*</span>}
</Label>
<Input
id="project-name"
@@ -242,16 +267,23 @@ export function NewProjectModal({
</div>
{/* Workspace Directory Display */}
<div className={cn(
"flex items-center gap-2 text-sm",
errors.workspaceDir ? "text-red-500" : "text-muted-foreground"
)}>
<div
className={cn(
"flex items-center gap-2 text-sm",
errors.workspaceDir ? "text-red-500" : "text-muted-foreground"
)}
>
<Folder className="w-4 h-4 shrink-0" />
<span className="flex-1 min-w-0">
{isLoadingWorkspace ? (
"Loading workspace..."
) : workspaceDir ? (
<>Will be created at: <code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">{projectPath || "..."}</code></>
<>
Will be created at:{" "}
<code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">
{projectPath || "..."}
</code>
</>
) : (
<span className="text-red-500">No workspace configured</span>
)}
@@ -302,14 +334,18 @@ export function NewProjectModal({
<div className="space-y-4">
{/* Error message for template selection */}
{errors.templateSelection && (
<p className="text-sm text-red-500">Please select a template or enter a custom GitHub URL</p>
<p className="text-sm text-red-500">
Please select a template or enter a custom GitHub URL
</p>
)}
{/* Preset Templates */}
<div className={cn(
"space-y-3 rounded-lg p-1 -m-1",
errors.templateSelection && "ring-2 ring-red-500/50"
)}>
<div
className={cn(
"space-y-3 rounded-lg p-1 -m-1",
errors.templateSelection && "ring-2 ring-red-500/50"
)}
>
{starterTemplates.map((template) => (
<div
key={template.id}
@@ -328,9 +364,10 @@ export function NewProjectModal({
<h4 className="font-medium text-foreground">
{template.name}
</h4>
{selectedTemplate?.id === template.id && !useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" />
)}
{selectedTemplate?.id === template.id &&
!useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" />
)}
</div>
<p className="text-sm text-muted-foreground mb-3">
{template.description}
@@ -391,15 +428,22 @@ export function NewProjectModal({
>
<div className="flex items-center gap-2 mb-2">
<Link className="w-4 h-4 text-muted-foreground" />
<h4 className="font-medium text-foreground">Custom GitHub URL</h4>
{useCustomUrl && <Check className="w-4 h-4 text-brand-500" />}
<h4 className="font-medium text-foreground">
Custom GitHub URL
</h4>
{useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" />
)}
</div>
<p className="text-sm text-muted-foreground mb-3">
Clone any public GitHub repository as a starting point.
</p>
{useCustomUrl && (
<div onClick={(e) => e.stopPropagation()} className="space-y-1">
<div
onClick={(e) => e.stopPropagation()}
className="space-y-1"
>
<Input
placeholder="https://github.com/username/repository"
value={customUrl}
@@ -413,7 +457,9 @@ export function NewProjectModal({
data-testid="custom-url-input"
/>
{errors.customUrl && (
<p className="text-xs text-red-500">GitHub URL is required</p>
<p className="text-xs text-red-500">
GitHub URL is required
</p>
)}
</div>
)}

View File

@@ -1,6 +1,11 @@
"use client";
import { useRef, useCallback, useMemo } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { xml } from "@codemirror/lang-xml";
import { EditorView } from "@codemirror/view";
import { Extension } from "@codemirror/state";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags as t } from "@lezer/highlight";
import { cn } from "@/lib/utils";
interface XmlSyntaxEditorProps {
@@ -11,199 +16,78 @@ interface XmlSyntaxEditorProps {
"data-testid"?: string;
}
// Tokenize XML content into parts for highlighting
interface Token {
type:
| "tag-bracket"
| "tag-name"
| "attribute-name"
| "attribute-equals"
| "attribute-value"
| "text"
| "comment"
| "cdata"
| "doctype";
value: string;
}
// Syntax highlighting that uses CSS variables from the app's theme system
// This automatically adapts to any theme (dark, light, dracula, nord, etc.)
const syntaxColors = HighlightStyle.define([
// XML tags - use primary color
{ tag: t.tagName, color: "var(--primary)" },
{ tag: t.angleBracket, color: "var(--muted-foreground)" },
function tokenizeXml(text: string): Token[] {
const tokens: Token[] = [];
let i = 0;
// Attributes
{ tag: t.attributeName, color: "var(--chart-2, oklch(0.6 0.118 184.704))" },
{ tag: t.attributeValue, color: "var(--chart-1, oklch(0.646 0.222 41.116))" },
while (i < text.length) {
// Comment: <!-- ... -->
if (text.slice(i, i + 4) === "<!--") {
const end = text.indexOf("-->", i + 4);
if (end !== -1) {
tokens.push({ type: "comment", value: text.slice(i, end + 3) });
i = end + 3;
continue;
}
}
// Strings and content
{ tag: t.string, color: "var(--chart-1, oklch(0.646 0.222 41.116))" },
{ tag: t.content, color: "var(--foreground)" },
// CDATA: <![CDATA[ ... ]]>
if (text.slice(i, i + 9) === "<![CDATA[") {
const end = text.indexOf("]]>", i + 9);
if (end !== -1) {
tokens.push({ type: "cdata", value: text.slice(i, end + 3) });
i = end + 3;
continue;
}
}
// Comments
{ tag: t.comment, color: "var(--muted-foreground)", fontStyle: "italic" },
// DOCTYPE: <!DOCTYPE ... >
if (text.slice(i, i + 9).toUpperCase() === "<!DOCTYPE") {
const end = text.indexOf(">", i + 9);
if (end !== -1) {
tokens.push({ type: "doctype", value: text.slice(i, end + 1) });
i = end + 1;
continue;
}
}
// Special
{ tag: t.processingInstruction, color: "var(--muted-foreground)" },
{ tag: t.documentMeta, color: "var(--muted-foreground)" },
]);
// Tag: < ... >
if (text[i] === "<") {
// Find the end of the tag
let tagEnd = i + 1;
let inString: string | null = null;
// Editor theme using CSS variables
const editorTheme = EditorView.theme({
"&": {
height: "100%",
fontSize: "0.875rem",
fontFamily: "ui-monospace, monospace",
backgroundColor: "transparent",
color: "var(--foreground)",
},
".cm-scroller": {
overflow: "auto",
fontFamily: "ui-monospace, monospace",
},
".cm-content": {
padding: "1rem",
minHeight: "100%",
caretColor: "var(--primary)",
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "var(--primary)",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
{
backgroundColor: "oklch(0.55 0.25 265 / 0.3)",
},
".cm-activeLine": {
backgroundColor: "transparent",
},
".cm-line": {
padding: "0",
},
"&.cm-focused": {
outline: "none",
},
".cm-gutters": {
display: "none",
},
".cm-placeholder": {
color: "var(--muted-foreground)",
fontStyle: "italic",
},
});
while (tagEnd < text.length) {
const char = text[tagEnd];
if (inString) {
if (char === inString && text[tagEnd - 1] !== "\\") {
inString = null;
}
} else {
if (char === '"' || char === "'") {
inString = char;
} else if (char === ">") {
tagEnd++;
break;
}
}
tagEnd++;
}
const tagContent = text.slice(i, tagEnd);
const tagTokens = tokenizeTag(tagContent);
tokens.push(...tagTokens);
i = tagEnd;
continue;
}
// Text content between tags
const nextTag = text.indexOf("<", i);
if (nextTag === -1) {
tokens.push({ type: "text", value: text.slice(i) });
break;
} else if (nextTag > i) {
tokens.push({ type: "text", value: text.slice(i, nextTag) });
i = nextTag;
}
}
return tokens;
}
function tokenizeTag(tag: string): Token[] {
const tokens: Token[] = [];
let i = 0;
// Opening bracket (< or </ or <?)
if (tag.startsWith("</")) {
tokens.push({ type: "tag-bracket", value: "</" });
i = 2;
} else if (tag.startsWith("<?")) {
tokens.push({ type: "tag-bracket", value: "<?" });
i = 2;
} else {
tokens.push({ type: "tag-bracket", value: "<" });
i = 1;
}
// Skip whitespace
while (i < tag.length && /\s/.test(tag[i])) {
tokens.push({ type: "text", value: tag[i] });
i++;
}
// Tag name
let tagName = "";
while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) {
tagName += tag[i];
i++;
}
if (tagName) {
tokens.push({ type: "tag-name", value: tagName });
}
// Attributes and closing
while (i < tag.length) {
// Skip whitespace
if (/\s/.test(tag[i])) {
let ws = "";
while (i < tag.length && /\s/.test(tag[i])) {
ws += tag[i];
i++;
}
tokens.push({ type: "text", value: ws });
continue;
}
// Closing bracket
if (tag[i] === ">" || tag.slice(i, i + 2) === "/>" || tag.slice(i, i + 2) === "?>") {
tokens.push({ type: "tag-bracket", value: tag.slice(i) });
break;
}
// Attribute name
let attrName = "";
while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) {
attrName += tag[i];
i++;
}
if (attrName) {
tokens.push({ type: "attribute-name", value: attrName });
}
// Skip whitespace around =
while (i < tag.length && /\s/.test(tag[i])) {
tokens.push({ type: "text", value: tag[i] });
i++;
}
// Equals sign
if (tag[i] === "=") {
tokens.push({ type: "attribute-equals", value: "=" });
i++;
}
// Skip whitespace after =
while (i < tag.length && /\s/.test(tag[i])) {
tokens.push({ type: "text", value: tag[i] });
i++;
}
// Attribute value
if (tag[i] === '"' || tag[i] === "'") {
const quote = tag[i];
let value = quote;
i++;
while (i < tag.length && tag[i] !== quote) {
value += tag[i];
i++;
}
if (i < tag.length) {
value += tag[i];
i++;
}
tokens.push({ type: "attribute-value", value });
}
}
return tokens;
}
// Combine all extensions
const extensions: Extension[] = [
xml(),
syntaxHighlighting(syntaxColors),
editorTheme,
];
export function XmlSyntaxEditor({
value,
@@ -212,78 +96,24 @@ export function XmlSyntaxEditor({
className,
"data-testid": testId,
}: XmlSyntaxEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
// Sync scroll between textarea and highlight layer
const handleScroll = useCallback(() => {
if (textareaRef.current && highlightRef.current) {
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
}
}, []);
// Handle tab key for indentation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Tab") {
e.preventDefault();
const textarea = e.currentTarget;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newValue =
value.substring(0, start) + " " + value.substring(end);
onChange(newValue);
// Reset cursor position after state update
requestAnimationFrame(() => {
textarea.selectionStart = textarea.selectionEnd = start + 2;
});
}
},
[value, onChange]
);
// Memoize the highlighted content
const highlightedContent = useMemo(() => {
const tokens = tokenizeXml(value);
return tokens.map((token, index) => {
const className = `xml-${token.type}`;
// React handles escaping automatically, just render the raw value
return (
<span key={index} className={className}>
{token.value}
</span>
);
});
}, [value]);
return (
<div className={cn("relative w-full h-full xml-editor", className)}>
{/* Syntax highlighted layer (read-only, behind textarea) */}
<div
ref={highlightRef}
className="absolute inset-0 overflow-auto pointer-events-none font-mono text-sm p-4 whitespace-pre-wrap break-words"
aria-hidden="true"
>
{value ? (
<code className="xml-highlight">{highlightedContent}</code>
) : (
<span className="text-muted-foreground opacity-50">{placeholder}</span>
)}
</div>
{/* Actual textarea (transparent text, handles input) */}
<textarea
ref={textareaRef}
<div className={cn("w-full h-full", className)} data-testid={testId}>
<CodeMirror
value={value}
onChange={(e) => onChange(e.target.value)}
onScroll={handleScroll}
onKeyDown={handleKeyDown}
placeholder=""
spellCheck={false}
className="absolute inset-0 w-full h-full font-mono text-sm p-4 bg-transparent resize-none focus:outline-none text-transparent caret-foreground selection:bg-primary/30"
data-testid={testId}
onChange={onChange}
extensions={extensions}
theme="none"
placeholder={placeholder}
className="h-full [&_.cm-editor]:h-full"
basicSetup={{
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightSelectionMatches: true,
autocompletion: true,
bracketMatching: true,
indentOnInput: true,
}}
/>
</div>
);

View File

@@ -14,7 +14,17 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Save, RefreshCw, FileText, Sparkles, Loader2, FilePlus2, AlertCircle, ListPlus, CheckCircle2 } from "lucide-react";
import {
Save,
RefreshCw,
FileText,
Sparkles,
Loader2,
FilePlus2,
AlertCircle,
ListPlus,
CheckCircle2,
} from "lucide-react";
import { toast } from "sonner";
import { Checkbox } from "@/components/ui/checkbox";
import { XmlSyntaxEditor } from "@/components/ui/xml-syntax-editor";
@@ -43,14 +53,14 @@ export function SpecView() {
const [projectOverview, setProjectOverview] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [generateFeatures, setGenerateFeatures] = useState(true);
// Generate features only state
const [isGeneratingFeatures, setIsGeneratingFeatures] = useState(false);
// Logs state (kept for internal tracking, but UI removed)
const [logs, setLogs] = useState<string>("");
const logsRef = useRef<string>("");
// Phase tracking and status
const [currentPhase, setCurrentPhase] = useState<string>("");
const [errorMessage, setErrorMessage] = useState<string>("");
@@ -107,28 +117,33 @@ export function SpecView() {
if (status.success && status.isRunning) {
// Something is running - restore state using backend's authoritative phase
console.log("[SpecView] Spec generation is running - restoring state", { phase: status.currentPhase });
console.log(
"[SpecView] Spec generation is running - restoring state",
{ phase: status.currentPhase }
);
if (!stateRestoredRef.current) {
setIsCreating(true);
setIsRegenerating(true);
stateRestoredRef.current = true;
}
// Use the backend's currentPhase directly - single source of truth
if (status.currentPhase) {
setCurrentPhase(status.currentPhase);
} else {
setCurrentPhase("in progress");
}
// Add resume message to logs if needed
if (!logsRef.current) {
const resumeMessage = "[Status] Resumed monitoring existing spec generation process...\n";
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";
const resumeMessage =
"\n[Status] Resumed monitoring existing spec generation process...\n";
logsRef.current = logsRef.current + resumeMessage;
setLogs(logsRef.current);
}
@@ -154,7 +169,11 @@ export function SpecView() {
// Sync state when tab becomes visible (user returns to spec editor)
useEffect(() => {
const handleVisibilityChange = async () => {
if (!document.hidden && currentProject && (isCreating || isRegenerating || isGeneratingFeatures)) {
if (
!document.hidden &&
currentProject &&
(isCreating || isRegenerating || isGeneratingFeatures)
) {
// Tab became visible and we think we're still generating - verify status from backend
try {
const api = getElectronAPI();
@@ -162,10 +181,12 @@ export function SpecView() {
const status = await api.specRegeneration.status();
console.log("[SpecView] Visibility change - status check:", status);
if (!status.isRunning) {
// Backend says not running - clear state
console.log("[SpecView] Visibility change: Backend indicates generation complete - clearing state");
console.log(
"[SpecView] Visibility change: Backend indicates generation complete - clearing state"
);
setIsCreating(false);
setIsRegenerating(false);
setIsGeneratingFeatures(false);
@@ -177,7 +198,10 @@ export function SpecView() {
setCurrentPhase(status.currentPhase);
}
} catch (error) {
console.error("[SpecView] Failed to check status on visibility change:", error);
console.error(
"[SpecView] Failed to check status on visibility change:",
error
);
}
}
};
@@ -186,11 +210,21 @@ export function SpecView() {
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [currentProject, isCreating, isRegenerating, isGeneratingFeatures, loadSpec]);
}, [
currentProject,
isCreating,
isRegenerating,
isGeneratingFeatures,
loadSpec,
]);
// Periodic status check to ensure state stays in sync (only when we think we're running)
useEffect(() => {
if (!currentProject || (!isCreating && !isRegenerating && !isGeneratingFeatures)) return;
if (
!currentProject ||
(!isCreating && !isRegenerating && !isGeneratingFeatures)
)
return;
const intervalId = setInterval(async () => {
try {
@@ -198,21 +232,26 @@ export function SpecView() {
if (!api.specRegeneration) return;
const status = await api.specRegeneration.status();
if (!status.isRunning) {
// Backend says not running - clear state
console.log("[SpecView] Periodic check: Backend indicates generation complete - clearing state");
console.log(
"[SpecView] Periodic check: Backend indicates generation complete - clearing state"
);
setIsCreating(false);
setIsRegenerating(false);
setIsGeneratingFeatures(false);
setCurrentPhase("");
stateRestoredRef.current = false;
loadSpec();
} else if (status.currentPhase && status.currentPhase !== currentPhase) {
} else if (
status.currentPhase &&
status.currentPhase !== currentPhase
) {
// Still running but phase changed - update from backend
console.log("[SpecView] Periodic check: Phase updated from backend", {
old: currentPhase,
new: status.currentPhase
console.log("[SpecView] Periodic check: Phase updated from backend", {
old: currentPhase,
new: status.currentPhase,
});
setCurrentPhase(status.currentPhase);
}
@@ -224,173 +263,203 @@ export function SpecView() {
return () => {
clearInterval(intervalId);
};
}, [currentProject, isCreating, isRegenerating, isGeneratingFeatures, currentPhase, loadSpec]);
}, [
currentProject,
isCreating,
isRegenerating,
isGeneratingFeatures,
currentPhase,
loadSpec,
]);
// Subscribe to spec regeneration events
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => {
console.log("[SpecView] Regeneration event:", event.type);
const unsubscribe = api.specRegeneration.onEvent(
(event: SpecRegenerationEvent) => {
console.log("[SpecView] Regeneration event:", event.type);
if (event.type === "spec_regeneration_progress") {
// Extract phase from content if present
const phaseMatch = event.content.match(/\[Phase:\s*([^\]]+)\]/);
if (phaseMatch) {
const phase = phaseMatch[1];
setCurrentPhase(phase);
console.log(`[SpecView] Phase updated: ${phase}`);
// If phase is "complete", clear running state immediately
if (phase === "complete") {
console.log("[SpecView] Phase is complete - clearing state");
if (event.type === "spec_regeneration_progress") {
// Extract phase from content if present
const phaseMatch = event.content.match(/\[Phase:\s*([^\]]+)\]/);
if (phaseMatch) {
const phase = phaseMatch[1];
setCurrentPhase(phase);
console.log(`[SpecView] Phase updated: ${phase}`);
// If phase is "complete", clear running state immediately
if (phase === "complete") {
console.log("[SpecView] Phase is complete - clearing state");
setIsCreating(false);
setIsRegenerating(false);
stateRestoredRef.current = false;
// Small delay to ensure spec file is written
setTimeout(() => {
loadSpec();
}, SPEC_FILE_WRITE_DELAY);
}
}
// Check for completion indicators in content
if (
event.content.includes("All tasks completed") ||
event.content.includes("✓ All tasks completed")
) {
// This indicates everything is done - clear state immediately
console.log(
"[SpecView] Detected completion in progress message - clearing state"
);
setIsCreating(false);
setIsRegenerating(false);
setCurrentPhase("");
stateRestoredRef.current = false;
// Small delay to ensure spec file is written
setTimeout(() => {
loadSpec();
}, SPEC_FILE_WRITE_DELAY);
}
}
// Check for completion indicators in content
if (event.content.includes("All tasks completed") ||
event.content.includes("✓ All tasks completed")) {
// This indicates everything is done - clear state immediately
console.log("[SpecView] Detected completion in progress message - clearing state");
setIsCreating(false);
setIsRegenerating(false);
setCurrentPhase("");
stateRestoredRef.current = false;
setTimeout(() => {
loadSpec();
}, SPEC_FILE_WRITE_DELAY);
}
// Append progress to logs
const newLog = logsRef.current + event.content;
logsRef.current = newLog;
setLogs(newLog);
console.log("[SpecView] Progress:", event.content.substring(0, 100));
// Append progress to logs
const newLog = logsRef.current + event.content;
logsRef.current = newLog;
setLogs(newLog);
console.log("[SpecView] Progress:", event.content.substring(0, 100));
// Clear error message when we get new progress
if (errorMessage) {
setErrorMessage("");
}
} else if (event.type === "spec_regeneration_tool") {
// Check if this is a feature creation tool
const isFeatureTool = event.tool === "mcp__automaker-tools__UpdateFeatureStatus" ||
event.tool === "UpdateFeatureStatus" ||
event.tool?.includes("Feature");
if (isFeatureTool) {
// Ensure we're in feature generation phase
if (currentPhase !== "feature_generation") {
setCurrentPhase("feature_generation");
// Clear error message when we get new progress
if (errorMessage) {
setErrorMessage("");
}
} else if (event.type === "spec_regeneration_tool") {
// Check if this is a feature creation tool
const isFeatureTool =
event.tool === "mcp__automaker-tools__UpdateFeatureStatus" ||
event.tool === "UpdateFeatureStatus" ||
event.tool?.includes("Feature");
if (isFeatureTool) {
// Ensure we're in feature generation phase
if (currentPhase !== "feature_generation") {
setCurrentPhase("feature_generation");
setIsCreating(true);
setIsRegenerating(true);
console.log(
"[SpecView] Detected feature creation tool - setting phase to feature_generation"
);
}
}
// Log tool usage with details
const toolInput = event.input
? ` (${JSON.stringify(event.input).substring(0, 100)}...)`
: "";
const toolLog = `\n[Tool] ${event.tool}${toolInput}\n`;
const newLog = logsRef.current + toolLog;
logsRef.current = newLog;
setLogs(newLog);
console.log("[SpecView] Tool:", event.tool, event.input);
} else if (event.type === "spec_regeneration_complete") {
// Add completion message to logs first
const completionLog =
logsRef.current + `\n[Complete] ${event.message}\n`;
logsRef.current = completionLog;
setLogs(completionLog);
// --- Completion Detection Logic ---
// The backend sends explicit signals for completion:
// 1. "All tasks completed" in the message
// 2. [Phase: complete] marker in logs
// 3. "Spec regeneration complete!" for regeneration
// 4. "Initial spec creation complete!" for creation without features
const isFinalCompletionMessage =
event.message?.includes("All tasks completed") ||
event.message === "All tasks completed!" ||
event.message === "All tasks completed" ||
event.message === "Spec regeneration complete!" ||
event.message === "Initial spec creation complete!";
const hasCompletePhase =
logsRef.current.includes("[Phase: complete]");
// Intermediate completion means features are being generated after spec creation
const isIntermediateCompletion =
event.message?.includes("Features are being generated") ||
event.message?.includes("features are being generated");
// Rely solely on explicit backend signals
const shouldComplete =
(isFinalCompletionMessage || hasCompletePhase) &&
!isIntermediateCompletion;
if (shouldComplete) {
// Fully complete - clear all states immediately
console.log(
"[SpecView] Final completion detected - clearing state",
{
isFinalCompletionMessage,
hasCompletePhase,
message: event.message,
}
);
setIsRegenerating(false);
setIsCreating(false);
setIsGeneratingFeatures(false);
setCurrentPhase("");
setShowRegenerateDialog(false);
setShowCreateDialog(false);
setProjectDefinition("");
setProjectOverview("");
setErrorMessage("");
stateRestoredRef.current = false;
// Reload the spec with delay to ensure file is written to disk
setTimeout(() => {
loadSpec();
}, SPEC_FILE_WRITE_DELAY);
// Show success toast notification
const isRegeneration = event.message?.includes("regeneration");
const isFeatureGeneration =
event.message?.includes("Feature generation");
toast.success(
isFeatureGeneration
? "Feature Generation Complete"
: isRegeneration
? "Spec Regeneration Complete"
: "Spec Creation Complete",
{
description: isFeatureGeneration
? "Features have been created from the app specification."
: "Your app specification has been saved.",
icon: <CheckCircle2 className="w-4 h-4" />,
}
);
} else if (isIntermediateCompletion) {
// Intermediate completion - keep state active for feature generation
setIsCreating(true);
setIsRegenerating(true);
console.log("[SpecView] Detected feature creation tool - setting phase to feature_generation");
setCurrentPhase("feature_generation");
console.log(
"[SpecView] Intermediate completion, continuing with feature generation"
);
}
}
// Log tool usage with details
const toolInput = event.input ? ` (${JSON.stringify(event.input).substring(0, 100)}...)` : "";
const toolLog = `\n[Tool] ${event.tool}${toolInput}\n`;
const newLog = logsRef.current + toolLog;
logsRef.current = newLog;
setLogs(newLog);
console.log("[SpecView] Tool:", event.tool, event.input);
} else if (event.type === "spec_regeneration_complete") {
// Add completion message to logs first
const completionLog = logsRef.current + `\n[Complete] ${event.message}\n`;
logsRef.current = completionLog;
setLogs(completionLog);
// --- Completion Detection Logic ---
// The backend sends explicit signals for completion:
// 1. "All tasks completed" in the message
// 2. [Phase: complete] marker in logs
// 3. "Spec regeneration complete!" for regeneration
// 4. "Initial spec creation complete!" for creation without features
const isFinalCompletionMessage = event.message?.includes("All tasks completed") ||
event.message === "All tasks completed!" ||
event.message === "All tasks completed" ||
event.message === "Spec regeneration complete!" ||
event.message === "Initial spec creation complete!";
const hasCompletePhase = logsRef.current.includes("[Phase: complete]");
// Intermediate completion means features are being generated after spec creation
const isIntermediateCompletion = event.message?.includes("Features are being generated") ||
event.message?.includes("features are being generated");
// Rely solely on explicit backend signals
const shouldComplete = (isFinalCompletionMessage || hasCompletePhase) && !isIntermediateCompletion;
if (shouldComplete) {
// Fully complete - clear all states immediately
console.log("[SpecView] Final completion detected - clearing state", {
isFinalCompletionMessage,
hasCompletePhase,
message: event.message
});
console.log("[SpecView] Spec generation event:", event.message);
} else if (event.type === "spec_regeneration_error") {
setIsRegenerating(false);
setIsCreating(false);
setIsGeneratingFeatures(false);
setCurrentPhase("");
setShowRegenerateDialog(false);
setShowCreateDialog(false);
setProjectDefinition("");
setProjectOverview("");
setErrorMessage("");
stateRestoredRef.current = false;
// Reload the spec with delay to ensure file is written to disk
setTimeout(() => {
loadSpec();
}, SPEC_FILE_WRITE_DELAY);
// Show success toast notification
const isRegeneration = event.message?.includes("regeneration");
const isFeatureGeneration = event.message?.includes("Feature generation");
toast.success(
isFeatureGeneration
? "Feature Generation Complete"
: isRegeneration
? "Spec Regeneration Complete"
: "Spec Creation Complete",
{
description: isFeatureGeneration
? "Features have been created from the app specification."
: "Your app specification has been saved.",
icon: <CheckCircle2 className="w-4 h-4" />,
}
);
} else if (isIntermediateCompletion) {
// Intermediate completion - keep state active for feature generation
setIsCreating(true);
setIsRegenerating(true);
setCurrentPhase("feature_generation");
console.log("[SpecView] Intermediate completion, continuing with feature generation");
setCurrentPhase("error");
setErrorMessage(event.error);
stateRestoredRef.current = false; // Reset restoration flag
// Add error to logs
const errorLog = logsRef.current + `\n\n[ERROR] ${event.error}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
console.error("[SpecView] Regeneration error:", event.error);
}
console.log("[SpecView] Spec generation event:", event.message);
} else if (event.type === "spec_regeneration_error") {
setIsRegenerating(false);
setIsCreating(false);
setIsGeneratingFeatures(false);
setCurrentPhase("error");
setErrorMessage(event.error);
stateRestoredRef.current = false; // Reset restoration flag
// Add error to logs
const errorLog = logsRef.current + `\n\n[ERROR] ${event.error}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
console.error("[SpecView] Regeneration error:", event.error);
}
});
);
return () => {
unsubscribe();
@@ -476,7 +545,10 @@ export function SpecView() {
// Reset logs when starting new generation
logsRef.current = "";
setLogs("");
console.log("[SpecView] Starting spec creation, generateFeatures:", generateFeatures);
console.log(
"[SpecView] Starting spec creation, generateFeatures:",
generateFeatures
);
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
@@ -537,7 +609,10 @@ export function SpecView() {
if (!result.success) {
const errorMsg = result.error || "Unknown error";
console.error("[SpecView] Failed to start feature generation:", errorMsg);
console.error(
"[SpecView] Failed to start feature generation:",
errorMsg
);
setIsGeneratingFeatures(false);
setCurrentPhase("error");
setErrorMessage(errorMsg);
@@ -606,18 +681,31 @@ export function SpecView() {
</div>
<div className="flex flex-col gap-1 min-w-0">
<span className="text-sm font-semibold text-primary leading-tight tracking-tight">
{isCreating ? "Generating Specification" : "Regenerating Specification"}
{isCreating
? "Generating Specification"
: "Regenerating Specification"}
</span>
{currentPhase && (
<span className="text-xs text-muted-foreground/90 leading-tight font-medium">
{currentPhase === "initialization" && "Initializing..."}
{currentPhase === "setup" && "Setting up tools..."}
{currentPhase === "analysis" && "Analyzing project structure..."}
{currentPhase === "spec_complete" && "Spec created! Generating features..."}
{currentPhase === "feature_generation" && "Creating features from roadmap..."}
{currentPhase === "analysis" &&
"Analyzing project structure..."}
{currentPhase === "spec_complete" &&
"Spec created! Generating features..."}
{currentPhase === "feature_generation" &&
"Creating features from roadmap..."}
{currentPhase === "complete" && "Complete!"}
{currentPhase === "error" && "Error occurred"}
{!["initialization", "setup", "analysis", "spec_complete", "feature_generation", "complete", "error"].includes(currentPhase) && currentPhase}
{![
"initialization",
"setup",
"analysis",
"spec_complete",
"feature_generation",
"complete",
"error",
].includes(currentPhase) && currentPhase}
</span>
)}
</div>
@@ -653,12 +741,23 @@ export function SpecView() {
<span className="text-sm font-semibold text-primary text-center tracking-tight">
{currentPhase === "initialization" && "Initializing..."}
{currentPhase === "setup" && "Setting up tools..."}
{currentPhase === "analysis" && "Analyzing project structure..."}
{currentPhase === "spec_complete" && "Spec created! Generating features..."}
{currentPhase === "feature_generation" && "Creating features from roadmap..."}
{currentPhase === "analysis" &&
"Analyzing project structure..."}
{currentPhase === "spec_complete" &&
"Spec created! Generating features..."}
{currentPhase === "feature_generation" &&
"Creating features from roadmap..."}
{currentPhase === "complete" && "Complete!"}
{currentPhase === "error" && "Error occurred"}
{!["initialization", "setup", "analysis", "spec_complete", "feature_generation", "complete", "error"].includes(currentPhase) && currentPhase}
{![
"initialization",
"setup",
"analysis",
"spec_complete",
"feature_generation",
"complete",
"error",
].includes(currentPhase) && currentPhase}
</span>
</div>
)}
@@ -682,10 +781,7 @@ export function SpecView() {
)}
{!isCreating && (
<div className="flex gap-2 justify-center">
<Button
size="lg"
onClick={() => setShowCreateDialog(true)}
>
<Button size="lg" onClick={() => setShowCreateDialog(true)}>
<FilePlus2 className="w-5 h-5 mr-2" />
Create app_spec
</Button>
@@ -695,8 +791,8 @@ export function SpecView() {
</div>
{/* Create Dialog */}
<Dialog
open={showCreateDialog}
<Dialog
open={showCreateDialog}
onOpenChange={(open) => {
if (!open && !isCreating) {
setShowCreateDialog(false);
@@ -707,20 +803,20 @@ export function SpecView() {
<DialogHeader>
<DialogTitle>Create App Specification</DialogTitle>
<DialogDescription className="text-muted-foreground">
We didn&apos;t find an app_spec.txt file. Let us help you generate your app_spec.txt
to help describe your project for our system. We&apos;ll analyze your project&apos;s
tech stack and create a comprehensive specification.
We didn&apos;t find an app_spec.txt file. Let us help you
generate your app_spec.txt to help describe your project for our
system. We&apos;ll analyze your project&apos;s tech stack and
create a comprehensive specification.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Project Overview
</label>
<label className="text-sm font-medium">Project Overview</label>
<p className="text-xs text-muted-foreground">
Describe what your project does and what features you want to build.
Be as detailed as you want - this will help us create a better specification.
Describe what your project does and what features you want to
build. Be as detailed as you want - this will help us create a
better specification.
</p>
<textarea
className="w-full h-48 p-3 rounded-md border border-border bg-background font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
@@ -736,19 +832,23 @@ export function SpecView() {
<Checkbox
id="generate-features"
checked={generateFeatures}
onCheckedChange={(checked) => setGenerateFeatures(checked === true)}
onCheckedChange={(checked) =>
setGenerateFeatures(checked === true)
}
disabled={isCreating}
/>
<div className="space-y-1">
<label
htmlFor="generate-features"
className={`text-sm font-medium ${isCreating ? "" : "cursor-pointer"}`}
className={`text-sm font-medium ${
isCreating ? "" : "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 generated.
Automatically create features in the features folder from
the implementation roadmap after the spec is generated.
</p>
</div>
</div>
@@ -812,18 +912,33 @@ export function SpecView() {
</div>
<div className="flex flex-col gap-1 min-w-0">
<span className="text-sm font-semibold text-primary leading-tight tracking-tight">
{isGeneratingFeatures ? "Generating Features" : isCreating ? "Generating Specification" : "Regenerating Specification"}
{isGeneratingFeatures
? "Generating Features"
: isCreating
? "Generating Specification"
: "Regenerating Specification"}
</span>
{currentPhase && (
<span className="text-xs text-muted-foreground/90 leading-tight font-medium">
{currentPhase === "initialization" && "Initializing..."}
{currentPhase === "setup" && "Setting up tools..."}
{currentPhase === "analysis" && "Analyzing project structure..."}
{currentPhase === "spec_complete" && "Spec created! Generating features..."}
{currentPhase === "feature_generation" && "Creating features from roadmap..."}
{currentPhase === "analysis" &&
"Analyzing project structure..."}
{currentPhase === "spec_complete" &&
"Spec created! Generating features..."}
{currentPhase === "feature_generation" &&
"Creating features from roadmap..."}
{currentPhase === "complete" && "Complete!"}
{currentPhase === "error" && "Error occurred"}
{!["initialization", "setup", "analysis", "spec_complete", "feature_generation", "complete", "error"].includes(currentPhase) && currentPhase}
{![
"initialization",
"setup",
"analysis",
"spec_complete",
"feature_generation",
"complete",
"error",
].includes(currentPhase) && currentPhase}
</span>
)}
</div>
@@ -833,8 +948,12 @@ export function SpecView() {
<div className="flex items-center gap-3 px-6 py-3.5 rounded-xl bg-gradient-to-r from-destructive/15 to-destructive/5 border border-destructive/30 shadow-lg backdrop-blur-md">
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0" />
<div className="flex flex-col gap-1 min-w-0">
<span className="text-sm font-semibold text-destructive leading-tight tracking-tight">Error</span>
<span className="text-xs text-destructive/90 leading-tight font-medium">{errorMessage}</span>
<span className="text-sm font-semibold text-destructive leading-tight tracking-tight">
Error
</span>
<span className="text-xs text-destructive/90 leading-tight font-medium">
{errorMessage}
</span>
</div>
</div>
)}
@@ -856,7 +975,13 @@ export function SpecView() {
<Button
size="sm"
onClick={saveSpec}
disabled={!hasChanges || isSaving || isCreating || isRegenerating || isGeneratingFeatures}
disabled={
!hasChanges ||
isSaving ||
isCreating ||
isRegenerating ||
isGeneratingFeatures
}
data-testid="save-spec"
>
<Save className="w-4 h-4 mr-2" />
@@ -868,7 +993,7 @@ export function SpecView() {
{/* Editor */}
<div className="flex-1 p-4 overflow-hidden">
<Card className="h-full overflow-hidden">
<Card className="h-full">
<XmlSyntaxEditor
value={appSpec}
onChange={handleChange}
@@ -879,8 +1004,8 @@ export function SpecView() {
</div>
{/* Regenerate Dialog */}
<Dialog
open={showRegenerateDialog}
<Dialog
open={showRegenerateDialog}
onOpenChange={(open) => {
if (!open && !isRegenerating) {
setShowRegenerateDialog(false);
@@ -891,9 +1016,10 @@ export function SpecView() {
<DialogHeader>
<DialogTitle>Regenerate App Specification</DialogTitle>
<DialogDescription className="text-muted-foreground">
We will regenerate your app spec based on a short project definition and the
current tech stack found in your project. The agent will analyze your codebase
to understand your existing technologies and create a comprehensive specification.
We will regenerate your app spec based on a short project
definition and the current tech stack found in your project. The
agent will analyze your codebase to understand your existing
technologies and create a comprehensive specification.
</DialogDescription>
</DialogHeader>
@@ -903,8 +1029,9 @@ export function SpecView() {
Describe your project
</label>
<p className="text-xs text-muted-foreground">
Provide a clear description of what your app should do. Be as detailed as you
want - the more context you provide, the more comprehensive the spec will be.
Provide a clear description of what your app should do. Be as
detailed as you want - the more context you provide, the more
comprehensive the spec will be.
</p>
<textarea
className="w-full h-40 p-3 rounded-md border border-border bg-background font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
@@ -945,9 +1072,17 @@ export function SpecView() {
</Button>
<HotkeyButton
onClick={handleRegenerate}
disabled={!projectDefinition.trim() || isRegenerating || isGeneratingFeatures}
disabled={
!projectDefinition.trim() ||
isRegenerating ||
isGeneratingFeatures
}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={showRegenerateDialog && !isRegenerating && !isGeneratingFeatures}
hotkeyActive={
showRegenerateDialog &&
!isRegenerating &&
!isGeneratingFeatures
}
>
{isRegenerating ? (
<>
@@ -965,7 +1100,6 @@ export function SpecView() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -12,12 +12,12 @@ export interface CliStatus {
// Claude Auth Method - all possible authentication sources
export type ClaudeAuthMethod =
| "oauth_token_env" // CLAUDE_CODE_OAUTH_TOKEN environment variable
| "oauth_token" // Stored OAuth token from claude login
| "api_key_env" // ANTHROPIC_API_KEY environment variable
| "api_key" // Manually stored API key
| "credentials_file" // Generic credentials file detection
| "cli_authenticated" // Claude CLI is installed and has active sessions/activity
| "oauth_token_env" // CLAUDE_CODE_OAUTH_TOKEN environment variable
| "oauth_token" // Stored OAuth token from claude login
| "api_key_env" // ANTHROPIC_API_KEY environment variable
| "api_key" // Manually stored API key
| "credentials_file" // Generic credentials file detection
| "cli_authenticated" // Claude CLI is installed and has active sessions/activity
| "none";
// Claude Auth Status
@@ -86,16 +86,19 @@ const initialInstallProgress: InstallProgress = {
output: [],
};
// Check if setup should be skipped (for E2E testing)
const shouldSkipSetup = process.env.NEXT_PUBLIC_SKIP_SETUP === "true";
const initialState: SetupState = {
isFirstRun: true,
setupComplete: false,
currentStep: "welcome",
isFirstRun: !shouldSkipSetup,
setupComplete: shouldSkipSetup,
currentStep: shouldSkipSetup ? "complete" : "welcome",
claudeCliStatus: null,
claudeAuthStatus: null,
claudeInstallProgress: { ...initialInstallProgress },
skipClaudeSetup: false,
skipClaudeSetup: shouldSkipSetup,
};
export const useSetupStore = create<SetupState & SetupActions>()(
@@ -106,12 +109,14 @@ export const useSetupStore = create<SetupState & SetupActions>()(
// Setup flow
setCurrentStep: (step) => set({ currentStep: step }),
completeSetup: () => set({ setupComplete: true, currentStep: "complete" }),
completeSetup: () =>
set({ setupComplete: true, currentStep: "complete" }),
resetSetup: () => set({
...initialState,
isFirstRun: false, // Don't reset first run flag
}),
resetSetup: () =>
set({
...initialState,
isFirstRun: false, // Don't reset first run flag
}),
setIsFirstRun: (isFirstRun) => set({ isFirstRun }),
@@ -120,16 +125,18 @@ export const useSetupStore = create<SetupState & SetupActions>()(
setClaudeAuthStatus: (status) => set({ claudeAuthStatus: status }),
setClaudeInstallProgress: (progress) => set({
claudeInstallProgress: {
...get().claudeInstallProgress,
...progress,
},
}),
setClaudeInstallProgress: (progress) =>
set({
claudeInstallProgress: {
...get().claudeInstallProgress,
...progress,
},
}),
resetClaudeInstallProgress: () => set({
claudeInstallProgress: { ...initialInstallProgress },
}),
resetClaudeInstallProgress: () =>
set({
claudeInstallProgress: { ...initialInstallProgress },
}),
// Preferences
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),

View File

@@ -0,0 +1,472 @@
import { test, expect, Page } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";
// Resolve the workspace root - handle both running from apps/app and from root
function getWorkspaceRoot(): string {
const cwd = process.cwd();
if (cwd.includes("apps/app")) {
return path.resolve(cwd, "../..");
}
return cwd;
}
const WORKSPACE_ROOT = getWorkspaceRoot();
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA");
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, ".automaker/app_spec.txt");
// Original spec content for resetting between tests
const ORIGINAL_SPEC_CONTENT = `<app_spec>
<name>Test Project A</name>
<description>A test fixture project for Playwright testing</description>
<tech_stack>
<item>TypeScript</item>
<item>React</item>
</tech_stack>
</app_spec>
`;
/**
* Reset the fixture's app_spec.txt to original content
*/
function resetFixtureSpec() {
const dir = path.dirname(SPEC_FILE_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(SPEC_FILE_PATH, ORIGINAL_SPEC_CONTENT);
}
/**
* Set up localStorage with a project pointing to our test fixture
* Note: In CI, setup wizard is also skipped via NEXT_PUBLIC_SKIP_SETUP env var
*/
async function setupProjectWithFixture(page: Page, projectPath: string) {
await page.addInitScript((path: string) => {
const mockProject = {
id: "test-project-fixture",
name: "projectA",
path: path,
lastOpened: new Date().toISOString(),
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
currentView: "board",
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Also mark setup as complete (fallback for when NEXT_PUBLIC_SKIP_SETUP isn't set)
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
skipClaudeSetup: false,
},
version: 0,
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
}, projectPath);
}
/**
* Navigate to spec editor via sidebar
*/
async function navigateToSpecEditor(page: Page) {
// Click on the Spec Editor nav item in the sidebar
const specNavButton = page.locator('[data-testid="nav-spec"]');
await specNavButton.waitFor({ state: "visible", timeout: 10000 });
await specNavButton.click();
// Wait for the spec view to be visible
await page.waitForSelector('[data-testid="spec-view"]', { timeout: 10000 });
}
/**
* Get the CodeMirror editor content
*/
async function getEditorContent(page: Page): Promise<string> {
// CodeMirror uses a contenteditable div with class .cm-content
const content = await page
.locator('[data-testid="spec-editor"] .cm-content')
.textContent();
return content || "";
}
/**
* Set the CodeMirror editor content by selecting all and typing
*/
async function setEditorContent(page: Page, content: string) {
// Click on the editor to focus it
const editor = page.locator('[data-testid="spec-editor"] .cm-content');
await editor.click();
// Wait for focus
await page.waitForTimeout(200);
// Select all content (Cmd+A on Mac, Ctrl+A on others)
const isMac = process.platform === "darwin";
await page.keyboard.press(isMac ? "Meta+a" : "Control+a");
// Wait for selection
await page.waitForTimeout(100);
// Delete the selected content first
await page.keyboard.press("Backspace");
// Wait for deletion
await page.waitForTimeout(100);
// Type the new content
await page.keyboard.type(content, { delay: 10 });
// Wait for typing to complete
await page.waitForTimeout(200);
}
/**
* Click the save button
*/
async function clickSaveButton(page: Page) {
const saveButton = page.locator('[data-testid="save-spec"]');
await saveButton.click();
// Wait for the button text to change to "Saved" indicating save is complete
await page.waitForFunction(
() => {
const btn = document.querySelector('[data-testid="save-spec"]');
return btn?.textContent?.includes("Saved");
},
{ timeout: 5000 }
);
}
test.describe("Spec Editor Persistence", () => {
test.beforeEach(async () => {
// Reset the fixture spec file to original content before each test
resetFixtureSpec();
});
test.afterEach(async () => {
// Clean up - reset the spec file after each test
resetFixtureSpec();
});
test("should open project, edit spec, save, and persist changes after refresh", async ({
page,
}) => {
// Use the resolved fixture path
const fixturePath = FIXTURE_PATH;
// Step 1: Set up the project in localStorage pointing to our fixture
await setupProjectWithFixture(page, fixturePath);
// Step 2: Navigate to the app
await page.goto("/");
await page.waitForLoadState("networkidle");
// Step 3: Verify we're on the dashboard with the project loaded
// The sidebar should show the project selector
const sidebar = page.locator('[data-testid="sidebar"]');
await sidebar.waitFor({ state: "visible", timeout: 10000 });
// Step 4: Click on the Spec Editor in the sidebar
await navigateToSpecEditor(page);
// Step 5: Wait for the spec editor to load
const specEditor = page.locator('[data-testid="spec-editor"]');
await specEditor.waitFor({ state: "visible", timeout: 10000 });
// Step 6: Wait for CodeMirror to initialize (it has a .cm-content element)
await page.waitForSelector('[data-testid="spec-editor"] .cm-content', {
timeout: 10000,
});
// Small delay to ensure editor is fully initialized
await page.waitForTimeout(500);
// Step 7: Modify the editor content to "hello world"
await setEditorContent(page, "hello world");
// Step 8: Click the save button
await clickSaveButton(page);
// Step 9: Refresh the page
await page.reload();
await page.waitForLoadState("networkidle");
// Step 10: Navigate back to the spec editor
// After reload, we need to wait for the app to initialize
await page.waitForSelector('[data-testid="sidebar"]', { timeout: 10000 });
// Navigate to spec editor again
await navigateToSpecEditor(page);
// Wait for CodeMirror to be ready
await page.waitForSelector('[data-testid="spec-editor"] .cm-content', {
timeout: 10000,
});
// Small delay to ensure editor content is loaded
await page.waitForTimeout(500);
// Step 11: Verify the content was persisted
const persistedContent = await getEditorContent(page);
expect(persistedContent.trim()).toBe("hello world");
});
test("should handle opening project via Open Project button and file browser", async ({
page,
}) => {
// This test covers the flow of:
// 1. Clicking Open Project button
// 2. Using the file browser to navigate to the fixture directory
// 3. Opening the project
// 4. Editing the spec
// Set up without a current project to test the open project flow
await page.addInitScript(() => {
const mockState = {
state: {
projects: [],
currentProject: null,
currentView: "welcome",
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Mark setup as complete
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
skipClaudeSetup: false,
},
version: 0,
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
});
// Navigate to the app
await page.goto("/");
await page.waitForLoadState("networkidle");
// Wait for the sidebar to be visible
const sidebar = page.locator('[data-testid="sidebar"]');
await sidebar.waitFor({ state: "visible", timeout: 10000 });
// Click the Open Project button
const openProjectButton = page.locator(
'[data-testid="open-project-button"]'
);
// Check if the button is visible (it might not be in collapsed sidebar)
const isButtonVisible = await openProjectButton
.isVisible()
.catch(() => false);
if (isButtonVisible) {
await openProjectButton.click();
// The file browser dialog should open
// Note: In web mode, this might use the FileBrowserDialog component
// which makes requests to the backend server at /api/fs/browse
// Wait a bit to see if a dialog appears
await page.waitForTimeout(1000);
// Check if a dialog is visible
const dialog = page.locator('[role="dialog"]');
const dialogVisible = await dialog.isVisible().catch(() => false);
if (dialogVisible) {
// If file browser dialog is open, we need to navigate to the fixture path
// This depends on the current directory structure
// For now, let's verify the dialog appeared and close it
// A full test would navigate through directories
console.log("File browser dialog opened successfully");
// Press Escape to close the dialog
await page.keyboard.press("Escape");
}
}
// For a complete e2e test with file browsing, we'd need to:
// 1. Navigate through the directory tree
// 2. Select the projectA directory
// 3. Click "Select Current Folder"
// Since this involves actual file system navigation,
// and depends on the backend server being properly configured,
// we'll verify the basic UI elements are present
expect(sidebar).toBeTruthy();
});
});
test.describe("Spec Editor - Full Open Project Flow", () => {
test.beforeEach(async () => {
// Reset the fixture spec file to original content before each test
resetFixtureSpec();
});
test.afterEach(async () => {
// Clean up - reset the spec file after each test
resetFixtureSpec();
});
test("should open project via file browser, edit spec, and persist", async ({
page,
}) => {
// Navigate to app first
await page.goto("/");
await page.waitForLoadState("networkidle");
// Set up localStorage state (without a current project, but mark setup complete)
// Using evaluate instead of addInitScript so it only runs once
// Note: In CI, setup wizard is also skipped via NEXT_PUBLIC_SKIP_SETUP env var
await page.evaluate(() => {
const mockState = {
state: {
projects: [],
currentProject: null,
currentView: "welcome",
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Mark setup as complete (fallback for when NEXT_PUBLIC_SKIP_SETUP isn't set)
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
skipClaudeSetup: false,
},
version: 0,
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
});
// Reload to apply the localStorage state
await page.reload();
await page.waitForLoadState("networkidle");
// Wait for sidebar
await page.waitForSelector('[data-testid="sidebar"]', { timeout: 10000 });
// Click the Open Project button
const openProjectButton = page.locator(
'[data-testid="open-project-button"]'
);
await openProjectButton.waitFor({ state: "visible", timeout: 10000 });
await openProjectButton.click();
// Wait for the file browser dialog to open
const dialogTitle = page.locator('text="Select Project Directory"');
await dialogTitle.waitFor({ state: "visible", timeout: 10000 });
// Wait for the dialog to fully load (loading to complete)
await page.waitForFunction(
() => !document.body.textContent?.includes("Loading directories..."),
{ timeout: 10000 }
);
// Use the path input to directly navigate to the fixture directory
const pathInput = page.locator('[data-testid="path-input"]');
await pathInput.waitFor({ state: "visible", timeout: 5000 });
// Clear the input and type the full path to the fixture
await pathInput.fill(FIXTURE_PATH);
// Click the Go button to navigate to the path
const goButton = page.locator('[data-testid="go-to-path-button"]');
await goButton.click();
// Wait for loading to complete
await page.waitForFunction(
() => !document.body.textContent?.includes("Loading directories..."),
{ timeout: 10000 }
);
// Verify we're in the right directory by checking the path display
const pathDisplay = page.locator(".font-mono.text-sm.truncate");
await expect(pathDisplay).toContainText("projectA");
// Click "Select Current Folder" button
const selectFolderButton = page.locator(
'button:has-text("Select Current Folder")'
);
await selectFolderButton.click();
// Wait for dialog to close and project to load
await page.waitForFunction(
() => !document.querySelector('[role="dialog"]'),
{ timeout: 10000 }
);
await page.waitForTimeout(500);
// Navigate to spec editor
const specNav = page.locator('[data-testid="nav-spec"]');
await specNav.waitFor({ state: "visible", timeout: 10000 });
await specNav.click();
// Wait for spec view with the editor (not the empty state)
await page.waitForSelector('[data-testid="spec-view"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="spec-editor"] .cm-content', {
timeout: 10000,
});
await page.waitForTimeout(500);
// Edit the content
await setEditorContent(page, "hello world");
// Click save button
await clickSaveButton(page);
// Refresh and verify persistence
await page.reload();
await page.waitForLoadState("networkidle");
// Navigate back to spec editor
await specNav.waitFor({ state: "visible", timeout: 10000 });
await specNav.click();
await page.waitForSelector('[data-testid="spec-editor"] .cm-content', {
timeout: 10000,
});
await page.waitForTimeout(500);
// Verify the content persisted
const persistedContent = await getEditorContent(page);
expect(persistedContent.trim()).toBe("hello world");
});
});

View File

@@ -0,0 +1,88 @@
/**
* XML Template Format Specification for app_spec.txt
*
* This format must be included in all prompts that generate, modify, or regenerate
* app specifications to ensure consistency across the application.
*/
export const APP_SPEC_XML_FORMAT = `
The app_spec.txt file MUST follow this exact XML format:
<project_specification>
<project_name>Project Name</project_name>
<overview>
A comprehensive description of what the project does, its purpose, and key goals.
</overview>
<technology_stack>
<technology>Technology 1</technology>
<technology>Technology 2</technology>
<!-- List all technologies, frameworks, libraries, and tools used -->
</technology_stack>
<core_capabilities>
<capability>Core capability 1</capability>
<capability>Core capability 2</capability>
<!-- List main features and capabilities the project provides -->
</core_capabilities>
<implemented_features>
<!-- Features that have been implemented (populated by AI agent based on code analysis) -->
</implemented_features>
<!-- Optional sections that may be included: -->
<additional_requirements>
<!-- Any additional requirements or constraints -->
</additional_requirements>
<development_guidelines>
<guideline>Guideline 1</guideline>
<guideline>Guideline 2</guideline>
<!-- Development standards and practices -->
</development_guidelines>
<implementation_roadmap>
<!-- Phases or roadmap items for implementation -->
</implementation_roadmap>
</project_specification>
IMPORTANT:
- All content must be wrapped in valid XML tags
- Use proper XML escaping for special characters (&lt;, &gt;, &amp;)
- Maintain proper indentation (2 spaces)
- All sections should be populated based on project analysis
- The format must be strictly followed - do not use markdown, JSON, or any other format
`;
/**
* Returns a prompt suffix that instructs the AI to format the response as XML
* following the app_spec.txt template format.
*/
export function getAppSpecFormatInstruction(): string {
return `
${APP_SPEC_XML_FORMAT}
CRITICAL FORMATTING REQUIREMENTS:
- Your ENTIRE response MUST be valid XML following the exact template structure above
- Do NOT use markdown formatting (no # headers, no **bold**, no - lists, etc.)
- Do NOT include any explanatory text, prefix, or suffix outside the XML tags
- Do NOT include phrases like "Based on my analysis..." or "I'll create..." before the XML
- Do NOT include any text before <project_specification> or after </project_specification>
- Your response must start IMMEDIATELY with <project_specification> with no preceding text
- Your response must end IMMEDIATELY with </project_specification> with no following text
- Use ONLY XML tags as shown in the template
- Properly escape XML special characters (&lt; for <, &gt; for >, &amp; for &)
- Maintain 2-space indentation for readability
- The output will be saved directly to app_spec.txt and must be parseable as valid XML
- The response must contain exactly ONE root XML element: <project_specification>
- Do not include code blocks, markdown fences, or any other formatting
VERIFICATION: Before responding, verify that:
1. Your response starts with <project_specification> (no spaces, no text before it)
2. Your response ends with </project_specification> (no spaces, no text after it)
3. There is exactly one root XML element
4. There is no explanatory text, analysis, or commentary outside the XML tags
Your response should be ONLY the XML content, nothing else.
`;
}

View File

@@ -15,13 +15,29 @@ let currentAbortController: AbortController | null = null;
function logAuthStatus(context: string): void {
const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
console.log(`[SpecRegeneration] ${context} - Auth Status:`);
console.log(`[SpecRegeneration] CLAUDE_CODE_OAUTH_TOKEN: ${hasOAuthToken ? 'SET (' + process.env.CLAUDE_CODE_OAUTH_TOKEN?.substring(0, 20) + '...)' : 'NOT SET'}`);
console.log(`[SpecRegeneration] ANTHROPIC_API_KEY: ${hasApiKey ? 'SET (' + process.env.ANTHROPIC_API_KEY?.substring(0, 20) + '...)' : 'NOT SET'}`);
console.log(
`[SpecRegeneration] CLAUDE_CODE_OAUTH_TOKEN: ${
hasOAuthToken
? "SET (" +
process.env.CLAUDE_CODE_OAUTH_TOKEN?.substring(0, 20) +
"...)"
: "NOT SET"
}`
);
console.log(
`[SpecRegeneration] ANTHROPIC_API_KEY: ${
hasApiKey
? "SET (" + process.env.ANTHROPIC_API_KEY?.substring(0, 20) + "...)"
: "NOT SET"
}`
);
if (!hasOAuthToken && !hasApiKey) {
console.error(`[SpecRegeneration] ⚠️ WARNING: No authentication configured! SDK will fail.`);
console.error(
`[SpecRegeneration] ⚠️ WARNING: No authentication configured! SDK will fail.`
);
}
}
@@ -30,9 +46,14 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
// Create project spec from overview
router.post("/create", async (req: Request, res: Response) => {
console.log("[SpecRegeneration] ========== /create endpoint called ==========");
console.log("[SpecRegeneration] Request body:", JSON.stringify(req.body, null, 2));
console.log(
"[SpecRegeneration] ========== /create endpoint called =========="
);
console.log(
"[SpecRegeneration] Request body:",
JSON.stringify(req.body, null, 2)
);
try {
const { projectPath, projectOverview, generateFeatures } = req.body as {
projectPath: string;
@@ -42,7 +63,11 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
console.log(`[SpecRegeneration] Parsed params:`);
console.log(`[SpecRegeneration] projectPath: ${projectPath}`);
console.log(`[SpecRegeneration] projectOverview length: ${projectOverview?.length || 0} chars`);
console.log(
`[SpecRegeneration] projectOverview length: ${
projectOverview?.length || 0
} chars`
);
console.log(`[SpecRegeneration] generateFeatures: ${generateFeatures}`);
if (!projectPath || !projectOverview) {
@@ -55,7 +80,9 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
}
if (isRunning) {
console.warn("[SpecRegeneration] Generation already running, rejecting request");
console.warn(
"[SpecRegeneration] Generation already running, rejecting request"
);
res.json({ success: false, error: "Spec generation already running" });
return;
}
@@ -79,19 +106,26 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
console.error("[SpecRegeneration] Error name:", error?.name);
console.error("[SpecRegeneration] Error message:", error?.message);
console.error("[SpecRegeneration] Error stack:", error?.stack);
console.error("[SpecRegeneration] Full error object:", JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
console.error(
"[SpecRegeneration] Full error object:",
JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
);
events.emit("spec-regeneration:event", {
type: "spec_error",
error: error.message || String(error),
});
})
.finally(() => {
console.log("[SpecRegeneration] Generation task finished (success or error)");
console.log(
"[SpecRegeneration] Generation task finished (success or error)"
);
isRunning = false;
currentAbortController = null;
});
console.log("[SpecRegeneration] Returning success response (generation running in background)");
console.log(
"[SpecRegeneration] Returning success response (generation running in background)"
);
res.json({ success: true });
} catch (error) {
console.error("[SpecRegeneration] ❌ Route handler exception:");
@@ -103,9 +137,14 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
// Generate from project definition
router.post("/generate", async (req: Request, res: Response) => {
console.log("[SpecRegeneration] ========== /generate endpoint called ==========");
console.log("[SpecRegeneration] Request body:", JSON.stringify(req.body, null, 2));
console.log(
"[SpecRegeneration] ========== /generate endpoint called =========="
);
console.log(
"[SpecRegeneration] Request body:",
JSON.stringify(req.body, null, 2)
);
try {
const { projectPath, projectDefinition } = req.body as {
projectPath: string;
@@ -114,7 +153,11 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
console.log(`[SpecRegeneration] Parsed params:`);
console.log(`[SpecRegeneration] projectPath: ${projectPath}`);
console.log(`[SpecRegeneration] projectDefinition length: ${projectDefinition?.length || 0} chars`);
console.log(
`[SpecRegeneration] projectDefinition length: ${
projectDefinition?.length || 0
} chars`
);
if (!projectPath || !projectDefinition) {
console.error("[SpecRegeneration] Missing required parameters");
@@ -126,7 +169,9 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
}
if (isRunning) {
console.warn("[SpecRegeneration] Generation already running, rejecting request");
console.warn(
"[SpecRegeneration] Generation already running, rejecting request"
);
res.json({ success: false, error: "Spec generation already running" });
return;
}
@@ -149,19 +194,26 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
console.error("[SpecRegeneration] Error name:", error?.name);
console.error("[SpecRegeneration] Error message:", error?.message);
console.error("[SpecRegeneration] Error stack:", error?.stack);
console.error("[SpecRegeneration] Full error object:", JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
console.error(
"[SpecRegeneration] Full error object:",
JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
);
events.emit("spec-regeneration:event", {
type: "spec_error",
error: error.message || String(error),
});
})
.finally(() => {
console.log("[SpecRegeneration] Generation task finished (success or error)");
console.log(
"[SpecRegeneration] Generation task finished (success or error)"
);
isRunning = false;
currentAbortController = null;
});
console.log("[SpecRegeneration] Returning success response (generation running in background)");
console.log(
"[SpecRegeneration] Returning success response (generation running in background)"
);
res.json({ success: true });
} catch (error) {
console.error("[SpecRegeneration] ❌ Route handler exception:");
@@ -173,9 +225,14 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
// Generate features from existing spec
router.post("/generate-features", async (req: Request, res: Response) => {
console.log("[SpecRegeneration] ========== /generate-features endpoint called ==========");
console.log("[SpecRegeneration] Request body:", JSON.stringify(req.body, null, 2));
console.log(
"[SpecRegeneration] ========== /generate-features endpoint called =========="
);
console.log(
"[SpecRegeneration] Request body:",
JSON.stringify(req.body, null, 2)
);
try {
const { projectPath } = req.body as { projectPath: string };
@@ -188,7 +245,9 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
}
if (isRunning) {
console.warn("[SpecRegeneration] Generation already running, rejecting request");
console.warn(
"[SpecRegeneration] Generation already running, rejecting request"
);
res.json({ success: false, error: "Generation already running" });
return;
}
@@ -197,27 +256,38 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
isRunning = true;
currentAbortController = new AbortController();
console.log("[SpecRegeneration] Starting background feature generation task...");
console.log(
"[SpecRegeneration] Starting background feature generation task..."
);
generateFeaturesFromSpec(projectPath, events, currentAbortController)
.catch((error) => {
console.error("[SpecRegeneration] ❌ Feature generation failed with error:");
console.error(
"[SpecRegeneration] ❌ Feature generation failed with error:"
);
console.error("[SpecRegeneration] Error name:", error?.name);
console.error("[SpecRegeneration] Error message:", error?.message);
console.error("[SpecRegeneration] Error stack:", error?.stack);
console.error("[SpecRegeneration] Full error object:", JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
console.error(
"[SpecRegeneration] Full error object:",
JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
);
events.emit("spec-regeneration:event", {
type: "features_error",
error: error.message || String(error),
});
})
.finally(() => {
console.log("[SpecRegeneration] Feature generation task finished (success or error)");
console.log(
"[SpecRegeneration] Feature generation task finished (success or error)"
);
isRunning = false;
currentAbortController = null;
});
console.log("[SpecRegeneration] Returning success response (generation running in background)");
console.log(
"[SpecRegeneration] Returning success response (generation running in background)"
);
res.json({ success: true });
} catch (error) {
console.error("[SpecRegeneration] ❌ Route handler exception:");
@@ -261,11 +331,15 @@ async function generateSpec(
abortController: AbortController,
generateFeatures?: boolean
) {
console.log("[SpecRegeneration] ========== generateSpec() started ==========");
console.log(
"[SpecRegeneration] ========== generateSpec() started =========="
);
console.log(`[SpecRegeneration] projectPath: ${projectPath}`);
console.log(`[SpecRegeneration] projectOverview length: ${projectOverview.length} chars`);
console.log(
`[SpecRegeneration] projectOverview length: ${projectOverview.length} chars`
);
console.log(`[SpecRegeneration] generateFeatures: ${generateFeatures}`);
const prompt = `You are helping to define a software project specification.
Project Overview:
@@ -281,19 +355,23 @@ Based on this overview, analyze the project and create a comprehensive specifica
6. **API Design** - Main endpoints/interfaces needed
7. **User Experience** - Key user flows and interactions
${generateFeatures ? `
${
generateFeatures
? `
Also generate a list of features to implement. For each feature provide:
- ID (lowercase-hyphenated)
- Title
- Description
- Priority (1=high, 2=medium, 3=low)
- Estimated complexity (simple, moderate, complex)
` : ""}
`
: ""
}
Format your response as markdown. Be specific and actionable.`;
console.log(`[SpecRegeneration] Prompt length: ${prompt.length} chars`);
events.emit("spec-regeneration:event", {
type: "spec_progress",
content: "Starting spec generation...\n",
@@ -308,9 +386,12 @@ Format your response as markdown. Be specific and actionable.`;
abortController,
};
console.log("[SpecRegeneration] SDK Options:", JSON.stringify(options, null, 2));
console.log(
"[SpecRegeneration] SDK Options:",
JSON.stringify(options, null, 2)
);
console.log("[SpecRegeneration] Calling Claude Agent SDK query()...");
// Log auth status right before the SDK call
logAuthStatus("Right before SDK query()");
@@ -332,13 +413,22 @@ Format your response as markdown. Be specific and actionable.`;
try {
for await (const msg of stream) {
messageCount++;
console.log(`[SpecRegeneration] Stream message #${messageCount}:`, JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2));
console.log(
`[SpecRegeneration] Stream message #${messageCount}:`,
JSON.stringify(
{ type: msg.type, subtype: (msg as any).subtype },
null,
2
)
);
if (msg.type === "assistant" && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText = block.text;
console.log(`[SpecRegeneration] Text block received (${block.text.length} chars)`);
console.log(
`[SpecRegeneration] Text block received (${block.text.length} chars)`
);
events.emit("spec-regeneration:event", {
type: "spec_progress",
content: block.text,
@@ -356,8 +446,13 @@ Format your response as markdown. Be specific and actionable.`;
console.log("[SpecRegeneration] Received success result");
responseText = (msg as any).result || responseText;
} else if ((msg as { type: string }).type === "error") {
console.error("[SpecRegeneration] ❌ Received error message from stream:");
console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2));
console.error(
"[SpecRegeneration] ❌ Received error message from stream:"
);
console.error(
"[SpecRegeneration] Error message:",
JSON.stringify(msg, null, 2)
);
}
}
} catch (streamError) {
@@ -366,15 +461,19 @@ Format your response as markdown. Be specific and actionable.`;
throw streamError;
}
console.log(`[SpecRegeneration] Stream iteration complete. Total messages: ${messageCount}`);
console.log(`[SpecRegeneration] Response text length: ${responseText.length} chars`);
console.log(
`[SpecRegeneration] Stream iteration complete. Total messages: ${messageCount}`
);
console.log(
`[SpecRegeneration] Response text length: ${responseText.length} chars`
);
// Save spec
const specDir = path.join(projectPath, ".automaker");
const specPath = path.join(specDir, "app_spec.txt");
console.log(`[SpecRegeneration] Saving spec to: ${specPath}`);
await fs.mkdir(specDir, { recursive: true });
await fs.writeFile(specPath, responseText);
@@ -391,8 +490,10 @@ Format your response as markdown. Be specific and actionable.`;
console.log("[SpecRegeneration] Starting feature generation...");
await parseAndCreateFeatures(projectPath, responseText, events);
}
console.log("[SpecRegeneration] ========== generateSpec() completed ==========");
console.log(
"[SpecRegeneration] ========== generateSpec() completed =========="
);
}
async function generateFeaturesFromSpec(
@@ -400,9 +501,11 @@ async function generateFeaturesFromSpec(
events: EventEmitter,
abortController: AbortController
) {
console.log("[SpecRegeneration] ========== generateFeaturesFromSpec() started ==========");
console.log(
"[SpecRegeneration] ========== generateFeaturesFromSpec() started =========="
);
console.log(`[SpecRegeneration] projectPath: ${projectPath}`);
// Read existing spec
const specPath = path.join(projectPath, ".automaker", "app_spec.txt");
let spec: string;
@@ -411,7 +514,9 @@ async function generateFeaturesFromSpec(
try {
spec = await fs.readFile(specPath, "utf-8");
console.log(`[SpecRegeneration] Spec loaded successfully (${spec.length} chars)`);
console.log(
`[SpecRegeneration] Spec loaded successfully (${spec.length} chars)`
);
} catch (readError) {
console.error("[SpecRegeneration] ❌ Failed to read spec file:", readError);
events.emit("spec-regeneration:event", {
@@ -466,9 +571,14 @@ Generate 5-15 features that build on each other logically.`;
abortController,
};
console.log("[SpecRegeneration] SDK Options:", JSON.stringify(options, null, 2));
console.log("[SpecRegeneration] Calling Claude Agent SDK query() for features...");
console.log(
"[SpecRegeneration] SDK Options:",
JSON.stringify(options, null, 2)
);
console.log(
"[SpecRegeneration] Calling Claude Agent SDK query() for features..."
);
logAuthStatus("Right before SDK query() for features");
let stream;
@@ -489,13 +599,22 @@ Generate 5-15 features that build on each other logically.`;
try {
for await (const msg of stream) {
messageCount++;
console.log(`[SpecRegeneration] Feature stream message #${messageCount}:`, JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2));
console.log(
`[SpecRegeneration] Feature stream message #${messageCount}:`,
JSON.stringify(
{ type: msg.type, subtype: (msg as any).subtype },
null,
2
)
);
if (msg.type === "assistant" && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText = block.text;
console.log(`[SpecRegeneration] Feature text block received (${block.text.length} chars)`);
console.log(
`[SpecRegeneration] Feature text block received (${block.text.length} chars)`
);
events.emit("spec-regeneration:event", {
type: "features_progress",
content: block.text,
@@ -506,22 +625,35 @@ Generate 5-15 features that build on each other logically.`;
console.log("[SpecRegeneration] Received success result for features");
responseText = (msg as any).result || responseText;
} else if ((msg as { type: string }).type === "error") {
console.error("[SpecRegeneration] ❌ Received error message from feature stream:");
console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2));
console.error(
"[SpecRegeneration] ❌ Received error message from feature stream:"
);
console.error(
"[SpecRegeneration] Error message:",
JSON.stringify(msg, null, 2)
);
}
}
} catch (streamError) {
console.error("[SpecRegeneration] ❌ Error while iterating feature stream:");
console.error(
"[SpecRegeneration] ❌ Error while iterating feature stream:"
);
console.error("[SpecRegeneration] Stream error:", streamError);
throw streamError;
}
console.log(`[SpecRegeneration] Feature stream complete. Total messages: ${messageCount}`);
console.log(`[SpecRegeneration] Feature response length: ${responseText.length} chars`);
console.log(
`[SpecRegeneration] Feature stream complete. Total messages: ${messageCount}`
);
console.log(
`[SpecRegeneration] Feature response length: ${responseText.length} chars`
);
await parseAndCreateFeatures(projectPath, responseText, events);
console.log("[SpecRegeneration] ========== generateFeaturesFromSpec() completed ==========");
console.log(
"[SpecRegeneration] ========== generateFeaturesFromSpec() completed =========="
);
}
async function parseAndCreateFeatures(
@@ -529,24 +661,33 @@ async function parseAndCreateFeatures(
content: string,
events: EventEmitter
) {
console.log("[SpecRegeneration] ========== parseAndCreateFeatures() started ==========");
console.log(
"[SpecRegeneration] ========== parseAndCreateFeatures() started =========="
);
console.log(`[SpecRegeneration] Content length: ${content.length} chars`);
try {
// Extract JSON from response
console.log("[SpecRegeneration] Extracting JSON from response...");
const jsonMatch = content.match(/\{[\s\S]*"features"[\s\S]*\}/);
if (!jsonMatch) {
console.error("[SpecRegeneration] ❌ No valid JSON found in response");
console.error("[SpecRegeneration] Content preview:", content.substring(0, 500));
console.error(
"[SpecRegeneration] Content preview:",
content.substring(0, 500)
);
throw new Error("No valid JSON found in response");
}
console.log(`[SpecRegeneration] JSON match found (${jsonMatch[0].length} chars)`);
console.log(
`[SpecRegeneration] JSON match found (${jsonMatch[0].length} chars)`
);
const parsed = JSON.parse(jsonMatch[0]);
console.log(`[SpecRegeneration] Parsed ${parsed.features?.length || 0} features`);
console.log(
`[SpecRegeneration] Parsed ${parsed.features?.length || 0} features`
);
const featuresDir = path.join(projectPath, ".automaker", "features");
await fs.mkdir(featuresDir, { recursive: true });
@@ -561,7 +702,7 @@ async function parseAndCreateFeatures(
id: feature.id,
title: feature.title,
description: feature.description,
status: "backlog", // Features go to backlog - user must manually start them
status: "backlog", // Features go to backlog - user must manually start them
priority: feature.priority || 2,
complexity: feature.complexity || "moderate",
dependencies: feature.dependencies || [],
@@ -577,7 +718,9 @@ async function parseAndCreateFeatures(
createdFeatures.push({ id: feature.id, title: feature.title });
}
console.log(`[SpecRegeneration] ✓ Created ${createdFeatures.length} features successfully`);
console.log(
`[SpecRegeneration] ✓ Created ${createdFeatures.length} features successfully`
);
events.emit("spec-regeneration:event", {
type: "features_complete",
@@ -592,6 +735,8 @@ async function parseAndCreateFeatures(
error: (error as Error).message,
});
}
console.log("[SpecRegeneration] ========== parseAndCreateFeatures() completed ==========");
console.log(
"[SpecRegeneration] ========== parseAndCreateFeatures() completed =========="
);
}

557
package-lock.json generated
View File

@@ -19,9 +19,12 @@
"hasInstallScript": true,
"license": "Unlicense",
"dependencies": {
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.3",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -32,6 +35,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
@@ -2517,13 +2521,6 @@
"@types/node": "*"
}
},
"apps/app/node_modules/@types/hast": {
"version": "3.0.4",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"apps/app/node_modules/@types/json-schema": {
"version": "7.0.15",
"dev": true,
@@ -2555,13 +2552,6 @@
"xmlbuilder": ">=11.0.1"
}
},
"apps/app/node_modules/@types/react": {
"version": "19.2.7",
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"apps/app/node_modules/@types/react-dom": {
"version": "19.2.3",
"devOptional": true,
@@ -2570,10 +2560,6 @@
"@types/react": "^19.2.0"
}
},
"apps/app/node_modules/@types/unist": {
"version": "3.0.3",
"license": "MIT"
},
"apps/app/node_modules/@types/verror": {
"version": "1.10.11",
"dev": true,
@@ -3737,14 +3723,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"apps/app/node_modules/character-entities": {
"version": "2.0.2",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"apps/app/node_modules/character-entities-html4": {
"version": "2.1.0",
"license": "MIT",
@@ -3753,22 +3731,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"apps/app/node_modules/character-entities-legacy": {
"version": "3.0.0",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"apps/app/node_modules/character-reference-invalid": {
"version": "2.0.1",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"apps/app/node_modules/chownr": {
"version": "2.0.0",
"dev": true,
@@ -3905,14 +3867,6 @@
"node": ">= 0.8"
}
},
"apps/app/node_modules/comma-separated-tokens": {
"version": "2.0.3",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"apps/app/node_modules/commander": {
"version": "5.1.0",
"dev": true,
@@ -4051,10 +4005,6 @@
"optional": true,
"peer": true
},
"apps/app/node_modules/csstype": {
"version": "3.2.3",
"license": "MIT"
},
"apps/app/node_modules/damerau-levenshtein": {
"version": "1.0.8",
"dev": true,
@@ -4123,17 +4073,6 @@
}
}
},
"apps/app/node_modules/decode-named-character-reference": {
"version": "1.2.0",
"license": "MIT",
"dependencies": {
"character-entities": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"apps/app/node_modules/deep-is": {
"version": "0.1.4",
"dev": true,
@@ -5662,26 +5601,6 @@
"node": ">= 12"
}
},
"apps/app/node_modules/is-alphabetical": {
"version": "2.0.1",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"apps/app/node_modules/is-alphanumerical": {
"version": "2.0.1",
"license": "MIT",
"dependencies": {
"is-alphabetical": "^2.0.0",
"is-decimal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"apps/app/node_modules/is-array-buffer": {
"version": "3.0.5",
"dev": true,
@@ -5831,14 +5750,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"apps/app/node_modules/is-decimal": {
"version": "2.0.1",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"apps/app/node_modules/is-extglob": {
"version": "2.1.1",
"dev": true,
@@ -5890,14 +5801,6 @@
"node": ">=0.10.0"
}
},
"apps/app/node_modules/is-hexadecimal": {
"version": "2.0.1",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"apps/app/node_modules/is-interactive": {
"version": "1.0.0",
"dev": true,
@@ -7544,27 +7447,6 @@
"node": ">=6"
}
},
"apps/app/node_modules/parse-entities": {
"version": "4.0.2",
"license": "MIT",
"dependencies": {
"@types/unist": "^2.0.0",
"character-entities-legacy": "^3.0.0",
"character-reference-invalid": "^2.0.0",
"decode-named-character-reference": "^1.0.0",
"is-alphanumerical": "^2.0.0",
"is-decimal": "^2.0.0",
"is-hexadecimal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"apps/app/node_modules/parse-entities/node_modules/@types/unist": {
"version": "2.0.11",
"license": "MIT"
},
"apps/app/node_modules/path-exists": {
"version": "4.0.0",
"dev": true,
@@ -7783,14 +7665,6 @@
"react-is": "^16.13.1"
}
},
"apps/app/node_modules/property-information": {
"version": "7.1.0",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"apps/app/node_modules/proxy-from-env": {
"version": "1.1.0",
"dev": true,
@@ -8345,14 +8219,6 @@
"source-map": "^0.6.0"
}
},
"apps/app/node_modules/space-separated-tokens": {
"version": "2.0.2",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"apps/app/node_modules/ssri": {
"version": "12.0.0",
"dev": true,
@@ -9861,6 +9727,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
@@ -9885,6 +9760,113 @@
"node": ">=18"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.0.tgz",
"integrity": "sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-xml": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
"integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/xml": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.11.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.1.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.2",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz",
"integrity": "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
"integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.39.4",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@electron/get": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
@@ -10888,6 +10870,47 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz",
"integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.5.tgz",
"integrity": "sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/xml": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
"integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@next/env": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
@@ -11655,6 +11678,15 @@
"@types/send": "*"
}
},
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/http-cache-semantics": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
@@ -11703,6 +11735,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/responselike": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
@@ -11734,6 +11775,12 @@
"@types/node": "*"
}
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -11755,6 +11802,59 @@
"@types/node": "*"
}
},
"node_modules/@uiw/codemirror-extensions-basic-setup": {
"version": "4.25.4",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.4.tgz",
"integrity": "sha512-YzNwkm0AbPv1EXhCHYR5v0nqfemG2jEB0Z3Att4rBYqKrlG7AA9Rhjc3IyBaOzsBu18wtrp9/+uhTyu7TXSRng==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@codemirror/autocomplete": ">=6.0.0",
"@codemirror/commands": ">=6.0.0",
"@codemirror/language": ">=6.0.0",
"@codemirror/lint": ">=6.0.0",
"@codemirror/search": ">=6.0.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/view": ">=6.0.0"
}
},
"node_modules/@uiw/react-codemirror": {
"version": "4.25.4",
"resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.4.tgz",
"integrity": "sha512-ipO067oyfUw+DVaXhQCxkB0ZD9b7RnY+ByrprSYSKCHaULvJ3sqWYC/Zen6zVQ8/XC4o5EPBfatGiX20kC7XGA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.6",
"@codemirror/commands": "^6.1.0",
"@codemirror/state": "^6.1.1",
"@codemirror/theme-one-dark": "^6.0.0",
"@uiw/codemirror-extensions-basic-setup": "4.25.4",
"codemirror": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.11.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/theme-one-dark": ">=6.0.0",
"@codemirror/view": ">=6.0.0",
"codemirror": ">=6.0.0",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.15.tgz",
@@ -12201,6 +12301,36 @@
"node": ">=18"
}
},
"node_modules/character-entities": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
"integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-entities-legacy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-reference-invalid": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
"integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -12220,6 +12350,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -12240,6 +12385,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -12287,6 +12442,12 @@
"node": ">= 0.10"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -12302,6 +12463,25 @@
"node": ">= 8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
"integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==",
"license": "MIT",
"dependencies": {
"character-entities": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -13225,6 +13405,40 @@
"node": ">= 0.10"
}
},
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
"integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-alphanumerical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
"integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
"license": "MIT",
"dependencies": {
"is-alphabetical": "^2.0.0",
"is-decimal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-decimal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
"integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -13235,6 +13449,16 @@
"node": ">=8"
}
},
"node_modules/is-hexadecimal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
"integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -13864,6 +14088,31 @@
"node": ">=8"
}
},
"node_modules/parse-entities": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
"integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
"license": "MIT",
"dependencies": {
"@types/unist": "^2.0.0",
"character-entities-legacy": "^3.0.0",
"character-reference-invalid": "^2.0.0",
"decode-named-character-reference": "^1.0.0",
"is-alphanumerical": "^2.0.0",
"is-decimal": "^2.0.0",
"is-hexadecimal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/parse-entities/node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -13954,6 +14203,16 @@
"node": ">=0.4.0"
}
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
"integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -14556,6 +14815,16 @@
"node": ">=0.10.0"
}
},
"node_modules/space-separated-tokens": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@@ -14587,6 +14856,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -15505,6 +15780,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

2
test/fixtures/projectA/.gitkeep vendored Normal file
View File

@@ -0,0 +1,2 @@
# This file ensures the test fixture directory is tracked by git.
# The .automaker directory is created at test runtime.