diff --git a/Cargo.lock b/Cargo.lock index 08dcc63cb..07bc204de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1882,7 +1882,9 @@ dependencies = [ "serde_json", "services", "sqlx", + "tempfile", "tokio", + "toml", "tracing", "ts-rs 11.0.1", "uuid", diff --git a/forge-app/src/bin/generate_forge_types.rs b/forge-app/src/bin/generate_forge_types.rs index a0934e93d..0fc5b288c 100644 --- a/forge-app/src/bin/generate_forge_types.rs +++ b/forge-app/src/bin/generate_forge_types.rs @@ -1,7 +1,7 @@ use std::{env, fs, path::Path}; use anyhow::{Context, Result, bail}; -use forge_config::{ForgeProjectSettings, ProjectConfig}; +use forge_config::{BetaFeature, FeatureMaturity, ForgeProjectSettings, ProjectConfig}; use forge_omni::{OmniConfig, OmniInstance, RecipientType, SendTextRequest, SendTextResponse}; use ts_rs::TS; @@ -18,6 +18,9 @@ fn main() -> Result<()> { OmniInstance::decl(), SendTextRequest::decl(), SendTextResponse::decl(), + // Beta features types + BetaFeature::decl(), + FeatureMaturity::decl(), ]; let body = declarations diff --git a/forge-app/src/router.rs b/forge-app/src/router.rs index 927b510b8..784c115d5 100644 --- a/forge-app/src/router.rs +++ b/forge-app/src/router.rs @@ -32,7 +32,7 @@ use db::models::{ }; use deployment::Deployment; use executors::profile::ExecutorProfileId; -use forge_config::ForgeProjectSettings; +use forge_config::{BetaFeature, ForgeProjectSettings}; use server::routes::{ self as upstream, approvals, auth, config as upstream_config, containers, drafts, events, execution_processes, filesystem, images, projects, tags, task_attempts, tasks, @@ -158,7 +158,12 @@ fn forge_api_routes() -> Router { "/api/forge/agents", get(get_forge_agents).post(create_forge_agent), ) - // Branch-templates extension removed - using simple forge/ prefix + // Beta features API + .route("/api/forge/beta-features", get(list_beta_features)) + .route( + "/api/forge/beta-features/{feature_id}/toggle", + post(toggle_beta_feature), + ) } /// Forge-specific CreateTask that includes is_agent field @@ -1758,6 +1763,45 @@ async fn update_project_settings( Ok(Json(ApiResponse::success(settings))) } +// ============================================================================ +// Beta Features API +// ============================================================================ + +/// List all beta features with their enabled states +async fn list_beta_features( + State(services): State, +) -> Result>>, StatusCode> { + services + .beta_features + .list() + .await + .map(|features| Json(ApiResponse::success(features))) + .map_err(|e| { + tracing::error!("Failed to list beta features: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + }) +} + +/// Toggle a beta feature's enabled state +async fn toggle_beta_feature( + Path(feature_id): Path, + State(services): State, +) -> Result>, StatusCode> { + services + .beta_features + .toggle(&feature_id) + .await + .map(|feature| Json(ApiResponse::success(feature))) + .map_err(|e| { + tracing::error!("Failed to toggle beta feature '{}': {}", feature_id, e); + if e.to_string().contains("not found") { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR + } + }) +} + /// Get executor profiles for a specific project async fn get_project_profiles( Path(project_id): Path, diff --git a/forge-app/src/services/mod.rs b/forge-app/src/services/mod.rs index c86259eda..2f741bc1e 100644 --- a/forge-app/src/services/mod.rs +++ b/forge-app/src/services/mod.rs @@ -21,7 +21,7 @@ use tokio::time::{Duration, sleep}; use uuid::Uuid; // Import forge extension services -use forge_config::ForgeConfigService; +use forge_config::{BetaFeaturesService, ForgeConfigService}; use forge_omni::{OmniConfig, OmniService}; /// Main forge services container @@ -31,6 +31,7 @@ pub struct ForgeServices { pub deployment: Arc, pub omni: Arc>, pub config: Arc, + pub beta_features: Arc, pub pool: SqlitePool, pub profile_cache: Arc, } @@ -73,6 +74,14 @@ impl ForgeServices { let omni_config = config.effective_omni_config(None).await?; let omni = Arc::new(RwLock::new(OmniService::new(omni_config))); + // Initialize beta features service + // Look for beta-features.toml in forge-main directory (relative to cwd) + let beta_features_path = std::env::current_dir()? + .join("forge-main") + .join("beta-features.toml"); + let beta_features = Arc::new(BetaFeaturesService::new(pool.clone(), beta_features_path)?); + beta_features.ensure_table().await?; + tracing::info!( forge_omni_enabled = global_settings.omni_enabled, "Loaded forge extension settings from auxiliary schema" @@ -91,6 +100,7 @@ impl ForgeServices { deployment, omni, config, + beta_features, pool, profile_cache, }) diff --git a/forge-extensions/config/Cargo.toml b/forge-extensions/config/Cargo.toml index d9bd57b65..c8a49f4e2 100644 --- a/forge-extensions/config/Cargo.toml +++ b/forge-extensions/config/Cargo.toml @@ -9,6 +9,7 @@ serde_json = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } ts-rs = { workspace = true } +toml = "0.8" sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "uuid", "chrono"] } uuid = { version = "1.0", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } @@ -17,3 +18,4 @@ services = { git = "https://github.com/namastexlabs/forge-core.git", tag = "v0.8 [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tempfile = "3.10" diff --git a/forge-extensions/config/bindings/BetaFeature.ts b/forge-extensions/config/bindings/BetaFeature.ts new file mode 100644 index 000000000..3d7dc036c --- /dev/null +++ b/forge-extensions/config/bindings/BetaFeature.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FeatureMaturity } from "./FeatureMaturity"; + +/** + * A beta feature with merged config + state + */ +export type BetaFeature = { id: string, name: string, description: string, maturity: FeatureMaturity, enabled: boolean, }; diff --git a/forge-extensions/config/bindings/FeatureMaturity.ts b/forge-extensions/config/bindings/FeatureMaturity.ts new file mode 100644 index 000000000..4e1bbff76 --- /dev/null +++ b/forge-extensions/config/bindings/FeatureMaturity.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Feature maturity level + */ +export type FeatureMaturity = "experimental" | "beta" | "stable"; diff --git a/forge-extensions/config/src/beta_features.rs b/forge-extensions/config/src/beta_features.rs new file mode 100644 index 000000000..37d1a56e6 --- /dev/null +++ b/forge-extensions/config/src/beta_features.rs @@ -0,0 +1,343 @@ +//! Beta Features Service +//! +//! Provides feature flag functionality with: +//! - Config file (TOML) for feature definitions (name, description, maturity) +//! - Database for enabled/disabled state per feature +//! +//! Usage: +//! ```rust +//! if services.beta_features.is_enabled("my_feature").await? { +//! // Feature-specific logic +//! } +//! ``` + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; +use std::collections::HashMap; +use std::path::Path; +use ts_rs::TS; + +/// A beta feature with merged config + state +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct BetaFeature { + pub id: String, + pub name: String, + pub description: String, + pub maturity: FeatureMaturity, + pub enabled: bool, +} + +/// Feature maturity level +#[derive(Debug, Clone, Serialize, Deserialize, TS, Default, PartialEq)] +#[ts(export)] +#[serde(rename_all = "lowercase")] +pub enum FeatureMaturity { + #[default] + Experimental, + Beta, + Stable, +} + +impl From<&str> for FeatureMaturity { + fn from(s: &str) -> Self { + match s.to_lowercase().as_str() { + "beta" => FeatureMaturity::Beta, + "stable" => FeatureMaturity::Stable, + _ => FeatureMaturity::Experimental, + } + } +} + +/// Parsed from TOML config file +#[derive(Debug, Clone, Deserialize)] +struct BetaFeaturesConfig { + #[serde(default)] + features: HashMap, +} + +/// Feature definition from config file +#[derive(Debug, Clone, Deserialize)] +struct FeatureDefinition { + name: String, + description: String, + #[serde(default)] + maturity: String, +} + +/// Service for managing beta features +pub struct BetaFeaturesService { + pool: SqlitePool, + config: BetaFeaturesConfig, +} + +impl BetaFeaturesService { + /// Create a new BetaFeaturesService + /// + /// Loads the config file once at creation time for efficiency. + pub fn new(pool: SqlitePool, config_path: impl AsRef) -> Result { + let config = Self::load_config_from_path(config_path.as_ref())?; + Ok(Self { pool, config }) + } + + /// Load feature definitions from config file + fn load_config_from_path(config_path: &Path) -> Result { + if !config_path.exists() { + tracing::debug!( + "Beta features config not found at {:?}, returning empty config", + config_path + ); + return Ok(BetaFeaturesConfig { + features: HashMap::new(), + }); + } + + let content = std::fs::read_to_string(config_path) + .with_context(|| format!("Failed to read beta features config: {:?}", config_path))?; + + let config: BetaFeaturesConfig = toml::from_str(&content) + .with_context(|| format!("Failed to parse beta features config: {:?}", config_path))?; + + Ok(config) + } + + /// Ensure the database table exists + pub async fn ensure_table(&self) -> Result<()> { + sqlx::query( + r#"CREATE TABLE IF NOT EXISTS forge_beta_feature_state ( + feature_id TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )"#, + ) + .execute(&self.pool) + .await + .context("Failed to create forge_beta_feature_state table")?; + + Ok(()) + } + + /// Load enabled states from database + async fn load_states(&self) -> Result> { + let rows: Vec<(String, i32)> = + sqlx::query_as("SELECT feature_id, enabled FROM forge_beta_feature_state") + .fetch_all(&self.pool) + .await?; + + Ok(rows.into_iter().map(|(id, enabled)| (id, enabled != 0)).collect()) + } + + /// List all beta features with their enabled states + pub async fn list(&self) -> Result> { + let states = self.load_states().await?; + + let features: Vec = self + .config + .features + .iter() + .map(|(id, def)| BetaFeature { + enabled: states.get(id).copied().unwrap_or(false), + id: id.clone(), + name: def.name.clone(), + description: def.description.clone(), + maturity: FeatureMaturity::from(def.maturity.as_str()), + }) + .collect(); + + Ok(features) + } + + /// Check if a feature is enabled + pub async fn is_enabled(&self, feature_id: &str) -> Result { + // First check if feature exists in config + if !self.config.features.contains_key(feature_id) { + tracing::warn!( + "Beta feature '{}' not found in config, returning false", + feature_id + ); + return Ok(false); + } + + // Check database state + let row: Option<(i32,)> = + sqlx::query_as("SELECT enabled FROM forge_beta_feature_state WHERE feature_id = ?") + .bind(feature_id) + .fetch_optional(&self.pool) + .await?; + + Ok(row.map(|(enabled,)| enabled != 0).unwrap_or(false)) + } + + /// Set a feature's enabled state + pub async fn set_enabled(&self, feature_id: &str, enabled: bool) -> Result<()> { + // Verify feature exists in config + if !self.config.features.contains_key(feature_id) { + anyhow::bail!("Beta feature '{}' not found in config", feature_id); + } + + sqlx::query( + r#"INSERT INTO forge_beta_feature_state (feature_id, enabled, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(feature_id) DO UPDATE SET + enabled = excluded.enabled, + updated_at = CURRENT_TIMESTAMP"#, + ) + .bind(feature_id) + .bind(if enabled { 1 } else { 0 }) + .execute(&self.pool) + .await?; + + tracing::info!( + "Beta feature '{}' {} by user", + feature_id, + if enabled { "enabled" } else { "disabled" } + ); + + Ok(()) + } + + /// Toggle a feature's enabled state and return the updated feature + pub async fn toggle(&self, feature_id: &str) -> Result { + let def = self + .config + .features + .get(feature_id) + .ok_or_else(|| anyhow::anyhow!("Beta feature '{}' not found in config", feature_id))?; + + // Query DB directly instead of calling is_enabled to avoid redundant config check + let row: Option<(i32,)> = + sqlx::query_as("SELECT enabled FROM forge_beta_feature_state WHERE feature_id = ?") + .bind(feature_id) + .fetch_optional(&self.pool) + .await?; + let current_state = row.map(|(enabled,)| enabled != 0).unwrap_or(false); + let new_state = !current_state; + + // Update DB directly instead of calling set_enabled to avoid redundant config check + sqlx::query( + r#"INSERT INTO forge_beta_feature_state (feature_id, enabled, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(feature_id) DO UPDATE SET + enabled = excluded.enabled, + updated_at = CURRENT_TIMESTAMP"#, + ) + .bind(feature_id) + .bind(if new_state { 1 } else { 0 }) + .execute(&self.pool) + .await?; + + tracing::info!( + "Beta feature '{}' {} by user", + feature_id, + if new_state { "enabled" } else { "disabled" } + ); + + Ok(BetaFeature { + id: feature_id.to_string(), + name: def.name.clone(), + description: def.description.clone(), + maturity: FeatureMaturity::from(def.maturity.as_str()), + enabled: new_state, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::path::PathBuf; + use tempfile::NamedTempFile; + + async fn setup_pool() -> SqlitePool { + SqlitePool::connect("sqlite::memory:") + .await + .expect("failed to create in-memory sqlite pool") + } + + fn create_test_config() -> NamedTempFile { + let mut file = NamedTempFile::new().expect("failed to create temp file"); + writeln!( + file, + r#" +[features.test_feature] +name = "Test Feature" +description = "A test feature" +maturity = "experimental" + +[features.beta_feature] +name = "Beta Feature" +description = "A beta feature" +maturity = "beta" +"# + ) + .expect("failed to write config"); + file + } + + #[tokio::test] + async fn test_list_features() { + let pool = setup_pool().await; + let config_file = create_test_config(); + let service = + BetaFeaturesService::new(pool, config_file.path()).expect("should create service"); + + service.ensure_table().await.expect("should create table"); + + let features = service.list().await.expect("should list features"); + assert_eq!(features.len(), 2); + + let test_feature = features.iter().find(|f| f.id == "test_feature").unwrap(); + assert_eq!(test_feature.name, "Test Feature"); + assert_eq!(test_feature.maturity, FeatureMaturity::Experimental); + assert!(!test_feature.enabled); + } + + #[tokio::test] + async fn test_toggle_feature() { + let pool = setup_pool().await; + let config_file = create_test_config(); + let service = + BetaFeaturesService::new(pool, config_file.path()).expect("should create service"); + + service.ensure_table().await.expect("should create table"); + + // Initially disabled + assert!(!service.is_enabled("test_feature").await.unwrap()); + + // Toggle on + let feature = service.toggle("test_feature").await.expect("should toggle"); + assert!(feature.enabled); + assert!(service.is_enabled("test_feature").await.unwrap()); + + // Toggle off + let feature = service.toggle("test_feature").await.expect("should toggle"); + assert!(!feature.enabled); + assert!(!service.is_enabled("test_feature").await.unwrap()); + } + + #[tokio::test] + async fn test_unknown_feature_returns_false() { + let pool = setup_pool().await; + let config_file = create_test_config(); + let service = + BetaFeaturesService::new(pool, config_file.path()).expect("should create service"); + + service.ensure_table().await.expect("should create table"); + + assert!(!service.is_enabled("nonexistent_feature").await.unwrap()); + } + + #[tokio::test] + async fn test_missing_config_file() { + let pool = setup_pool().await; + let service = BetaFeaturesService::new(pool, PathBuf::from("/nonexistent/path.toml")) + .expect("should create service with empty config"); + + service.ensure_table().await.expect("should create table"); + + let features = service.list().await.expect("should return empty list"); + assert!(features.is_empty()); + } +} diff --git a/forge-extensions/config/src/lib.rs b/forge-extensions/config/src/lib.rs index c36570c48..92d95f0a3 100644 --- a/forge-extensions/config/src/lib.rs +++ b/forge-extensions/config/src/lib.rs @@ -3,9 +3,11 @@ //! This module contains forge-specific configuration functionality. //! For Task 2, this focuses on project-level config management and Omni integration. +pub mod beta_features; pub mod service; pub mod types; +pub use beta_features::{BetaFeature, BetaFeaturesService, FeatureMaturity}; pub use service::ForgeConfigService; pub use types::*; diff --git a/forge-main/beta-features.toml b/forge-main/beta-features.toml new file mode 100644 index 000000000..3e09cbf0b --- /dev/null +++ b/forge-main/beta-features.toml @@ -0,0 +1,13 @@ +# Beta Features Registry +# Add new features here. Users toggle them in Settings > Beta Features. +# +# Usage: +# 1. Add a new [features.your_feature_id] section below +# 2. Use in Rust: services.beta_features.is_enabled("your_feature_id").await? +# 3. Use in React: ... +# 4. Users enable/disable features in Settings > Beta Features + +[features.task_archiving] +name = "Task Archiving" +description = "Hide archived tasks from the main board. Toggle visibility with the Archived button to show the column and drag tasks to unarchive." +maturity = "experimental" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a8a3f41a0..f67911267 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ import { useNamestexerSessionTracking } from '@/hooks/useNamestexerSessionTracki import { AgentSettings, + BetaFeaturesSettings, GeneralSettings, McpSettings, ProjectSettings, @@ -35,6 +36,7 @@ import { MobileNavigationProvider } from '@/contexts/MobileNavigationContext'; import { HotkeysProvider } from 'react-hotkeys-hook'; import { ProjectProvider } from '@/contexts/project-context'; +import { BetaFeaturesProvider } from '@/contexts/beta-features-context'; import { ThemeMode } from 'shared/types'; import * as Sentry from '@sentry/react'; import { Loader } from '@/components/ui/loader'; @@ -329,6 +331,7 @@ function AppContent() { } /> } /> } /> + } /> - - + + + {/* Keep 'global' active at all times so the hotkeys scope stack is never empty */} - - + + + ); diff --git a/frontend/src/components/breadcrumb.tsx b/frontend/src/components/breadcrumb.tsx index a299ebe2a..076573cc3 100644 --- a/frontend/src/components/breadcrumb.tsx +++ b/frontend/src/components/breadcrumb.tsx @@ -49,6 +49,7 @@ import type { LayoutMode } from '@/components/layout/TasksLayout'; import type { Task, GitBranch as GitBranchType } from 'shared/types'; import { projectsApi } from '@/lib/api'; import { GitActionsGroup } from '@/components/breadcrumb/git-actions'; +import { ArchiveButton } from '@/components/tasks/ArchiveButton'; export function Breadcrumb() { const location = useLocation(); @@ -688,8 +689,8 @@ export function Breadcrumb() { })} - {/* Right side: Git status badges */} - {currentTask && ( + {/* Right side: Archive button (board view) or Git status badges (task view) */} + {currentTask ? (
{/* Compact git status badge - only show behind (rebase needed) */} {branchStatus && @@ -778,6 +779,8 @@ export function Breadcrumb() { /> )}
+ ) : ( + )} ); diff --git a/frontend/src/components/layout/navbar.tsx b/frontend/src/components/layout/navbar.tsx index 18d9f0dd5..a2230c0a9 100644 --- a/frontend/src/components/layout/navbar.tsx +++ b/frontend/src/components/layout/navbar.tsx @@ -72,18 +72,16 @@ function ThemeToggle() { className="relative" > ); diff --git a/frontend/src/components/tasks/ArchiveButton.tsx b/frontend/src/components/tasks/ArchiveButton.tsx new file mode 100644 index 000000000..a86cd3c71 --- /dev/null +++ b/frontend/src/components/tasks/ArchiveButton.tsx @@ -0,0 +1,75 @@ +import { useCallback, useMemo } from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { Archive, Eye, EyeOff } from 'lucide-react'; +import { useProject } from '@/contexts/project-context'; +import { useProjectTasks } from '@/hooks/useProjectTasks'; +import { useBetaFeatures } from '@/contexts/beta-features-context'; + +export function ArchiveButton() { + const { t } = useTranslation('tasks'); + const { projectId } = useProject(); + const { taskId } = useParams<{ taskId?: string }>(); + const { tasks } = useProjectTasks(projectId || ''); + const [searchParams, setSearchParams] = useSearchParams(); + + // Beta feature check + const { isEnabled } = useBetaFeatures(); + const taskArchivingEnabled = isEnabled('task_archiving'); + + // Check if showing archived column + const showArchived = searchParams.get('filter') === 'archived'; + + // Count archived tasks + const archivedCount = useMemo(() => { + if (!taskArchivingEnabled) return 0; + return tasks.filter((task) => task.status.toLowerCase() === 'archived') + .length; + }, [tasks, taskArchivingEnabled]); + + // Only show when beta is enabled and we have a project (board view only) + const shouldShow = taskArchivingEnabled && projectId && !taskId; + + const handleClick = useCallback(() => { + const params = new URLSearchParams(searchParams); + if (showArchived) { + params.delete('filter'); + } else { + params.set('filter', 'archived'); + } + setSearchParams(params, { replace: true }); + }, [searchParams, showArchived, setSearchParams]); + + if (!shouldShow) { + return null; + } + + return ( + + ); +} diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx new file mode 100644 index 000000000..0fcecc004 --- /dev/null +++ b/frontend/src/components/ui/switch.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +interface SwitchProps { + id?: string; + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; + className?: string; + disabled?: boolean; +} + +const Switch = React.forwardRef( + ( + { className, checked = false, onCheckedChange, disabled, ...props }, + ref + ) => { + return ( + + ); + } +); +Switch.displayName = 'Switch'; + +export { Switch }; diff --git a/frontend/src/contexts/beta-features-context.tsx b/frontend/src/contexts/beta-features-context.tsx new file mode 100644 index 000000000..52beb7de1 --- /dev/null +++ b/frontend/src/contexts/beta-features-context.tsx @@ -0,0 +1,139 @@ +import { + createContext, + useContext, + ReactNode, + useMemo, + useCallback, +} from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { betaFeaturesApi } from '@/lib/api'; +import type { BetaFeature } from 'shared/forge-types'; + +const BETA_FEATURES_QUERY_KEY = ['beta-features']; + +interface BetaFeaturesContextValue { + features: BetaFeature[]; + loading: boolean; + error: Error | null; + isEnabled: (featureId: string) => boolean; + toggleFeature: (featureId: string) => Promise; + refreshFeatures: () => Promise; +} + +const BetaFeaturesContext = createContext( + null +); + +interface BetaFeaturesProviderProps { + children: ReactNode; +} + +export function BetaFeaturesProvider({ children }: BetaFeaturesProviderProps) { + const queryClient = useQueryClient(); + + const query = useQuery({ + queryKey: BETA_FEATURES_QUERY_KEY, + queryFn: () => betaFeaturesApi.list(), + staleTime: 5 * 60 * 1000, // 5 minutes + retry: 1, + }); + + const toggleMutation = useMutation({ + mutationFn: (featureId: string) => betaFeaturesApi.toggle(featureId), + onSuccess: (updatedFeature) => { + // Update the cache with the new feature state + queryClient.setQueryData( + BETA_FEATURES_QUERY_KEY, + (old) => { + if (!old) return [updatedFeature]; + return old.map((f) => + f.id === updatedFeature.id ? updatedFeature : f + ); + } + ); + }, + }); + + const isEnabled = useCallback( + (featureId: string): boolean => { + const feature = query.data?.find((f) => f.id === featureId); + return feature?.enabled ?? false; + }, + [query.data] + ); + + const toggleFeature = useCallback( + async (featureId: string): Promise => { + await toggleMutation.mutateAsync(featureId); + }, + [toggleMutation] + ); + + const refreshFeatures = useCallback(async (): Promise => { + await queryClient.invalidateQueries({ queryKey: BETA_FEATURES_QUERY_KEY }); + }, [queryClient]); + + const value = useMemo( + () => ({ + features: query.data ?? [], + loading: query.isLoading, + error: query.error, + isEnabled, + toggleFeature, + refreshFeatures, + }), + [ + query.data, + query.isLoading, + query.error, + isEnabled, + toggleFeature, + refreshFeatures, + ] + ); + + return ( + + {children} + + ); +} + +/** + * Hook to access the full beta features context + */ +export function useBetaFeatures(): BetaFeaturesContextValue { + const context = useContext(BetaFeaturesContext); + if (!context) { + throw new Error( + 'useBetaFeatures must be used within a BetaFeaturesProvider' + ); + } + return context; +} + +/** + * Convenience hook to check if a single feature is enabled + */ +export function useBetaFeature(featureId: string): boolean { + const { isEnabled } = useBetaFeatures(); + return isEnabled(featureId); +} + +/** + * Component wrapper for conditional rendering based on beta feature state + */ +interface BetaFeatureGateProps { + feature: string; + children: ReactNode; + fallback?: ReactNode; +} + +export function BetaFeatureGate({ + feature, + children, + fallback = null, +}: BetaFeatureGateProps) { + const enabled = useBetaFeature(feature); + return <>{enabled ? children : fallback}; +} diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 0fe7ee210..e0e820694 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -10,9 +10,18 @@ "agents": "Agents", "agentsDesc": "Coding agent configurations", "mcp": "MCP Servers", - "mcpDesc": "Model Context Protocol servers" + "mcpDesc": "Model Context Protocol servers", + "beta": "Beta Features", + "betaDesc": "Experimental features and early access" } }, + "betaFeatures": { + "title": "Beta Features", + "description": "Enable experimental features to try new functionality before general release.", + "warning": "Beta features are experimental and may be unstable. Use at your own risk.", + "noFeatures": "No beta features available at this time.", + "error": "Failed to load beta features" + }, "general": { "loading": "Loading settings...", "loadError": "Failed to load configuration.", diff --git a/frontend/src/i18n/locales/en/tasks.json b/frontend/src/i18n/locales/en/tasks.json index 2e20843af..2bc806ea6 100644 --- a/frontend/src/i18n/locales/en/tasks.json +++ b/frontend/src/i18n/locales/en/tasks.json @@ -330,6 +330,11 @@ "duplicate": "Duplicate", "archive": "Archive" }, + "archive": { + "title": "Archived", + "showArchived": "Show archived tasks", + "hideArchived": "Hide archived tasks" + }, "showcases": { "taskPanel": { "companion": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 561c49e80..6dca494b3 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -954,3 +954,21 @@ export const approvalsApi = { return handleApiResponse(res); }, }; + +// Beta Features API +import type { BetaFeature } from 'shared/forge-types'; + +export const betaFeaturesApi = { + list: async (): Promise => { + const response = await makeRequest('/api/forge/beta-features'); + return handleApiResponse(response); + }, + + toggle: async (featureId: string): Promise => { + const response = await makeRequest( + `/api/forge/beta-features/${featureId}/toggle`, + { method: 'POST' } + ); + return handleApiResponse(response); + }, +}; diff --git a/frontend/src/pages/project-tasks.tsx b/frontend/src/pages/project-tasks.tsx index 52642a62b..114d4ba0e 100644 --- a/frontend/src/pages/project-tasks.tsx +++ b/frontend/src/pages/project-tasks.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { AlertTriangle, Plus } from 'lucide-react'; +import { useBetaFeatures } from '@/contexts/beta-features-context'; import { Loader } from '@/components/ui/loader'; import { tasksApi } from '@/lib/api'; import { openTaskForm } from '@/lib/openTaskForm'; @@ -59,7 +60,7 @@ import { ChatPanelActions } from '@/components/panels/ChatPanelActions'; type Task = TaskWithAttemptStatus; -const TASK_STATUSES = [ +const ALL_TASK_STATUSES = [ 'todo', 'inprogress', 'inreview', @@ -90,6 +91,21 @@ export function ProjectTasks() { (isMobile && !isLandscape) || (isSmallScreen && !isLandscape); const posthog = usePostHog(); + // Beta feature: task archiving - hide archived column from kanban unless showing archived + const { isEnabled } = useBetaFeatures(); + const taskArchivingEnabled = isEnabled('task_archiving'); + + // Check if showing archived column + const showArchived = searchParams.get('filter') === 'archived'; + + // Filter out archived status when beta feature is enabled (unless showing archived) + const TASK_STATUSES = useMemo(() => { + if (taskArchivingEnabled && !showArchived) { + return ALL_TASK_STATUSES.filter((s) => s !== 'archived'); + } + return ALL_TASK_STATUSES; + }, [taskArchivingEnabled, showArchived]); + const { projectId, isLoading: projectLoading, @@ -311,12 +327,13 @@ export function ProjectTasks() { const normalizedStatus = task.status.toLowerCase(); if (groups[normalizedStatus]) { groups[normalizedStatus].push(task); - } else { + } else if (normalizedStatus !== 'archived' || !taskArchivingEnabled) { + // Only add to 'todo' fallback if it's not an archived task being hidden groups['todo'].push(task); } }); return groups; - }, [filteredTasks]); + }, [filteredTasks, TASK_STATUSES, taskArchivingEnabled]); useKeyNavUp( () => { @@ -477,7 +494,7 @@ export function ProjectTasks() { } } } - }, [selectedTask, groupedFilteredTasks, handleViewTaskDetails]); + }, [selectedTask, groupedFilteredTasks, handleViewTaskDetails, TASK_STATUSES]); const selectPreviousTask = useCallback(() => { if (selectedTask) { @@ -497,7 +514,7 @@ export function ProjectTasks() { } } } - }, [selectedTask, groupedFilteredTasks, handleViewTaskDetails]); + }, [selectedTask, groupedFilteredTasks, handleViewTaskDetails, TASK_STATUSES]); const selectNextColumn = useCallback(() => { if (selectedTask) { @@ -520,7 +537,7 @@ export function ProjectTasks() { } } } - }, [selectedTask, groupedFilteredTasks, handleViewTaskDetails]); + }, [selectedTask, groupedFilteredTasks, handleViewTaskDetails, TASK_STATUSES]); const selectPreviousColumn = useCallback(() => { if (selectedTask) { @@ -543,7 +560,7 @@ export function ProjectTasks() { } } } - }, [selectedTask, groupedFilteredTasks, handleViewTaskDetails]); + }, [selectedTask, groupedFilteredTasks, handleViewTaskDetails, TASK_STATUSES]); const handleDragEnd = useCallback( async (event: DragEndEvent) => { @@ -608,40 +625,39 @@ export function ProjectTasks() { return ; } - const kanbanContent = - tasks.length === 0 ? ( -
- - -

{t('empty.noTasks')}

- -
-
-
- ) : filteredTasks.length === 0 ? ( -
- - -

- {t('empty.noSearchResults')} -

-
-
-
- ) : ( -
- -
- ); + const kanbanContent = tasks.length === 0 ? ( +
+ + +

{t('empty.noTasks')}

+ +
+
+
+ ) : filteredTasks.length === 0 ? ( +
+ + +

+ {t('empty.noSearchResults')} +

+
+
+
+ ) : ( +
+ +
+ ); // Breadcrumb is always shown in the main navbar - no need to duplicate it here const rightHeader = null; diff --git a/frontend/src/pages/settings/BetaFeaturesSettings.tsx b/frontend/src/pages/settings/BetaFeaturesSettings.tsx new file mode 100644 index 000000000..4dd8c4639 --- /dev/null +++ b/frontend/src/pages/settings/BetaFeaturesSettings.tsx @@ -0,0 +1,165 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Switch } from '@/components/ui/switch'; +import { Badge } from '@/components/ui/badge'; +import { Loader2, FlaskConical, AlertTriangle } from 'lucide-react'; +import { useBetaFeatures } from '@/contexts/beta-features-context'; +import type { BetaFeature, FeatureMaturity } from 'shared/forge-types'; + +function MaturityBadge({ maturity }: { maturity: FeatureMaturity }) { + const variants: Record = + { + experimental: { + className: + 'bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/20', + label: 'Experimental', + }, + beta: { + className: + 'bg-blue-500/10 text-blue-500 border-blue-500/20 hover:bg-blue-500/20', + label: 'Beta', + }, + stable: { + className: + 'bg-green-500/10 text-green-500 border-green-500/20 hover:bg-green-500/20', + label: 'Stable', + }, + }; + + const variant = variants[maturity]; + + return ( + + {variant.label} + + ); +} + +function FeatureRow({ + feature, + onToggle, + isToggling, +}: { + feature: BetaFeature; + onToggle: (featureId: string) => void; + isToggling: boolean; +}) { + return ( +
+
+
+ {feature.name} + +
+

