From 6165d94aee114d8e9bfd098f5f9f19d55a6715f5 Mon Sep 17 00:00:00 2001 From: matheusBBarni Date: Sat, 11 Apr 2026 09:47:48 -0300 Subject: [PATCH 01/32] Add project-scoped setup and document settings flow --- docs/PRD.md | 32 +- docs/SPEC.md | 54 +- src-tauri/src/lib.rs | 634 +++++++++-- src/App.tsx | 1289 ++++++++++++++-------- src/components/AppRail.tsx | 24 +- src/components/CliHealthCard.tsx | 44 + src/components/MainWorkspace.tsx | 37 +- src/components/PrdEmptyState.tsx | 71 ++ src/components/ProjectAiSettingsCard.tsx | 126 +++ src/components/ProjectDocumentsCard.tsx | 98 ++ src/components/SettingsView.tsx | 173 +-- src/components/SpecEmptyState.tsx | 52 +- src/components/StatusPill.tsx | 1 + src/lib/appShell.ts | 8 + src/lib/projectConfig.ts | 114 ++ src/lib/runtime.ts | 68 +- src/screens/ConfigurationScreen.tsx | 276 +++++ src/store/useProjectStore.ts | 62 +- src/store/useSettingsStore.ts | 34 +- src/types.ts | 23 + 20 files changed, 2476 insertions(+), 744 deletions(-) create mode 100644 src/components/CliHealthCard.tsx create mode 100644 src/components/PrdEmptyState.tsx create mode 100644 src/components/ProjectAiSettingsCard.tsx create mode 100644 src/components/ProjectDocumentsCard.tsx create mode 100644 src/lib/projectConfig.ts create mode 100644 src/screens/ConfigurationScreen.tsx diff --git a/docs/PRD.md b/docs/PRD.md index 995cecc..8b4747c 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -2,7 +2,7 @@ ## 1. Product Overview -**SpecForge** is a spec review workspace for desktop-first development. It helps a user load a PRD and a technical spec, inspect workspace files, review environment readiness, draft a missing spec from AI when needed, and step through an execution-style dashboard before handing work off to a real IDE or CLI workflow. +**SpecForge** is a setup-first review workspace for desktop-first development. It helps a user choose a project folder, persist project-scoped AI/document settings in `.specforge/settings.json`, inspect CLI readiness, draft missing PRD/spec documents from AI when needed, review workspace files, and step through an execution-style dashboard before handing work off to a real IDE or CLI workflow. Today the product focuses on **review, import, diff inspection, and approval UX**. The execution loop shown in the app is currently a **simulated agent run**, not a real Claude CLI or Codex CLI orchestration engine. @@ -14,29 +14,28 @@ Today the product focuses on **review, import, diff inspection, and approval UX* ## 3. Current User Flow -1. **Open the review workspace:** The app starts with bundled `docs/PRD.md` and `docs/SPEC.md`. -2. **Load documents:** The user can use pane-header `Load PRD` and `Load Spec` actions to import Markdown or PDF through the desktop native file picker, or Markdown through the browser fallback picker. -3. **Open a workspace folder:** The user can scan a folder into the workspace tree. Desktop scanning respects `.gitignore`, and browser folder import applies root and nested `.gitignore` rules. -4. **Handle missing documents:** If the opened workspace does not contain a PRD, the left pane should show a dedicated PRD empty state until the user loads a file or switches into edit mode. If the workspace does not contain a spec, the right pane should show either a generation state or a blocked state that asks for a PRD first. -5. **Review and adjust:** The user can edit either document directly, switch between preview and edit, approve the spec from the spec pane header, and then prepare the execution flow. -6. **Approve and run:** Once the spec is approved, the user can launch the execution dashboard in stepped, milestone, or god mode. -7. **Inspect the result:** The app streams simulated terminal output, shows approval gates, and renders a diff based on the current git state when available. +1. **Open the setup screen:** The app starts on a configuration flow instead of dropping directly into review. +2. **Choose the project folder:** The user picks a workspace folder. If `.specforge/settings.json` already exists, SpecForge loads it immediately. +3. **Review CLI status:** The user sees Claude CLI, Codex CLI, and Git health plus optional machine-local override paths. +4. **Configure AI defaults:** The user chooses the default model/reasoning profile and edits the saved PRD/spec prompt templates for this project. +5. **Configure document locations:** The user sets the PRD path, spec path, and optional supporting document paths relative to the selected workspace, then saves the setup to create or update `.specforge/settings.json`. +6. **Review and adjust:** The review workspace loads the configured PRD/spec files when they exist. Missing files surface dedicated empty states instead of fallback bundled docs. +7. **Approve and run:** Once the spec is approved, the user can launch the execution dashboard in stepped, milestone, or god mode. +8. **Inspect the result:** The app streams simulated terminal output, shows approval gates, and renders a diff based on the current git state when available. ## 4. Functional Requirements ### 4.1. Document Ingestion * **Desktop native picker:** Must support `.md` and `.pdf` imports for PRD and spec documents. -* **Browser file import:** Must support Markdown only and explain that PDF parsing requires the desktop runtime. * **Pane-local controls:** The PRD and spec panes must own their own load actions instead of relying on a separate sidebar ingestion panel. -* **Workspace auto-detection:** When a workspace is opened, the app should try to load: - * `PRD.md`, then `PRD.pdf` - * `spec.md`, then `spec.pdf` -* **Missing document reset:** Opening a workspace must clear stale PRD/spec content from the previous workspace when those files are not found in the new one. -* **PRD empty state:** When the active PRD content is empty in preview mode, the PRD pane must show a dedicated empty state with the same preview/load/edit controls as the normal document header. -* **Empty spec generation:** When the active spec content is empty and a PRD is available, the spec pane must show a textbox and generate button that use the current PRD plus the user's note to draft a markdown spec through the selected desktop AI CLI. +* **Project configuration file:** Saving setup must create or update `.specforge/settings.json` inside the selected workspace. +* **Configured document paths:** The review panes should use the PRD/spec paths stored in `.specforge/settings.json`, not bundled defaults. +* **Missing document reset:** Loading a project must clear stale PRD/spec content when the configured files do not exist yet. +* **PRD empty state:** When the active PRD content is empty in preview mode, the PRD pane must show a dedicated empty state with a textbox, helper copy describing the saved default PRD prompt, and a generate action that appends the textbox note after that saved prompt. +* **Empty spec generation:** When the active spec content is empty and a PRD is available, the spec pane must show a textbox and generate button that append the user's note after the saved default spec prompt and include the current PRD content. * **Blocked spec state:** When both the PRD and spec are empty in preview mode, the spec pane must explain that a PRD is required before generation while still allowing an existing spec to be loaded. -* **Generated spec persistence:** After generation succeeds in the desktop runtime, the markdown must be saved into the same folder as the active PRD using a sibling `SPEC.md` or `spec.md` file before the pane updates. +* **Generated document persistence:** After PRD/spec generation succeeds in the desktop runtime, the markdown must be saved into the configured project-relative Markdown path from `.specforge/settings.json` before the pane updates. ### 4.2. Workspace Review @@ -54,6 +53,7 @@ Today the product focuses on **review, import, diff inspection, and approval UX* * **Environment scan:** The app must surface Claude CLI, Codex CLI, and Git availability plus optional manual override paths. * **Manual override behavior:** A manual path is only considered healthy after the backend successfully probes it as an executable. * **Theme controls:** The workspace must support Dracula, Light, and System themes. +* **Project-scoped AI settings:** Model selection, reasoning profile, PRD prompt, spec prompt, and configured document paths must be saved per project in `.specforge/settings.json`. * **Git diff visibility:** The review diff should include staged, unstaged, and untracked changes when a repository is available. Sample diff content is acceptable only when the repository is effectively clean or when running in browser fallback mode. ### 4.4. Approval and Execution UX diff --git a/docs/SPEC.md b/docs/SPEC.md index f66f4f6..ab0182e 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -32,20 +32,17 @@ The webview must never execute shell commands or arbitrary file reads directly. ## 3. Default State And Stores -### 3.1. Bundled review docs +### 3.1. Setup-first startup -On startup, the app loads: +On startup, the app routes to a project configuration screen. The user selects a workspace folder, and the desktop runtime either loads an existing `.specforge/settings.json` or prepares default project settings that can be saved into that file. -* `docs/PRD.md` -* `docs/SPEC.md` - -These bundled documents are the default contents of the PRD and spec panes until the user imports replacements or opens a workspace that clears one of those documents. +The review workspace no longer boots with bundled `docs/PRD.md` / `docs/SPEC.md` content by default. ### 3.2. Zustand stores -* **`useProjectStore`:** PRD/spec content, approval mode, selected model, selected range, annotations, and open workspace file tabs. +* **`useProjectStore`:** PRD/spec content, approval mode, selected model/reasoning, saved prompt templates, configured document paths, annotations, and open workspace file tabs. * **`useAgentStore`:** Simulated run status, streamed output, current milestone, pending diff, and summary text. -* **`useSettingsStore`:** Theme, CLI override paths, environment scan results, and the current workspace tree entries. +* **`useSettingsStore`:** Theme, CLI override paths, last opened project path, environment scan results, and the current workspace tree entries. ## 4. Import And Workspace Flows @@ -56,35 +53,29 @@ The desktop runtime currently exposes two import paths: * **User-facing import:** `pick_document()` opens a native file picker for `.md` and `.pdf`, parses the chosen file in Rust, and returns a `WorkspaceDocument`. The PRD and spec panes trigger this from their own header controls. * **Reserved path import:** `parse_document(filePath)` still accepts only repository-relative paths that stay inside the project root, but it is not currently surfaced in the main review UI. -### 4.2. Browser import fallback - -Browser mode keeps a file-input fallback: - -* Direct document import supports **Markdown only**. -* Browser-side folder import can discover PRD/spec matches, but PDF parsing is intentionally unavailable there. +### 4.2. Project setup, workspace scan, and file opens -### 4.3. Workspace scan and file opens - -* `open_workspace_folder()` opens a native folder picker, walks the chosen directory with `.gitignore` awareness, and returns workspace entries plus detected PRD/spec documents. +* `pick_project_folder()` opens a native folder picker, walks the chosen directory with `.gitignore` awareness, loads `.specforge/settings.json` when it exists, and returns a project-context payload for the setup flow. +* `load_project_context(folderPath)` reloads an already-known project folder and rehydrates the workspace plus saved project settings. +* `save_project_settings(folderPath, settings)` writes `.specforge/settings.json` inside the selected project. * The backend stores the active workspace root and its relative-path-to-file map in shared state. * `read_workspace_file(filePath)` now treats `filePath` as a **workspace-relative path only** and resolves it through the active workspace map. * Files outside the active workspace must be rejected even if the frontend passes an absolute path or traversal sequence. -* When a scanned workspace does not contain `PRD.md`/`PRD.pdf` or `spec.md`/`spec.pdf`, the frontend must clear the prior document content instead of leaving stale content visible. +* When the configured PRD/spec files do not exist yet, the frontend clears the prior document content instead of leaving stale content visible. ### 4.4. Empty document and spec generation flow * When `prdContent` is empty and the PRD pane is in preview mode, the left pane swaps to a dedicated PRD empty state while preserving preview/load/edit controls in the header. +* The PRD empty state includes a note field, shows the saved default PRD prompt from `.specforge/settings.json`, and explains that the note is appended after that prompt before generation. +* `generate_prd_document(...)` writes Markdown to the configured PRD path inside the workspace. * When `specContent` is empty, the spec pane keeps the same preview/load/edit controls in its header area. * If `specContent` is empty and `prdContent` is present, the spec pane swaps to a dedicated generation state with a prompt textarea and generate button. * If both `prdContent` and `specContent` are empty in preview mode, the spec pane shows a blocked state that asks for a PRD before generation while still allowing `Load Spec`. -* The generate action sends the current PRD, the user's note, the selected model, and the selected reasoning profile through `src/lib/runtime.ts`. -* `generate_spec_document(...)` runs the selected Claude CLI or Codex CLI in non-interactive mode from a temporary folder, resolves the active PRD path, and writes the returned markdown into a sibling `SPEC.md`/`spec.md` file beside that PRD. +* The spec empty state shows the saved default spec prompt from `.specforge/settings.json` and explains that the note is appended after that prompt before generation. +* The generate actions send the current prompt template, note, selected model, selected reasoning profile, and configured output path through `src/lib/runtime.ts`. +* `generate_spec_document(...)` runs the selected Claude CLI or Codex CLI in non-interactive mode from a temporary folder and writes the returned markdown into the configured spec path inside the workspace. * The saved spec document metadata is returned to the frontend so the spec pane reflects the on-disk path immediately; execution remains a separate simulated flow. -### 4.5. Browser `.gitignore` behavior - -Browser folder imports normalize root-prefixed paths and apply root plus nested `.gitignore` rules before building the workspace tree. - ## 5. Tauri Command Surface The current Tauri commands are: @@ -92,11 +83,15 @@ The current Tauri commands are: * `run_environment_scan(claudePath?: string, codexPath?: string)` * `parse_document(filePath: string)` * `pick_document()` +* `pick_project_folder()` +* `load_project_context(folderPath: string)` +* `save_project_settings(folderPath: string, settings: ProjectSettings)` * `open_workspace_folder()` * `read_workspace_file(filePath: string)` * `get_workspace_snapshot()` * `git_get_diff()` -* `generate_spec_document(prdPath: string, prdContent: string, userPrompt: string, provider: string, model: string, reasoning: string, claudePath?: string, codexPath?: string)` +* `generate_prd_document(workspaceRoot: string, outputPath: string, promptTemplate: string, userPrompt: string, provider: string, model: string, reasoning: string, claudePath?: string, codexPath?: string)` +* `generate_spec_document(workspaceRoot: string, outputPath: string, prdContent: string, promptTemplate: string, userPrompt: string, provider: string, model: string, reasoning: string, claudePath?: string, codexPath?: string)` * `spawn_cli_agent(specPayload: string, mode: string, model: string, reasoning: string)` * `approve_action()` * `kill_agent_process()` @@ -126,18 +121,19 @@ The current execution runtime is **simulated**: This is a review-and-approval shell, not a real CLI orchestration engine yet. -The spec generation flow is separate: it uses the configured Claude/Codex CLI to draft markdown, saves that markdown next to the active PRD, and loads the saved file into the spec pane. It does not replace the simulated execution loop. +The PRD/spec generation flows are separate from execution: they use the configured Claude/Codex CLI to draft markdown, save that markdown to the configured project-relative Markdown targets, and load the saved file into the review pane. They do not replace the simulated execution loop. ## 7. Environment And Settings * CLI health is derived from executable probing, not just path existence. * Manual override paths can be relative to the repo or absolute on disk. -* Theme preference is stored in browser local storage and resolved into Dracula, Light, or System behavior in the webview. +* Theme preference plus CLI override paths are stored in browser local storage. +* The last opened project path is stored in browser local storage so the desktop app can restore project setup on the next launch. +* Project-specific model/reasoning defaults, prompt templates, and document paths are stored in `.specforge/settings.json` inside the selected workspace. * The review sidebar now presents only agent configuration controls plus an MCP summary list derived from the current runtime/tool health. ## 8. Known Limits * Opened workspace file tabs are editable in-memory only; there is no save-to-disk flow. -* Browser mode does not parse PDFs. -* Browser mode does not support AI-backed spec generation. +* The current project-setup flow expects the desktop runtime for real `.specforge/settings.json` persistence. * The app presents model and approval controls, but the current run loop is simulated rather than connected to real workspace-mutating Claude/Codex execution. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 76c063c..a9bf34d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,7 @@ use git2::{DiffFormat, DiffOptions, Repository}; use ignore::WalkBuilder; use lopdf::Document; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, fs, @@ -73,6 +73,32 @@ struct WorkspaceDocument { file_name: String, } +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProjectSettings { + selected_model: String, + selected_reasoning: String, + prd_prompt: String, + spec_prompt: String, + prd_path: String, + spec_path: String, + supporting_document_paths: Vec, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProjectContextPayload { + root_name: String, + root_path: String, + settings_path: String, + has_saved_settings: bool, + settings: ProjectSettings, + entries: Vec, + ignored_file_count: usize, + prd_document: Option, + spec_document: Option, +} + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct WorkspaceScanResult { @@ -125,6 +151,36 @@ index 0000000..forge42 100644 + Introduce PRD/spec review workspace with execution controls + Add Dracula-first theme tokens and persisted preferences + Surface CLI health, diff approvals, and terminal streaming"#; +const SPECFORGE_SETTINGS_RELATIVE_PATH: &str = ".specforge/settings.json"; +const DEFAULT_PROJECT_PRD_PATH: &str = "docs/PRD.md"; +const DEFAULT_PROJECT_SPEC_PATH: &str = "docs/SPEC.md"; +const DEFAULT_PRD_PROMPT: &str = r#"Act as an Expert Senior Product Manager. Your goal is to help me write a comprehensive, well-structured Product Requirements Document (PRD) for a new [product / feature / app] called [Project Name]. + +I have some initial ideas, but I want to make sure the PRD is thorough. Before you draft the full document, please ask me a series of clarifying questions to gather the necessary context. + +Please ask about: +- The core problem we are solving +- The target audience/user personas +- Key features and user flows +- Success metrics (KPIs) +- Technical or timeline constraints + +Ask me these questions one or two at a time so I do not get overwhelmed. Once you have enough context, we will move on to drafting the actual PRD."#; +const DEFAULT_SPEC_PROMPT: &str = r#"Act as an Expert Software Architect and Tech Lead. I have attached the Product Requirements Document (PRD) for our upcoming project. + +Your task is to analyze this PRD and draft a comprehensive Technical Specification Document. + +Please structure the spec with the following sections: + +1. High-Level Architecture: A conceptual overview of how the system components will interact. +2. Tech Stack & Tooling: Define the frontend, backend, and infrastructure. +3. Data Models & Database Schema: Define the core entities, their attributes, and relationships. +4. API Contracts: Outline the primary endpoints (methods, routes, request/response structures) needed to support the user flows. +5. Component & State Management: How data will flow through the application and how the UI will be structured. +6. Security & Edge Cases: Potential vulnerabilities, error handling, and performance bottlenecks. +7. Engineering Milestones: Break the implementation down into logical, phased deliverables. + +Before writing the full document, please provide a brief bulleted summary of your proposed technical approach, and ask me up to 3 clarifying questions about any technical constraints or non-functional requirements that might be missing from the PRD."#; #[tauri::command] fn run_environment_scan( @@ -170,6 +226,74 @@ fn pick_document() -> Result, String> { })) } +#[tauri::command] +fn pick_project_folder(state: State) -> Result, String> { + let Some(folder_path) = rfd::FileDialog::new().pick_folder() else { + return Ok(None); + }; + + load_project_context_from_folder(&state, &folder_path).map(Some) +} + +#[tauri::command] +fn load_project_context( + state: State, + folder_path: String, +) -> Result { + let trimmed_path = folder_path.trim(); + + if trimmed_path.is_empty() { + return Err(String::from("A workspace folder path is required.")); + } + + load_project_context_from_folder(&state, &PathBuf::from(trimmed_path)) +} + +#[tauri::command] +fn save_project_settings( + folder_path: String, + settings: ProjectSettings, +) -> Result { + let trimmed_path = folder_path.trim(); + + if trimmed_path.is_empty() { + return Err(String::from("A workspace folder path is required.")); + } + + let workspace_root = + canonicalize_existing_path(&PathBuf::from(trimmed_path)).map_err(|error| { + format!( + "Unable to resolve the selected workspace folder {}: {error}", + trimmed_path + ) + })?; + let default_settings = build_default_project_settings(&workspace_root, None, None); + let normalized_settings = + normalize_project_settings(&workspace_root, default_settings, Some(settings))?; + let settings_path = workspace_root.join(SPECFORGE_SETTINGS_RELATIVE_PATH); + let settings_directory = settings_path + .parent() + .ok_or_else(|| String::from("Unable to resolve the .specforge directory."))?; + + fs::create_dir_all(settings_directory).map_err(|error| { + format!( + "Unable to create the project settings directory {}: {error}", + settings_directory.display() + ) + })?; + let settings_json = serde_json::to_string_pretty(&normalized_settings) + .map_err(|error| format!("Unable to encode project settings: {error}"))?; + + fs::write(&settings_path, settings_json.as_bytes()).map_err(|error| { + format!( + "Unable to write project settings to {}: {error}", + settings_path.display() + ) + })?; + + Ok(normalized_settings) +} + #[tauri::command] fn open_workspace_folder(state: State) -> Result, String> { let Some(folder_path) = rfd::FileDialog::new().pick_folder() else { @@ -369,6 +493,235 @@ fn scan_workspace_folder(root: &Path) -> Result { }) } +fn load_project_context_from_folder( + state: &State, + folder_path: &Path, +) -> Result { + let scanned_workspace = scan_workspace_folder(folder_path)?; + let settings_path = scanned_workspace + .context + .root + .join(SPECFORGE_SETTINGS_RELATIVE_PATH); + let default_settings = build_default_project_settings( + &scanned_workspace.context.root, + scanned_workspace.result.prd_document.as_ref(), + scanned_workspace.result.spec_document.as_ref(), + ); + let (settings, has_saved_settings) = read_project_settings( + &settings_path, + &scanned_workspace.context.root, + default_settings, + )?; + let prd_document = + load_configured_workspace_document(&scanned_workspace.context.root, &settings.prd_path)?; + let spec_document = + load_configured_workspace_document(&scanned_workspace.context.root, &settings.spec_path)?; + let mut active_workspace = state + .workspace + .lock() + .map_err(|_| String::from("Workspace lock was poisoned."))?; + *active_workspace = Some(scanned_workspace.context); + + Ok(ProjectContextPayload { + root_name: scanned_workspace.result.root_name, + root_path: active_workspace + .as_ref() + .map(|workspace| workspace.root.display().to_string()) + .unwrap_or_default(), + settings_path: settings_path.display().to_string(), + has_saved_settings, + settings, + entries: scanned_workspace.result.entries, + ignored_file_count: scanned_workspace.result.ignored_file_count, + prd_document, + spec_document, + }) +} + +fn build_default_project_settings( + workspace_root: &Path, + prd_document: Option<&WorkspaceDocument>, + spec_document: Option<&WorkspaceDocument>, +) -> ProjectSettings { + ProjectSettings { + selected_model: String::from("gpt-5.4"), + selected_reasoning: String::from("medium"), + prd_prompt: String::from(DEFAULT_PRD_PROMPT), + spec_prompt: String::from(DEFAULT_SPEC_PROMPT), + prd_path: derive_default_document_path( + workspace_root, + prd_document, + DEFAULT_PROJECT_PRD_PATH, + ), + spec_path: derive_default_document_path( + workspace_root, + spec_document, + DEFAULT_PROJECT_SPEC_PATH, + ), + supporting_document_paths: Vec::new(), + } +} + +fn read_project_settings( + settings_path: &Path, + workspace_root: &Path, + defaults: ProjectSettings, +) -> Result<(ProjectSettings, bool), String> { + if !settings_path.exists() { + return Ok((defaults, false)); + } + + let raw_settings = fs::read_to_string(settings_path).map_err(|error| { + format!( + "Unable to read project settings {}: {error}", + settings_path.display() + ) + })?; + let parsed_settings = + serde_json::from_str::(&raw_settings).map_err(|error| { + format!( + "Unable to parse project settings {}: {error}", + settings_path.display() + ) + })?; + + Ok(( + normalize_project_settings(workspace_root, defaults, Some(parsed_settings))?, + true, + )) +} + +fn normalize_project_settings( + workspace_root: &Path, + defaults: ProjectSettings, + provided: Option, +) -> Result { + let Some(provided) = provided else { + return Ok(defaults); + }; + + let selected_model = + normalize_project_model(&provided.selected_model, &defaults.selected_model); + let selected_reasoning = + normalize_project_reasoning(&provided.selected_reasoning, &defaults.selected_reasoning); + let normalized_prd_path = + normalize_project_path_or_default(workspace_root, &provided.prd_path, &defaults.prd_path)?; + let normalized_spec_path = normalize_project_path_or_default( + workspace_root, + &provided.spec_path, + &defaults.spec_path, + )?; + let supporting_document_paths = provided + .supporting_document_paths + .iter() + .filter_map(|entry| normalize_relative_path(entry).ok()) + .collect::>(); + + Ok(ProjectSettings { + selected_model, + selected_reasoning, + prd_prompt: if provided.prd_prompt.trim().is_empty() { + defaults.prd_prompt + } else { + provided.prd_prompt.trim().to_string() + }, + spec_prompt: if provided.spec_prompt.trim().is_empty() { + defaults.spec_prompt + } else { + provided.spec_prompt.trim().to_string() + }, + prd_path: normalized_prd_path, + spec_path: normalized_spec_path, + supporting_document_paths, + }) +} + +fn normalize_project_model(value: &str, fallback: &str) -> String { + const VALID_MODELS: &[&str] = &[ + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.3-codex", + "gpt-5.2", + "claude-opus-4-1-20250805", + "claude-opus-4-20250514", + "claude-sonnet-4-20250514", + "claude-3-7-sonnet-20250219", + "claude-3-5-sonnet-20241022", + "claude-3-5-sonnet-20240620", + "claude-3-5-haiku-20241022", + "claude-3-haiku-20240307", + ]; + + if VALID_MODELS.contains(&value.trim()) { + return value.trim().to_string(); + } + + fallback.to_string() +} + +fn normalize_project_reasoning(value: &str, fallback: &str) -> String { + match value.trim() { + "low" | "medium" | "high" | "max" => value.trim().to_string(), + _ => fallback.to_string(), + } +} + +fn normalize_project_path_or_default( + workspace_root: &Path, + value: &str, + fallback: &str, +) -> Result { + if value.trim().is_empty() { + return Ok(fallback.to_string()); + } + + normalize_relative_path(value).map_err(|error| { + format!( + "Invalid project document path for workspace {}: {error}", + workspace_root.display() + ) + }) +} + +fn derive_default_document_path( + workspace_root: &Path, + document: Option<&WorkspaceDocument>, + fallback: &str, +) -> String { + document + .and_then(|entry| { + PathBuf::from(&entry.source_path) + .strip_prefix(workspace_root) + .ok() + .map(|path| path.to_string_lossy().replace('\\', "/")) + }) + .unwrap_or_else(|| fallback.to_string()) +} + +fn load_configured_workspace_document( + workspace_root: &Path, + relative_path: &str, +) -> Result, String> { + let resolved_path = resolve_relative_path_under_root(workspace_root, relative_path)?; + + if !resolved_path.exists() { + return Ok(None); + } + + let content = parse_workspace_document(&resolved_path)?; + let file_name = resolved_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("Document") + .to_string(); + + Ok(Some(WorkspaceDocument { + content, + source_path: resolved_path.display().to_string(), + file_name, + })) +} + #[tauri::command] fn git_get_diff() -> Result { let repository = Repository::discover(project_root()) @@ -408,10 +761,50 @@ fn git_get_diff() -> Result { Ok(rendered) } +#[tauri::command] +fn generate_prd_document( + workspace_root: String, + output_path: String, + prompt_template: String, + user_prompt: String, + provider: String, + model: String, + reasoning: String, + claude_path: Option, + codex_path: Option, +) -> Result { + let trimmed_prompt = user_prompt.trim(); + + if trimmed_prompt.is_empty() { + return Err(String::from( + "Add the product context you want the AI to consider.", + )); + } + + let prompt_payload = build_generation_prompt(&prompt_template, trimmed_prompt, &[]); + let generated_prd = run_generation_request( + &provider, + &model, + &reasoning, + claude_path.as_deref(), + codex_path.as_deref(), + &prompt_payload, + )?; + + write_generated_workspace_document( + &workspace_root, + &output_path, + generated_prd, + "PRD output path", + ) +} + #[tauri::command] fn generate_spec_document( - prd_path: String, + workspace_root: String, + output_path: String, prd_content: String, + prompt_template: String, user_prompt: String, provider: String, model: String, @@ -434,53 +827,26 @@ fn generate_spec_document( )); } - let prompt_payload = build_spec_generation_prompt(trimmed_prd, trimmed_prompt); - let generated_spec = match provider.as_str() { - "codex" => run_codex_spec_generation( - &resolve_cli_binary("codex", codex_path.as_deref())?, - &model, - &reasoning, - &prompt_payload, - )?, - "claude" => run_claude_spec_generation( - &resolve_cli_binary("claude", claude_path.as_deref())?, - &model, - &reasoning, - &prompt_payload, - )?, - _ => return Err(format!("Unsupported model provider: {provider}")), - }; - - let normalized_spec = strip_wrapping_code_fence(generated_spec.trim()); - let rendered_spec = format!("{}\n", normalized_spec.trim()); - - if rendered_spec.trim().is_empty() { - return Err(String::from( - "The AI returned an empty specification. Adjust the prompt and try again.", - )); - } - - let resolved_prd_path = resolve_existing_document_path(&prd_path)?; - let saved_spec_path = build_generated_spec_path(&resolved_prd_path); - - fs::write(&saved_spec_path, rendered_spec.as_bytes()).map_err(|error| { - format!( - "Unable to save the generated spec to {}: {error}", - saved_spec_path.display() - ) - })?; - - let file_name = saved_spec_path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("SPEC.md") - .to_string(); - - Ok(WorkspaceDocument { - content: rendered_spec, - source_path: saved_spec_path.display().to_string(), - file_name, - }) + let prompt_payload = build_generation_prompt( + &prompt_template, + trimmed_prompt, + &[("Attached Product Requirements Document (PRD)", trimmed_prd)], + ); + let generated_spec = run_generation_request( + &provider, + &model, + &reasoning, + claude_path.as_deref(), + codex_path.as_deref(), + &prompt_payload, + )?; + + write_generated_workspace_document( + &workspace_root, + &output_path, + generated_spec, + "SPEC output path", + ) } #[tauri::command] @@ -543,10 +909,14 @@ pub fn run() { run_environment_scan, parse_document, pick_document, + pick_project_folder, + load_project_context, + save_project_settings, open_workspace_folder, read_workspace_file, get_workspace_snapshot, git_get_diff, + generate_prd_document, generate_spec_document, spawn_cli_agent, approve_action, @@ -790,54 +1160,74 @@ fn resolve_workspace_file_path( Ok(canonical_path) } -fn resolve_existing_document_path(path_value: &str) -> Result { - let trimmed_value = path_value.trim(); +fn resolve_relative_path_under_root(root: &Path, relative_path: &str) -> Result { + let normalized_path = normalize_relative_path(relative_path)?; + Ok(root.join(normalized_path)) +} - if trimmed_value.is_empty() { +fn write_generated_workspace_document( + workspace_root: &str, + output_path: &str, + generated_content: String, + field_name: &str, +) -> Result { + let trimmed_root = workspace_root.trim(); + + if trimmed_root.is_empty() { + return Err(String::from("A workspace root is required.")); + } + + let canonical_root = canonicalize_existing_path(&PathBuf::from(trimmed_root)) + .map_err(|error| format!("Unable to resolve workspace root {}: {error}", trimmed_root))?; + let resolved_output_path = resolve_relative_path_under_root(&canonical_root, output_path) + .map_err(|error| format!("{field_name} is invalid: {error}"))?; + let rendered_document = format!( + "{}\n", + strip_wrapping_code_fence(generated_content.trim()).trim() + ); + + if rendered_document.trim().is_empty() { return Err(String::from( - "A PRD path is required to save the generated spec.", + "The AI returned an empty document. Adjust the prompt and try again.", )); } - let candidate = PathBuf::from(trimmed_value); + if resolved_output_path + .extension() + .and_then(|value| value.to_str()) + .map(|value| !value.eq_ignore_ascii_case("md")) + .unwrap_or(true) + { + return Err(format!( + "{field_name} must point to a Markdown file inside the selected workspace." + )); + } - if candidate.is_absolute() { - return canonicalize_existing_path(&candidate).map_err(|error| { + if let Some(parent_directory) = resolved_output_path.parent() { + fs::create_dir_all(parent_directory).map_err(|error| { format!( - "Unable to resolve PRD path {}: {error}", - candidate.display() + "Unable to create the document folder {}: {error}", + parent_directory.display() ) - }); + })?; } - resolve_project_document_path(trimmed_value) -} - -fn build_generated_spec_path(prd_path: &Path) -> PathBuf { - let file_name = prd_path - .file_name() - .and_then(|value| value.to_str()) - .map(derive_spec_file_name) - .unwrap_or_else(|| String::from("SPEC.md")); - - prd_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .join(file_name) -} - -fn derive_spec_file_name(prd_file_name: &str) -> String { - let normalized = prd_file_name.to_ascii_lowercase(); - - if normalized == "prd.md" || normalized == "prd.pdf" { - return if prd_file_name == prd_file_name.to_ascii_lowercase() { - String::from("spec.md") - } else { - String::from("SPEC.md") - }; - } + fs::write(&resolved_output_path, rendered_document.as_bytes()).map_err(|error| { + format!( + "Unable to save the generated document to {}: {error}", + resolved_output_path.display() + ) + })?; - String::from("SPEC.md") + Ok(WorkspaceDocument { + content: rendered_document, + source_path: resolved_output_path.display().to_string(), + file_name: resolved_output_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("Document.md") + .to_string(), + }) } fn resolve_override_path(path_value: &str) -> PathBuf { @@ -957,25 +1347,59 @@ fn resolve_cli_binary(binary_name: &str, override_path: Option<&str>) -> Result< }) } -fn build_spec_generation_prompt(prd_content: &str, user_prompt: &str) -> String { - format!( - concat!( - "You are drafting a technical specification document from a PRD and operator notes.\n", - "Return only the final markdown for the SPEC document.\n", - "Do not use shell commands, tools, or external files. Work only from the provided text.\n", - "Use concrete technical detail, clear section headings, and explicit assumptions when the PRD leaves a gap.\n", - "Include architecture, workflows, data/contracts, constraints, edge cases, and acceptance criteria when relevant.\n\n", - "# Operator Notes\n", - "{user_prompt}\n\n", - "# PRD\n", - "{prd_content}\n" +fn build_generation_prompt( + prompt_template: &str, + user_prompt: &str, + attachments: &[(&str, &str)], +) -> String { + let mut prompt = String::new(); + prompt.push_str(prompt_template.trim()); + prompt.push_str("\n\n"); + prompt.push_str("Additional operator context:\n"); + prompt.push_str(user_prompt.trim()); + + for (label, content) in attachments { + let trimmed_content = content.trim(); + + if trimmed_content.is_empty() { + continue; + } + + prompt.push_str("\n\n"); + prompt.push_str(label); + prompt.push_str(":\n"); + prompt.push_str(trimmed_content); + } + + prompt +} + +fn run_generation_request( + provider: &str, + model: &str, + reasoning: &str, + claude_path: Option<&str>, + codex_path: Option<&str>, + prompt_payload: &str, +) -> Result { + match provider { + "codex" => run_codex_generation( + &resolve_cli_binary("codex", codex_path)?, + model, + reasoning, + prompt_payload, ), - user_prompt = user_prompt, - prd_content = prd_content - ) + "claude" => run_claude_generation( + &resolve_cli_binary("claude", claude_path)?, + model, + reasoning, + prompt_payload, + ), + _ => Err(format!("Unsupported model provider: {provider}")), + } } -fn run_codex_spec_generation( +fn run_codex_generation( binary_path: &Path, model: &str, reasoning: &str, @@ -1030,7 +1454,7 @@ fn run_codex_spec_generation( result } -fn run_claude_spec_generation( +fn run_claude_generation( binary_path: &Path, model: &str, reasoning: &str, @@ -1044,9 +1468,7 @@ fn run_claude_spec_generation( .stdout(Stdio::piped()) .stderr(Stdio::piped()) .arg("--print") - .arg( - "Using the piped PRD and operator notes, write a complete technical specification in markdown and return only the specification.", - ) + .arg("Respond to the request provided on stdin.") .arg("--model") .arg(model) .arg("--output-format") diff --git a/src/App.tsx b/src/App.tsx index 0dc6d75..b5771f1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { - useCallback, startTransition, + useCallback, useDeferredValue, useEffect, useMemo, @@ -12,74 +12,79 @@ import { Navigate, Route, Routes, - useLocation + useLocation, + useNavigate } from "react-router-dom"; -import bundledPrd from "../docs/PRD.md?raw"; -import bundledSpec from "../docs/SPEC.md?raw"; import { AppRail } from "./components/AppRail"; import { - DocumentTarget, FallbackStep, WorkspaceFileSource, - WorkspaceSelectionPayload, buildFallbackSteps, clearFallbackTimer, - collectWorkspaceFiles, filterWorkspaceEntries, - getDirectoryPicker, - isDirectoryPickerAbort, isOpenableWorkspacePath, - normalizeWorkspacePath, resolveTheme, runFallbackStep, - stampLog + stampLog, + type DocumentTarget } from "./lib/appShell"; import { getModelLabel, - getModelOptions, getModelProvider, getReasoningLabel } from "./lib/agentConfig"; +import { + DEFAULT_PROJECT_PRD_PATH, + DEFAULT_PROJECT_SPEC_PATH, + SPECFORGE_SETTINGS_RELATIVE_PATH, + formatSupportingDocumentPaths, + normalizeProjectRelativePath, + normalizeProjectSettings, + parseSupportingDocumentPaths +} from "./lib/projectConfig"; import { DEFAULT_PENDING_DIFF, approveAgentAction, emergencyStop, + generatePrdDocument, generateSpecDocument, getGitDiff, getWorkspaceSnapshot, isTauriRuntime, - openWorkspaceFolder, - parseDocument, + loadProjectContext, pickDocument, + pickProjectFolder, readWorkspaceFile, runEnvironmentScan, + saveProjectSettings, startAgentRun, subscribeToAgentEvents } from "./lib/runtime"; import { - buildWorkspaceImportSnapshot, - filterWorkspaceFiles, - findProjectDocuments, isOpenableTextFile, parseWorkspaceDocument, parseWorkspaceTextFile, type ImportableFile } from "./lib/workspaceImport"; +import { ConfigurationScreen } from "./screens/ConfigurationScreen"; +import { PrdScreen } from "./screens/PrdScreen"; +import { SettingsScreen } from "./screens/SettingsScreen"; import { useAgentStore } from "./store/useAgentStore"; import { useProjectStore } from "./store/useProjectStore"; import { useSettingsStore } from "./store/useSettingsStore"; -import { PrdScreen } from "./screens/PrdScreen"; -import { SettingsScreen } from "./screens/SettingsScreen"; import type { EnvironmentStatus, - ModelProvider + ModelProvider, + ProjectContext } from "./types"; function App() { const location = useLocation(); - const isSettingsRoute = location.pathname === "/settings"; + const navigate = useNavigate(); + const isReviewRoute = location.pathname === "/review"; const desktopRuntime = isTauriRuntime(); + const agentStatus = useAgentStore((state) => state.status); const terminalOutput = useAgentStore((state) => state.terminalOutput); const pendingDiff = useAgentStore((state) => state.pendingDiff); @@ -95,61 +100,80 @@ function App() { const annotations = useProjectStore((state) => state.annotations); const activeTab = useProjectStore((state) => state.activeTab); const autonomyMode = useProjectStore((state) => state.autonomyMode); + const configuredPrdPath = useProjectStore((state) => state.configuredPrdPath); + const configuredSpecPath = useProjectStore((state) => state.configuredSpecPath); const isSpecApproved = useProjectStore((state) => state.isSpecApproved); const openEditorTabs = useProjectStore((state) => state.openEditorTabs); const prdContent = useProjectStore((state) => state.prdContent); const prdPaneMode = useProjectStore((state) => state.prdPaneMode); const prdPath = useProjectStore((state) => state.prdPath); - const reviewPrompt = useProjectStore((state) => state.reviewPrompt); + const prdPromptTemplate = useProjectStore((state) => state.prdPromptTemplate); const selectedModel = useProjectStore((state) => state.selectedModel); const selectedReasoning = useProjectStore((state) => state.selectedReasoning); const selectedSpecRange = useProjectStore((state) => state.selectedSpecRange); const specContent = useProjectStore((state) => state.specContent); const specPaneMode = useProjectStore((state) => state.specPaneMode); const specPath = useProjectStore((state) => state.specPath); + const specPromptTemplate = useProjectStore((state) => state.specPromptTemplate); + const supportingDocumentPaths = useProjectStore((state) => state.supportingDocumentPaths); const approveSpec = useProjectStore((state) => state.approveSpec); - const applyRefinement = useProjectStore((state) => state.applyRefinement); const closeEditorTab = useProjectStore((state) => state.closeEditorTab); const openEditorTab = useProjectStore((state) => state.openEditorTab); const resetWorkspaceContext = useProjectStore((state) => state.resetWorkspaceContext); const setActiveTab = useProjectStore((state) => state.setActiveTab); const setAutonomyMode = useProjectStore((state) => state.setAutonomyMode); + const setConfiguredPrdPath = useProjectStore((state) => state.setConfiguredPrdPath); + const setConfiguredSpecPath = useProjectStore((state) => state.setConfiguredSpecPath); const setPrdContent = useProjectStore((state) => state.setPrdContent); const setPrdPaneMode = useProjectStore((state) => state.setPrdPaneMode); + const setPrdPromptTemplate = useProjectStore((state) => state.setPrdPromptTemplate); + const setProjectSettings = useProjectStore((state) => state.setProjectSettings); const setReasoningProfile = useProjectStore((state) => state.setReasoningProfile); - const setReviewPrompt = useProjectStore((state) => state.setReviewPrompt); const setSelectedModel = useProjectStore((state) => state.setSelectedModel); const setSelectedSpecRange = useProjectStore((state) => state.setSelectedSpecRange); const setSpecContent = useProjectStore((state) => state.setSpecContent); const setSpecPaneMode = useProjectStore((state) => state.setSpecPaneMode); + const setSpecPromptTemplate = useProjectStore((state) => state.setSpecPromptTemplate); + const setSupportingDocumentPaths = useProjectStore((state) => state.setSupportingDocumentPaths); const updateEditorTabContent = useProjectStore((state) => state.updateEditorTabContent); const claudePath = useSettingsStore((state) => state.claudePath); const codexPath = useSettingsStore((state) => state.codexPath); const environment = useSettingsStore((state) => state.environment); + const lastProjectPath = useSettingsStore((state) => state.lastProjectPath); const theme = useSettingsStore((state) => state.theme); const workspaceEntries = useSettingsStore((state) => state.workspaceEntries); const setClaudePath = useSettingsStore((state) => state.setClaudePath); const setCodexPath = useSettingsStore((state) => state.setCodexPath); const setEnvironment = useSettingsStore((state) => state.setEnvironment); + const setLastProjectPath = useSettingsStore((state) => state.setLastProjectPath); const setTheme = useSettingsStore((state) => state.setTheme); const setWorkspaceEntries = useSettingsStore((state) => state.setWorkspaceEntries); + const [commandSearch, setCommandSearch] = useState(""); - const [importPath, setImportPath] = useState("docs/PRD.md"); - const [importTarget, setImportTarget] = useState("prd"); - const [importError, setImportError] = useState(""); const [isImporting, setIsImporting] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false); + const [isProjectLoading, setIsProjectLoading] = useState(false); + const [isProjectSaving, setIsProjectSaving] = useState(false); const [latestDiff, setLatestDiff] = useState(DEFAULT_PENDING_DIFF); - const [systemPrefersDark, setSystemPrefersDark] = useState(true); - const [workspaceRootName, setWorkspaceRootName] = useState("SpecForge"); + const [projectConfigPath, setProjectConfigPath] = useState(""); + const [projectErrorMessage, setProjectErrorMessage] = useState(""); + const [projectRootName, setProjectRootName] = useState("No project selected"); + const [projectRootPath, setProjectRootPath] = useState(""); + const [projectStatusMessage, setProjectStatusMessage] = useState(""); const [workspaceNotice, setWorkspaceNotice] = useState( - "Open a folder to scan for PRD/spec files and build the workspace tree." + "Finish the setup flow to load a project workspace." ); - const [hasOpenedWorkspaceFolder, setHasOpenedWorkspaceFolder] = useState(false); + const [hasSavedProjectSettings, setHasSavedProjectSettings] = useState(false); + const [hasSelectedProject, setHasSelectedProject] = useState(false); + const [hasAttemptedProjectRestore, setHasAttemptedProjectRestore] = useState(!desktopRuntime); + const [systemPrefersDark, setSystemPrefersDark] = useState(true); const [workspaceFiles, setWorkspaceFiles] = useState>({}); + const [prdGenerationPrompt, setPrdGenerationPrompt] = useState(""); + const [prdGenerationError, setPrdGenerationError] = useState(""); const [specGenerationPrompt, setSpecGenerationPrompt] = useState(""); const [specGenerationError, setSpecGenerationError] = useState(""); + const searchInputRef = useRef(null); const fileInputRef = useRef(null); const folderInputRef = useRef(null); @@ -157,9 +181,11 @@ function App() { const fallbackTimerRef = useRef(null); const fallbackStepsRef = useRef([]); const fallbackIndexRef = useRef(0); - const hasInitializedDocumentsRef = useRef(false); const hasScannedEnvironmentRef = useRef(false); + const projectSaveTimerRef = useRef(null); + const pendingProjectReloadRef = useRef(false); const deferredSearch = useDeferredValue(commandSearch); + const filteredWorkspaceEntries = useMemo( () => filterWorkspaceEntries(workspaceEntries, deferredSearch), [deferredSearch, workspaceEntries] @@ -168,10 +194,7 @@ function App() { () => getModelProvider(selectedModel), [selectedModel] ); - const selectedSpecText = useMemo( - () => selectedSpecRange?.text.trim() || "", - [selectedSpecRange] - ); + const isGeneratingPrd = agentStatus === "generating_prd"; const isGeneratingSpec = agentStatus === "generating_spec"; const visibleDiff = pendingDiff ?? latestDiff; const resolvedTheme = useMemo( @@ -193,55 +216,155 @@ function App() { }, [environment.claude.status, environment.codex.status]); const mcpItems = useMemo( () => [ - { - name: environment.codex.name, - detail: environment.codex.detail, - status: environment.codex.status - }, - { - name: environment.claude.name, - detail: environment.claude.detail, - status: environment.claude.status - }, - { - name: environment.git.name, - detail: environment.git.detail, - status: environment.git.status - } + { name: environment.codex.name, detail: environment.codex.detail, status: environment.codex.status }, + { name: environment.claude.name, detail: environment.claude.detail, status: environment.claude.status }, + { name: environment.git.name, detail: environment.git.detail, status: environment.git.status } ], [environment] ); - const selectedProviderStatus = selectedModelProvider === "claude" ? environment.claude : environment.codex; + const selectedProviderStatus = + selectedModelProvider === "claude" ? environment.claude : environment.codex; + const currentProjectSettings = useMemo( + () => + normalizeProjectSettings({ + selectedModel, + selectedReasoning, + prdPrompt: prdPromptTemplate, + specPrompt: specPromptTemplate, + prdPath: configuredPrdPath || DEFAULT_PROJECT_PRD_PATH, + specPath: configuredSpecPath || DEFAULT_PROJECT_SPEC_PATH, + supportingDocumentPaths + }), + [ + configuredPrdPath, + configuredSpecPath, + prdPromptTemplate, + selectedModel, + selectedReasoning, + specPromptTemplate, + supportingDocumentPaths + ] + ); + const configPathDisplay = useMemo(() => { + if (projectConfigPath.trim()) { + return projectConfigPath; + } + + if (projectRootPath.trim()) { + return `${projectRootPath.replace(/\\/g, "/")}/${SPECFORGE_SETTINGS_RELATIVE_PATH}`; + } + + return SPECFORGE_SETTINGS_RELATIVE_PATH; + }, [projectConfigPath, projectRootPath]); + const supportingDocumentsValue = useMemo( + () => formatSupportingDocumentPaths(supportingDocumentPaths), + [supportingDocumentPaths] + ); + + const canGeneratePrd = useMemo( + () => + desktopRuntime && + !isGeneratingPrd && + projectRootPath.trim().length > 0 && + configuredPrdPath.trim().length > 0 && + prdGenerationPrompt.trim().length > 0, + [ + configuredPrdPath, + desktopRuntime, + isGeneratingPrd, + prdGenerationPrompt, + projectRootPath + ] + ); const canGenerateSpec = useMemo( () => desktopRuntime && !isGeneratingSpec && + projectRootPath.trim().length > 0 && prdContent.trim().length > 0 && + configuredSpecPath.trim().length > 0 && specGenerationPrompt.trim().length > 0, - [desktopRuntime, isGeneratingSpec, prdContent, specGenerationPrompt] + [ + configuredSpecPath, + desktopRuntime, + isGeneratingSpec, + prdContent, + projectRootPath, + specGenerationPrompt + ] ); + const prdGenerationHelperText = useMemo(() => { + if (!desktopRuntime) { + return "AI PRD generation requires the desktop runtime."; + } + + if (!projectRootPath.trim()) { + return "Choose a project folder in setup before generating a PRD."; + } + + if (!configuredPrdPath.trim()) { + return "Configure a PRD path in setup or settings first."; + } + + if (!configuredPrdPath.toLowerCase().endsWith(".md")) { + return "Configure the PRD path as a Markdown file if you want generated output saved into the workspace."; + } + + if (!prdGenerationPrompt.trim()) { + return "Add the product context you want to append after the saved PRD prompt."; + } + + if (selectedProviderStatus.status !== "found") { + return `${selectedProviderStatus.name} is not currently marked ready. Update its path in Settings and refresh if generation fails.`; + } + + return `This appends your note after the saved PRD prompt from ${configPathDisplay}, runs ${getModelLabel(selectedModel)}, and writes markdown to ${configuredPrdPath}.`; + }, [ + configPathDisplay, + configuredPrdPath, + desktopRuntime, + prdGenerationPrompt, + projectRootPath, + selectedModel, + selectedProviderStatus.name, + selectedProviderStatus.status + ]); const specGenerationHelperText = useMemo(() => { if (!desktopRuntime) { return "AI spec generation requires the desktop runtime."; } + if (!projectRootPath.trim()) { + return "Choose a project folder in setup before generating a spec."; + } + if (!prdContent.trim()) { - return "Load or write a PRD first. The generator combines that PRD with your note."; + return "Load or generate a PRD first. The spec generator appends your note after the saved spec prompt and includes the current PRD content."; + } + + if (!configuredSpecPath.trim()) { + return "Configure a spec path in setup or settings first."; + } + + if (!configuredSpecPath.toLowerCase().endsWith(".md")) { + return "Configure the spec path as a Markdown file if you want generated output saved into the workspace."; } if (!specGenerationPrompt.trim()) { - return "Add the technical guidance you want the AI to consider."; + return "Add the technical guidance you want to append after the saved spec prompt."; } if (selectedProviderStatus.status !== "found") { - return `${selectedProviderStatus.name} is not currently marked ready. If generation fails, update its path in Settings and refresh.`; + return `${selectedProviderStatus.name} is not currently marked ready. Update its path in Settings and refresh if generation fails.`; } - return `This sends the current PRD and your note to ${getModelLabel(selectedModel)}, saves the generated markdown next to the PRD, and loads that saved file into the spec pane.`; + return `This appends your note after the saved spec prompt from ${configPathDisplay}, includes the current PRD content, and writes markdown to ${configuredSpecPath}.`; }, [ + configPathDisplay, + configuredSpecPath, desktopRuntime, prdContent, - selectedModel, + projectRootPath, selectedProviderStatus.name, selectedProviderStatus.status, specGenerationPrompt @@ -254,13 +377,15 @@ function App() { claudePath, codexPath }).catch(() => previousEnvironment ?? environment), - getWorkspaceSnapshot().catch(() => workspaceEntries), + hasSelectedProject + ? Promise.resolve(workspaceEntries) + : getWorkspaceSnapshot().catch(() => workspaceEntries), getGitDiff().catch(() => DEFAULT_PENDING_DIFF) ]); setEnvironment(nextEnvironment); - if (!hasOpenedWorkspaceFolder) { + if (!hasSelectedProject) { setWorkspaceEntries(snapshotEntries); } @@ -270,7 +395,7 @@ function App() { claudePath, codexPath, environment, - hasOpenedWorkspaceFolder, + hasSelectedProject, setEnvironment, setWorkspaceEntries, workspaceEntries @@ -282,161 +407,194 @@ function App() { startTransition(() => { if (target === "prd") { setPrdContent(content, path); + setPrdPaneMode("preview"); return; } setSpecContent(content, path); - setSpecPaneMode("edit"); + setSpecPaneMode("preview"); }); - if (target === "spec") { - setSpecGenerationPrompt(""); - setSpecGenerationError(""); + if (target === "prd") { + setPrdGenerationPrompt(""); + setPrdGenerationError(""); + return; } + + setSpecGenerationPrompt(""); + setSpecGenerationError(""); }, - [setPrdContent, setSpecContent, setSpecPaneMode] + [setPrdContent, setPrdPaneMode, setSpecContent, setSpecPaneMode] ); - const applyWorkspaceSelection = useCallback( - ({ - rootName, - entries, - ignoredFileCount, - files, - prdDocument, - specDocument - }: WorkspaceSelectionPayload) => { - const loadedDocuments: string[] = []; + const applyProjectContext = useCallback( + (context: ProjectContext, options?: { navigateToReview?: boolean }) => { + const nextWorkspaceFiles = Object.fromEntries( + context.entries + .filter((entry) => entry.kind === "file") + .map((entry) => [ + entry.path, + { + kind: "desktop", + fileName: entry.name + } satisfies WorkspaceFileSource + ]) + ); resetWorkspaceContext(); - setWorkspaceEntries(entries); - setWorkspaceRootName(rootName); - setHasOpenedWorkspaceFolder(true); - setWorkspaceFiles(files); + setProjectRootName(context.rootName); + setProjectRootPath(context.rootPath); + setProjectConfigPath(context.settingsPath); + setHasSelectedProject(true); + setHasSavedProjectSettings(context.hasSavedSettings); + setWorkspaceEntries(context.entries); + setWorkspaceFiles(nextWorkspaceFiles); + setLastProjectPath(context.rootPath); + setProjectSettings(context.settings); + setPrdGenerationPrompt(""); + setPrdGenerationError(""); setSpecGenerationPrompt(""); setSpecGenerationError(""); + setProjectStatusMessage( + context.hasSavedSettings + ? `Loaded project settings from ${context.settingsPath}.` + : `Selected ${context.rootName}. Save the setup to create ${context.settingsPath}.` + ); + setProjectErrorMessage(""); + setWorkspaceNotice(buildWorkspaceNotice(context)); startTransition(() => { - setPrdContent(prdDocument?.content ?? "", prdDocument?.sourcePath ?? "PRD.md"); - setSpecContent(specDocument?.content ?? "", specDocument?.sourcePath ?? "spec.md"); - setSpecPaneMode(specDocument ? "edit" : "preview"); + setPrdContent(context.prdDocument?.content ?? "", context.prdDocument?.sourcePath ?? context.settings.prdPath); + setSpecContent(context.specDocument?.content ?? "", context.specDocument?.sourcePath ?? context.settings.specPath); + setPrdPaneMode("preview"); + setSpecPaneMode("preview"); }); - if (prdDocument) { - loadedDocuments.push(prdDocument.fileName); - } - - if (specDocument) { - loadedDocuments.push(specDocument.fileName); - } - - const missingDocuments = [ - prdDocument ? null : "PRD", - specDocument ? null : "spec" - ].filter((value): value is string => value !== null); - const missingDocumentNotice = formatMissingWorkspaceDocuments(missingDocuments); - - if (loadedDocuments.length > 0) { - setWorkspaceNotice( - `Loaded ${loadedDocuments.join(" and ")} from ${rootName}.${missingDocumentNotice}${ignoredFileCount > 0 ? ` Ignored ${ignoredFileCount} file(s) from .gitignore.` : ""}` - ); - appendTerminalOutput( - stampLog( - "workspace", - `Loaded workspace folder ${rootName} and detected ${loadedDocuments.join(", ")}.${missingDocumentNotice}` - ) - ); - return; + if (options?.navigateToReview) { + navigate("/review"); } - - setWorkspaceNotice( - `${rootName} opened successfully, but no matching PRD/spec files were found.${ignoredFileCount > 0 ? ` Ignored ${ignoredFileCount} file(s) from .gitignore.` : ""}` - ); }, [ - appendTerminalOutput, + navigate, resetWorkspaceContext, + setLastProjectPath, setPrdContent, + setPrdPaneMode, + setProjectSettings, setSpecContent, setSpecPaneMode, setWorkspaceEntries ] ); - const importWorkspaceFiles = useCallback( - async (files: ImportableFile[]) => { - if (files.length === 0) { + const saveCurrentProjectSettings = useCallback( + async ({ + reloadProject = false, + navigateToReview = false + }: { + reloadProject?: boolean; + navigateToReview?: boolean; + } = {}) => { + if (!desktopRuntime) { + setProjectErrorMessage("Project configuration requires the desktop runtime."); return; } - const filteredFiles = await filterWorkspaceFiles(files); - const snapshot = buildWorkspaceImportSnapshot(filteredFiles); - const matches = findProjectDocuments(filteredFiles); - const ignoredFileCount = files.length - filteredFiles.length; - const nextWorkspaceFiles = filteredFiles.reduce>( - (accumulator, file) => { - const normalizedPath = normalizeWorkspacePath( - file.webkitRelativePath || file.name, - snapshot.rootName - ); - accumulator[normalizedPath] = { - kind: "browser", - file - }; - return accumulator; - }, - {} - ); + if (!projectRootPath.trim()) { + setProjectErrorMessage("Choose a project folder before saving."); + return; + } + + setProjectErrorMessage(""); + setProjectStatusMessage(""); + setIsProjectSaving(true); try { - const [prdDocument, specDocument] = await Promise.all([ - matches.prdFile ? parseWorkspaceDocument(matches.prdFile) : Promise.resolve(null), - matches.specFile ? parseWorkspaceDocument(matches.specFile) : Promise.resolve(null) - ]); - - applyWorkspaceSelection({ - rootName: snapshot.rootName, - entries: snapshot.entries, - ignoredFileCount, - files: nextWorkspaceFiles, - prdDocument: prdDocument - ? { - content: prdDocument.content, - sourcePath: prdDocument.sourcePath, - fileName: matches.prdFile?.name ?? prdDocument.sourcePath - } - : null, - specDocument: specDocument - ? { - content: specDocument.content, - sourcePath: specDocument.sourcePath, - fileName: matches.specFile?.name ?? specDocument.sourcePath - } - : null + const savedSettings = await saveProjectSettings({ + folderPath: projectRootPath, + settings: currentProjectSettings }); + + setProjectSettings(savedSettings); + setHasSavedProjectSettings(true); + setProjectStatusMessage(`Saved project settings to ${configPathDisplay}.`); + + if (reloadProject || navigateToReview) { + const reloadedContext = await loadProjectContext(projectRootPath); + applyProjectContext(reloadedContext, { navigateToReview }); + } } catch (error) { - setWorkspaceNotice( - error instanceof Error - ? `${snapshot.rootName} opened, but document parsing failed: ${error.message}` - : `${snapshot.rootName} opened, but one of the detected documents could not be parsed.` + setProjectErrorMessage( + error instanceof Error ? error.message : "Unable to save the current project settings." ); + } finally { + setIsProjectSaving(false); + } + }, + [ + applyProjectContext, + configPathDisplay, + currentProjectSettings, + desktopRuntime, + projectRootPath, + setProjectSettings + ] + ); + + const scheduleProjectSettingsSave = useCallback( + (reloadProject = false) => { + if (!desktopRuntime || !hasSavedProjectSettings || !projectRootPath.trim()) { + return; + } + + pendingProjectReloadRef.current = pendingProjectReloadRef.current || reloadProject; + + if (projectSaveTimerRef.current !== null) { + window.clearTimeout(projectSaveTimerRef.current); } + + projectSaveTimerRef.current = window.setTimeout(() => { + const shouldReload = pendingProjectReloadRef.current; + pendingProjectReloadRef.current = false; + projectSaveTimerRef.current = null; + void saveCurrentProjectSettings({ reloadProject: shouldReload }); + }, 700); }, - [applyWorkspaceSelection] + [desktopRuntime, hasSavedProjectSettings, projectRootPath, saveCurrentProjectSettings] ); - const handlePathImport = useCallback(async () => { - setIsImporting(true); - setImportError(""); + const handlePickProjectFolder = useCallback(async () => { + if (!desktopRuntime) { + setProjectErrorMessage("Project configuration requires the desktop runtime."); + return; + } + + setProjectErrorMessage(""); + setProjectStatusMessage(""); + setIsProjectLoading(true); try { - assignDocument(importTarget, await parseDocument(importPath), importPath); + const nextProjectContext = await pickProjectFolder(); + + if (!nextProjectContext) { + return; + } + + applyProjectContext(nextProjectContext, { + navigateToReview: nextProjectContext.hasSavedSettings + }); + + if (!nextProjectContext.hasSavedSettings) { + navigate("/"); + } } catch (error) { - setImportError(error instanceof Error ? error.message : "Unable to parse the requested document."); + setProjectErrorMessage( + error instanceof Error ? error.message : "Unable to open the selected project folder." + ); } finally { - setIsImporting(false); + setIsProjectLoading(false); } - }, [assignDocument, importPath, importTarget]); + }, [applyProjectContext, desktopRuntime, navigate]); const handleFileSelection = useCallback( async (event: ChangeEvent) => { @@ -449,11 +607,15 @@ function App() { try { const document = await parseWorkspaceDocument(file); assignDocument(pendingImportTargetRef.current, document.content, document.sourcePath); - setImportError(""); } catch (error) { - setImportError( - error instanceof Error ? error.message : "The selected file could not be imported." - ); + const message = + error instanceof Error ? error.message : "The selected file could not be imported."; + + if (pendingImportTargetRef.current === "prd") { + setPrdGenerationError(message); + } else { + setSpecGenerationError(message); + } } finally { event.target.value = ""; } @@ -462,14 +624,8 @@ function App() { ); const handleWorkspaceFolderSelection = useCallback( - async (event: ChangeEvent) => { - try { - await importWorkspaceFiles(Array.from(event.target.files ?? []) as ImportableFile[]); - } finally { - event.target.value = ""; - } - }, - [importWorkspaceFiles] + (_event: ChangeEvent) => undefined, + [] ); const handleWorkspaceFileOpen = useCallback( @@ -519,6 +675,40 @@ function App() { [openEditorTab, workspaceFiles] ); + const handleOpenImportFile = useCallback( + async (target: DocumentTarget) => { + pendingImportTargetRef.current = target; + + if (desktopRuntime) { + setIsImporting(true); + + try { + const document = await pickDocument(); + + if (document) { + assignDocument(target, document.content, document.sourcePath); + } + } catch (error) { + const message = + error instanceof Error ? error.message : "The selected file could not be imported."; + + if (target === "prd") { + setPrdGenerationError(message); + } else { + setSpecGenerationError(message); + } + } finally { + setIsImporting(false); + } + + return; + } + + fileInputRef.current?.click(); + }, + [assignDocument, desktopRuntime] + ); + const handleApproveSpec = useCallback(() => { if (!specContent.trim()) { return; @@ -565,14 +755,14 @@ function App() { }, [ appendTerminalOutput, autonomyMode, + desktopRuntime, isSpecApproved, resetRun, + selectedModel, + selectedReasoning, setActiveTab, setAgentStatus, setCurrentMilestone, - desktopRuntime, - selectedModel, - selectedReasoning, specContent ]); @@ -630,19 +820,21 @@ function App() { appendTerminalOutput(stampLog("halt", "Emergency stop triggered. Agent loop is paused.")); }, [appendTerminalOutput, desktopRuntime, setAgentStatus, setExecutionSummary, setPendingDiff]); - const handleCommandSearchChange = useCallback( - (event: ChangeEvent) => setCommandSearch(event.target.value), - [] + const handlePrdContentChange = useCallback( + (value: string) => setPrdContent(value, prdPath), + [prdPath, setPrdContent] ); - const closeWorkspaceSearch = useCallback(() => { - setIsSearchOpen(false); - setCommandSearch(""); - }, []); + const handleSpecContentChange = useCallback( + (value: string) => { + if (value.trim()) { + setSpecGenerationError(""); + } - const handleImportTargetChange = useCallback((target: DocumentTarget) => { - setImportTarget(target); - }, []); + setSpecContent(value, specPath); + }, + [setSpecContent, specPath] + ); const handleSpecSelect = useCallback( (event: ChangeEvent) => { @@ -660,156 +852,109 @@ function App() { [setSelectedSpecRange] ); - const handleOpenImportFile = useCallback(async (target: DocumentTarget) => { - pendingImportTargetRef.current = target; - setImportTarget(target); - - if (desktopRuntime) { - setIsImporting(true); - setImportError(""); - - try { - const document = await pickDocument(); + const handlePrdGenerationPromptChange = useCallback((value: string) => { + setPrdGenerationPrompt(value); - if (document) { - assignDocument(target, document.content, document.sourcePath); - } - } catch (error) { - setImportError( - error instanceof Error ? error.message : "The selected file could not be imported." - ); - } finally { - setIsImporting(false); - } - - return; + if (prdGenerationError) { + setPrdGenerationError(""); } - fileInputRef.current?.click(); - }, [assignDocument, desktopRuntime]); - - const handleOpenWorkspaceFolder = useCallback(async () => { - if (desktopRuntime) { - try { - const workspaceFolder = await openWorkspaceFolder(); - - if (!workspaceFolder) { - return; - } - - const nextWorkspaceFiles = Object.fromEntries( - workspaceFolder.entries - .filter((entry) => entry.kind === "file") - .map((entry) => [ - entry.path, - { - kind: "desktop", - fileName: entry.name - } satisfies WorkspaceFileSource - ]) - ); - - applyWorkspaceSelection({ - rootName: workspaceFolder.rootName, - entries: workspaceFolder.entries, - ignoredFileCount: workspaceFolder.ignoredFileCount, - files: nextWorkspaceFiles, - prdDocument: workspaceFolder.prdDocument, - specDocument: workspaceFolder.specDocument - }); - return; - } catch (error) { - setWorkspaceNotice( - error instanceof Error - ? `Workspace import failed: ${error.message}` - : "Workspace import failed." - ); - return; - } + if (agentStatus === "error") { + setAgentStatus("idle"); } + }, [agentStatus, prdGenerationError, setAgentStatus]); - const pickDirectory = getDirectoryPicker(); + const handleSpecGenerationPromptChange = useCallback((value: string) => { + setSpecGenerationPrompt(value); - if (pickDirectory) { - try { - const directoryHandle = await pickDirectory({ mode: "read" }); - await importWorkspaceFiles(await collectWorkspaceFiles(directoryHandle)); - return; - } catch (error) { - if (isDirectoryPickerAbort(error)) { - return; - } - } + if (specGenerationError) { + setSpecGenerationError(""); } - folderInputRef.current?.click(); - }, [applyWorkspaceSelection, desktopRuntime, importWorkspaceFiles]); - - const handleRefresh = useCallback(() => { - void refreshDiagnostics(); - }, [refreshDiagnostics]); - - const handlePathImportClick = useCallback(() => { - void handlePathImport(); - }, [handlePathImport]); - - const handleOpenPrdImportClick = useCallback(() => { - void handleOpenImportFile("prd"); - }, [handleOpenImportFile]); - - const handleOpenSpecImportClick = useCallback(() => { - void handleOpenImportFile("spec"); - }, [handleOpenImportFile]); - - const handleStartBuildClick = useCallback(() => { - void handleStartBuild(); - }, [handleStartBuild]); - - const handleApproveExecutionGateClick = useCallback(() => { - void handleApproveExecutionGate(); - }, [handleApproveExecutionGate]); + if (agentStatus === "error") { + setAgentStatus("idle"); + } + }, [agentStatus, setAgentStatus, specGenerationError]); - const handleEmergencyStopClick = useCallback(() => { - void handleEmergencyStop(); - }, [handleEmergencyStop]); + const handleGeneratePrd = useCallback(async () => { + const trimmedPrompt = prdGenerationPrompt.trim(); - const handleWorkspaceFileOpenClick = useCallback( - (path: string) => { - void handleWorkspaceFileOpen(path); - }, - [handleWorkspaceFileOpen] - ); + if (!desktopRuntime) { + setPrdGenerationError("AI PRD generation requires the desktop runtime."); + return; + } - const handlePrdContentChange = useCallback( - (value: string) => setPrdContent(value, prdPath), - [prdPath, setPrdContent] - ); + if (!projectRootPath.trim()) { + setPrdGenerationError("Choose a project folder before generating a PRD."); + return; + } - const handleSpecContentChange = useCallback( - (value: string) => { - if (value.trim()) { - setSpecGenerationError(""); - } + if (!currentProjectSettings.prdPath.toLowerCase().endsWith(".md")) { + setPrdGenerationError("Configure the PRD path as a Markdown file before generating."); + return; + } - setSpecContent(value, specPath); - }, - [setSpecContent, specPath] - ); + if (!trimmedPrompt) { + setPrdGenerationError("Add the product context you want the AI to consider."); + return; + } - const handleSpecGenerationPromptChange = useCallback( - (value: string) => { - setSpecGenerationPrompt(value); + setPrdGenerationError(""); + setAgentStatus("generating_prd"); + appendTerminalOutput( + stampLog( + "prd", + `Generating a PRD draft with ${getModelLabel(selectedModel)} (${getReasoningLabel(selectedModel, selectedReasoning)} reasoning).` + ) + ); - if (specGenerationError) { - setSpecGenerationError(""); - } + try { + const generatedPrd = await generatePrdDocument({ + workspaceRoot: projectRootPath, + outputPath: currentProjectSettings.prdPath, + promptTemplate: currentProjectSettings.prdPrompt, + userPrompt: trimmedPrompt, + provider: selectedModelProvider, + model: selectedModel, + reasoning: selectedReasoning, + claudePath, + codexPath + }); - if (agentStatus === "error") { - setAgentStatus("idle"); - } - }, - [agentStatus, setAgentStatus, specGenerationError] - ); + startTransition(() => { + setPrdContent(generatedPrd.content, generatedPrd.sourcePath); + setPrdPaneMode("preview"); + }); + setPrdGenerationPrompt(""); + setAgentStatus("idle"); + appendTerminalOutput( + stampLog( + "prd", + `PRD draft generated, saved to ${generatedPrd.fileName}, and loaded into the review pane.` + ) + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Unable to generate a PRD."; + setPrdGenerationError(message); + setAgentStatus("error"); + appendTerminalOutput(stampLog("error", message)); + } + }, [ + appendTerminalOutput, + claudePath, + codexPath, + currentProjectSettings.prdPath, + currentProjectSettings.prdPrompt, + desktopRuntime, + prdGenerationPrompt, + projectRootPath, + selectedModel, + selectedModelProvider, + selectedReasoning, + setAgentStatus, + setPrdContent, + setPrdPaneMode + ]); const handleGenerateSpec = useCallback(async () => { const trimmedPrompt = specGenerationPrompt.trim(); @@ -819,8 +964,18 @@ function App() { return; } + if (!projectRootPath.trim()) { + setSpecGenerationError("Choose a project folder before generating a spec."); + return; + } + if (!prdContent.trim()) { - setSpecGenerationError("Load or write a PRD before generating a specification."); + setSpecGenerationError("Load or generate a PRD before drafting a specification."); + return; + } + + if (!currentProjectSettings.specPath.toLowerCase().endsWith(".md")) { + setSpecGenerationError("Configure the spec path as a Markdown file before generating."); return; } @@ -840,8 +995,10 @@ function App() { try { const generatedSpec = await generateSpecDocument({ - prdPath, + workspaceRoot: projectRootPath, + outputPath: currentProjectSettings.specPath, prdContent, + promptTemplate: currentProjectSettings.specPrompt, userPrompt: trimmedPrompt, provider: selectedModelProvider, model: selectedModel, @@ -865,7 +1022,6 @@ function App() { } catch (error) { const message = error instanceof Error ? error.message : "Unable to generate a specification."; - setSpecGenerationError(message); setAgentStatus("error"); appendTerminalOutput(stampLog("error", message)); @@ -874,9 +1030,11 @@ function App() { appendTerminalOutput, claudePath, codexPath, + currentProjectSettings.specPath, + currentProjectSettings.specPrompt, desktopRuntime, - prdPath, prdContent, + projectRootPath, selectedModel, selectedModelProvider, selectedReasoning, @@ -886,22 +1044,90 @@ function App() { specGenerationPrompt ]); + const handleProjectModelChange = useCallback((model: typeof selectedModel) => { + setSelectedModel(model); + scheduleProjectSettingsSave(false); + }, [scheduleProjectSettingsSave, setSelectedModel]); + + const handleProjectReasoningChange = useCallback((reasoning: typeof selectedReasoning) => { + setReasoningProfile(reasoning); + scheduleProjectSettingsSave(false); + }, [scheduleProjectSettingsSave, setReasoningProfile]); + + const handlePrdPromptTemplateChange = useCallback((value: string) => { + setPrdPromptTemplate(value); + scheduleProjectSettingsSave(false); + }, [scheduleProjectSettingsSave, setPrdPromptTemplate]); + + const handleSpecPromptTemplateChange = useCallback((value: string) => { + setSpecPromptTemplate(value); + scheduleProjectSettingsSave(false); + }, [scheduleProjectSettingsSave, setSpecPromptTemplate]); + + const handleConfiguredPrdPathChange = useCallback((value: string) => { + setConfiguredPrdPath(normalizeProjectRelativePath(value)); + scheduleProjectSettingsSave(true); + }, [scheduleProjectSettingsSave, setConfiguredPrdPath]); + + const handleConfiguredSpecPathChange = useCallback((value: string) => { + setConfiguredSpecPath(normalizeProjectRelativePath(value)); + scheduleProjectSettingsSave(true); + }, [scheduleProjectSettingsSave, setConfiguredSpecPath]); + + const handleSupportingDocumentsChange = useCallback((value: string) => { + setSupportingDocumentPaths(parseSupportingDocumentPaths(value)); + scheduleProjectSettingsSave(false); + }, [scheduleProjectSettingsSave, setSupportingDocumentPaths]); + + const handleCommandSearchChange = useCallback( + (event: ChangeEvent) => setCommandSearch(event.target.value), + [] + ); + + const closeWorkspaceSearch = useCallback(() => { + setIsSearchOpen(false); + setCommandSearch(""); + }, []); + + const handleRefresh = useCallback(() => { + void refreshDiagnostics(); + }, [refreshDiagnostics]); + + const handleOpenPrdImportClick = useCallback(() => { + void handleOpenImportFile("prd"); + }, [handleOpenImportFile]); + + const handleOpenSpecImportClick = useCallback(() => { + void handleOpenImportFile("spec"); + }, [handleOpenImportFile]); + + const handleStartBuildClick = useCallback(() => { + void handleStartBuild(); + }, [handleStartBuild]); + + const handleApproveExecutionGateClick = useCallback(() => { + void handleApproveExecutionGate(); + }, [handleApproveExecutionGate]); + + const handleEmergencyStopClick = useCallback(() => { + void handleEmergencyStop(); + }, [handleEmergencyStop]); + + const handleWorkspaceFileOpenClick = useCallback((path: string) => { + void handleWorkspaceFileOpen(path); + }, [handleWorkspaceFileOpen]); + + const handleGeneratePrdClick = useCallback(() => { + void handleGeneratePrd(); + }, [handleGeneratePrd]); + const handleGenerateSpecClick = useCallback(() => { void handleGenerateSpec(); }, [handleGenerateSpec]); - useEffect(() => { - if (hasInitializedDocumentsRef.current) { - return; - } - - hasInitializedDocumentsRef.current = true; - startTransition(() => { - setPrdContent(bundledPrd, "docs/PRD.md"); - setSpecContent(bundledSpec, "docs/SPEC.md"); - }); - }, [setPrdContent, setSpecContent]); - + const handleSaveConfigurationAndContinue = useCallback(() => { + void saveCurrentProjectSettings({ reloadProject: true, navigateToReview: true }); + }, [saveCurrentProjectSettings]); useEffect(() => { if (typeof window === "undefined" || !window.matchMedia) { return; @@ -919,24 +1145,6 @@ function App() { document.documentElement.classList.toggle("dark", resolvedTheme === "dracula"); }, [resolvedTheme]); - useEffect(() => { - if (configuredModelProviders.length !== 1) { - return; - } - - const onlyConfiguredProvider = configuredModelProviders[0]; - - if (getModelProvider(selectedModel) === onlyConfiguredProvider) { - return; - } - - const nextModel = getModelOptions(onlyConfiguredProvider)[0]?.value; - - if (nextModel) { - setSelectedModel(nextModel); - } - }, [configuredModelProviders, selectedModel, setSelectedModel]); - useEffect(() => { const handleKeyDown = (event: globalThis.KeyboardEvent) => { if (event.defaultPrevented || event.isComposing) { @@ -950,19 +1158,19 @@ function App() { event.key.toLowerCase() === "f"; if (isFindShortcut) { - event.preventDefault(); - - if (!isSettingsRoute) { - setIsSearchOpen((currentValue) => { - if (currentValue) { - setCommandSearch(""); - return false; - } - - return true; - }); + if (!isReviewRoute) { + return; } + event.preventDefault(); + setIsSearchOpen((currentValue) => { + if (currentValue) { + setCommandSearch(""); + return false; + } + + return true; + }); return; } @@ -974,16 +1182,16 @@ function App() { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [closeWorkspaceSearch, isSearchOpen, isSettingsRoute]); + }, [closeWorkspaceSearch, isReviewRoute, isSearchOpen]); useEffect(() => { - if (isSettingsRoute && isSearchOpen) { + if (!isReviewRoute && isSearchOpen) { closeWorkspaceSearch(); } - }, [closeWorkspaceSearch, isSearchOpen, isSettingsRoute]); + }, [closeWorkspaceSearch, isReviewRoute, isSearchOpen]); useEffect(() => { - if (!isSearchOpen || isSettingsRoute) { + if (!isSearchOpen || !isReviewRoute) { return; } @@ -993,7 +1201,7 @@ function App() { }); return () => window.cancelAnimationFrame(focusFrame); - }, [isSearchOpen, isSettingsRoute]); + }, [isReviewRoute, isSearchOpen]); useEffect(() => { if (hasScannedEnvironmentRef.current) { @@ -1004,9 +1212,58 @@ function App() { void refreshDiagnostics(environment); }, [environment, refreshDiagnostics]); + useEffect(() => { + if (hasAttemptedProjectRestore || !desktopRuntime) { + return; + } + + if (!lastProjectPath.trim()) { + setHasAttemptedProjectRestore(true); + return; + } + + let isDisposed = false; + setIsProjectLoading(true); + + void loadProjectContext(lastProjectPath) + .then((context) => { + if (isDisposed) { + return; + } + + applyProjectContext(context); + }) + .catch(() => { + if (isDisposed) { + return; + } + + setLastProjectPath(""); + }) + .finally(() => { + if (isDisposed) { + return; + } + + setIsProjectLoading(false); + setHasAttemptedProjectRestore(true); + }); + + return () => { + isDisposed = true; + }; + }, [ + applyProjectContext, + desktopRuntime, + hasAttemptedProjectRestore, + lastProjectPath, + setLastProjectPath + ]); + useEffect(() => { let unlisten: (() => void) | undefined; let isDisposed = false; + void subscribeToAgentEvents({ onLine: appendTerminalOutput, onState: (payload) => { @@ -1028,12 +1285,147 @@ function App() { isDisposed = true; unlisten?.(); clearFallbackTimer(fallbackTimerRef); + + if (projectSaveTimerRef.current !== null) { + window.clearTimeout(projectSaveTimerRef.current); + projectSaveTimerRef.current = null; + } }; }, [appendTerminalOutput, applyAgentEvent]); + const reviewScreen = hasSavedProjectSettings ? ( + 0, + onFileOpen: handleWorkspaceFileOpenClick, + onFolderChange: handleWorkspaceFolderSelection, + onOpenFolder: handlePickProjectFolder, + workspaceEntries: filteredWorkspaceEntries, + workspaceNotice, + workspaceRootName: projectRootName + }} + isSearchOpen={isSearchOpen} + isSpecApproved={isSpecApproved} + mainWorkspaceProps={{ + activeTab, + agentStatus, + canGeneratePrd, + canGenerateSpec, + configPath: configPathDisplay, + executionSummary, + isGeneratingPrd, + isGeneratingSpec, + isSpecApproved, + onActiveTabChange: setActiveTab, + onApproveExecutionGate: handleApproveExecutionGateClick, + onApproveSpec: handleApproveSpec, + onEditorTabChange: updateEditorTabContent, + onEditorTabClose: closeEditorTab, + onEmergencyStop: handleEmergencyStopClick, + onGeneratePrd: handleGeneratePrdClick, + onGenerateSpec: handleGenerateSpecClick, + onLoadPrd: handleOpenPrdImportClick, + onLoadSpec: handleOpenSpecImportClick, + onPrdContentChange: handlePrdContentChange, + onPrdGenerationPromptChange: handlePrdGenerationPromptChange, + onPrdPaneModeChange: setPrdPaneMode, + onSpecContentChange: handleSpecContentChange, + onSpecGenerationPromptChange: handleSpecGenerationPromptChange, + onSpecPaneModeChange: setSpecPaneMode, + onSpecSelect: handleSpecSelect, + openEditorTabs, + prdContent, + prdGenerationError, + prdGenerationHelperText, + prdGenerationPrompt, + prdPaneMode, + prdPath, + prdPromptTemplate, + specContent, + specGenerationError, + specGenerationHelperText, + specGenerationPrompt, + specPaneMode, + specPath, + specPromptTemplate, + terminalOutput, + visibleDiff, + workspaceRootName: projectRootName + }} + onCommandSearchChange={handleCommandSearchChange} + onRefresh={handleRefresh} + onStartBuild={handleStartBuildClick} + searchInputRef={searchInputRef} + workspaceRootName={projectRootName} + /> + ) : hasAttemptedProjectRestore ? ( + + ) : ( +
+ Loading project configuration... +
+ ); + + const settingsScreen = hasSavedProjectSettings ? ( + + ) : hasAttemptedProjectRestore ? ( + + ) : ( +
+ Loading project configuration... +
+ ); + return (
- +
+ 0, - onFileOpen: handleWorkspaceFileOpenClick, - onFolderChange: handleWorkspaceFolderSelection, - onOpenFolder: handleOpenWorkspaceFolder, - workspaceEntries: filteredWorkspaceEntries, - workspaceNotice, - workspaceRootName - }} - isSearchOpen={isSearchOpen} - isSpecApproved={isSpecApproved} - mainWorkspaceProps={{ - activeTab, - agentStatus, - canGenerateSpec, - executionSummary, - isSpecApproved, - isGeneratingSpec, - onApproveSpec: handleApproveSpec, - onEditorTabChange: updateEditorTabContent, - onEditorTabClose: closeEditorTab, - onActiveTabChange: setActiveTab, - onApproveExecutionGate: handleApproveExecutionGateClick, - onEmergencyStop: handleEmergencyStopClick, - onGenerateSpec: handleGenerateSpecClick, - onLoadPrd: handleOpenPrdImportClick, - onLoadSpec: handleOpenSpecImportClick, - openEditorTabs, - onPrdContentChange: handlePrdContentChange, - onPrdPaneModeChange: setPrdPaneMode, - onSpecContentChange: handleSpecContentChange, - onSpecGenerationPromptChange: handleSpecGenerationPromptChange, - onSpecPaneModeChange: setSpecPaneMode, - onSpecSelect: handleSpecSelect, - prdContent, - prdPaneMode, - prdPath, - specGenerationError, - specGenerationHelperText, - specGenerationPrompt, - specContent, - specPaneMode, - specPath, - terminalOutput, - visibleDiff, - workspaceRootName - }} - onCommandSearchChange={handleCommandSearchChange} + } path="/" /> - - } - path="/settings" - /> + + } path="*" />
@@ -1147,15 +1486,15 @@ function App() { export default App; -function formatMissingWorkspaceDocuments(missingDocuments: string[]) { - if (missingDocuments.length === 0) { - return ""; - } +function buildWorkspaceNotice(context: ProjectContext) { + const loadedDocuments = [ + context.prdDocument?.fileName ? `PRD: ${context.prdDocument.fileName}` : null, + context.specDocument?.fileName ? `SPEC: ${context.specDocument.fileName}` : null + ].filter((value): value is string => value !== null); - if (missingDocuments.length === 1) { - return ` No matching ${missingDocuments[0]} file was found.`; + if (loadedDocuments.length === 0) { + return `${context.rootName} is configured. No document exists yet at ${context.settings.prdPath} or ${context.settings.specPath}.`; } - const finalDocument = missingDocuments[missingDocuments.length - 1]; - return ` No matching ${missingDocuments.slice(0, -1).join(" or ")} or ${finalDocument} files were found.`; + return `${context.rootName} is configured. Loaded ${loadedDocuments.join(" and ")} from the saved project paths.`; } diff --git a/src/components/AppRail.tsx b/src/components/AppRail.tsx index b9b3e34..ea078a2 100644 --- a/src/components/AppRail.tsx +++ b/src/components/AppRail.tsx @@ -7,17 +7,35 @@ import { } from "iconoir-react"; import { NavLink } from "react-router-dom"; -export function AppRail() { +interface AppRailProps { + hasProjectConfigured: boolean; +} + +export function AppRail({ hasProjectConfigured }: AppRailProps) { return (