{feature.description}

+
+
+ {isToggling ? ( + + ) : ( + onToggle(feature.id)} + /> + )} +
+
+ ); +} + +export function BetaFeaturesSettings() { + const { t } = useTranslation(['settings', 'common']); + const { features, loading, error, toggleFeature } = useBetaFeatures(); + const [togglingId, setTogglingId] = useState(null); + + const handleToggle = async (featureId: string) => { + setTogglingId(featureId); + try { + await toggleFeature(featureId); + } catch (err) { + console.error('Failed to toggle feature:', err); + } finally { + setTogglingId(null); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + + {t('settings.betaFeatures.error', { + defaultValue: 'Failed to load beta features', + })} + + + ); + } + + return ( +
+ + + + + {t('settings.betaFeatures.title', { defaultValue: 'Beta Features' })} + + + {t('settings.betaFeatures.description', { + defaultValue: + 'Enable experimental features to try new functionality before general release.', + })} + + + + + + + {t('settings.betaFeatures.warning', { + defaultValue: + 'Beta features are experimental and may be unstable. Use at your own risk.', + })} + + + + {features.length === 0 ? ( +
+ +

+ {t('settings.betaFeatures.noFeatures', { + defaultValue: 'No beta features available at this time.', + })} +

+
+ ) : ( +
+ {features.map((feature) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/settings/SettingsLayout.tsx b/frontend/src/pages/settings/SettingsLayout.tsx index 63978feef..b25c24f07 100644 --- a/frontend/src/pages/settings/SettingsLayout.tsx +++ b/frontend/src/pages/settings/SettingsLayout.tsx @@ -1,6 +1,6 @@ import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Settings, Cpu, Server, X, FolderOpen, ArrowLeft } from 'lucide-react'; +import { Settings, Cpu, Server, X, FolderOpen, ArrowLeft, FlaskConical } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { useEffect } from 'react'; @@ -27,6 +27,10 @@ const settingsNavigation = [ path: 'mcp', icon: Server, }, + { + path: 'beta', + icon: FlaskConical, + }, ]; export function SettingsLayout() { diff --git a/frontend/src/pages/settings/index.ts b/frontend/src/pages/settings/index.ts index 213fe73d8..06ca53a45 100644 --- a/frontend/src/pages/settings/index.ts +++ b/frontend/src/pages/settings/index.ts @@ -3,3 +3,4 @@ export { GeneralSettings } from './GeneralSettings'; export { ProjectSettings } from './ProjectSettings'; export { AgentSettings } from './AgentSettings'; export { McpSettings } from './McpSettings'; +export { BetaFeaturesSettings } from './BetaFeaturesSettings'; diff --git a/shared/forge-types.ts b/shared/forge-types.ts index 4606b04db..b73e6caa4 100644 --- a/shared/forge-types.ts +++ b/shared/forge-types.ts @@ -18,3 +18,7 @@ export type OmniInstance = { instance_name: string, channel_type: string, displa export type SendTextRequest = { phone_number: string | null, user_id: string | null, text: string, }; export type SendTextResponse = { success: boolean, message_id: string | null, status: string, error: string | null, }; + +export type BetaFeature = { id: string, name: string, description: string, maturity: FeatureMaturity, enabled: boolean, }; + +export type FeatureMaturity = "experimental" | "beta" | "stable";