diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index a9a2e6a6b5..4c1cdb3284 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -15,6 +15,7 @@ import { logger } from "../../utils/logging" import { GlobalFileNames } from "../../shared/globalFileNames" import { ensureSettingsDirectoryExists } from "../../utils/globalContext" import { t } from "../../i18n" +import { ModeFamiliesManager } from "./ModeFamiliesManager" const ROOMODES_FILENAME = ".roomodes" @@ -55,6 +56,7 @@ export class CustomModesManager { constructor( private readonly context: vscode.ExtensionContext, private readonly onUpdate: () => Promise, + private readonly modeFamiliesManager?: ModeFamiliesManager, ) { this.watchCustomModesFiles().catch((error) => { console.error("[CustomModesManager] Failed to setup file watchers:", error) @@ -356,6 +358,10 @@ export class CustomModesManager { // Check if we have a valid cached result. const now = Date.now() + // Include active family ID in cache key for proper invalidation + const activeFamilyId = this.modeFamiliesManager ? await this.modeFamiliesManager.getActiveFamily().then(f => f?.id || null) : null + const cacheKey = `customModes:${activeFamilyId}` + if (this.cachedModes && now - this.cachedAt < CustomModesManager.cacheTTL) { return this.cachedModes } @@ -394,10 +400,22 @@ export class CustomModesManager { await this.context.globalState.update("customModes", mergedModes) - this.cachedModes = mergedModes + // Apply family filtering if ModeFamiliesManager is available + let filteredModes = mergedModes + if (this.modeFamiliesManager) { + try { + filteredModes = await this.modeFamiliesManager.getFilteredModes(mergedModes) + } catch (error) { + console.error("[CustomModesManager] Error filtering modes by family:", error) + // Fall back to returning all modes if filtering fails + filteredModes = mergedModes + } + } + + this.cachedModes = filteredModes this.cachedAt = now - return mergedModes + return filteredModes } public async updateCustomMode(slug: string, config: ModeConfig): Promise { @@ -1002,6 +1020,95 @@ export class CustomModesManager { this.cachedAt = 0 } + /** + * Clear cache when family changes occur + * This ensures that mode filtering updates when families are modified + */ + public clearCacheForFamilyChange(): void { + this.clearCache() + } + + /** + * Get all modes without family filtering (bypasses family restrictions) + * This is useful for administrative operations or when all modes are needed + */ + public async getAllModes(): Promise { + // Check if we have a valid cached result for all modes. + const now = Date.now() + const cacheKey = "allModes" + + if (this.cachedModes && now - this.cachedAt < CustomModesManager.cacheTTL) { + return this.cachedModes + } + + // Get modes from settings file. + const settingsPath = await this.getCustomModesFilePath() + const settingsModes = await this.loadModesFromFile(settingsPath) + + // Get modes from .roomodes if it exists. + const roomodesPath = await this.getWorkspaceRoomodes() + const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : [] + + // Create maps to store modes by source. + const projectModes = new Map() + const globalModes = new Map() + + // Add project modes (they take precedence). + for (const mode of roomodesModes) { + projectModes.set(mode.slug, { ...mode, source: "project" as const }) + } + + // Add global modes. + for (const mode of settingsModes) { + if (!projectModes.has(mode.slug)) { + globalModes.set(mode.slug, { ...mode, source: "global" as const }) + } + } + + // Combine modes in the correct order: project modes first, then global modes. + const mergedModes = [ + ...roomodesModes.map((mode) => ({ ...mode, source: "project" as const })), + ...settingsModes + .filter((mode) => !projectModes.has(mode.slug)) + .map((mode) => ({ ...mode, source: "global" as const })), + ] + + await this.context.globalState.update("customModes", mergedModes) + + this.cachedModes = mergedModes + this.cachedAt = now + + return mergedModes + } + + /** + * Check if family filtering is currently active + */ + public isFamilyFilteringActive(): boolean { + return this.modeFamiliesManager !== undefined + } + + /** + * Get the current active family information if family filtering is enabled + */ + public async getActiveFamilyInfo(): Promise<{ isActive: boolean; familyName?: string; familyId?: string }> { + if (!this.modeFamiliesManager) { + return { isActive: false } + } + + try { + const activeFamily = await this.modeFamiliesManager.getActiveFamily() + return { + isActive: !!activeFamily, + familyName: activeFamily?.name, + familyId: activeFamily?.id, + } + } catch (error) { + console.error("[CustomModesManager] Error getting active family info:", error) + return { isActive: false } + } + } + dispose(): void { for (const disposable of this.disposables) { disposable.dispose() diff --git a/src/core/config/ModeFamiliesManager.ts b/src/core/config/ModeFamiliesManager.ts new file mode 100644 index 0000000000..48ec84dd3a --- /dev/null +++ b/src/core/config/ModeFamiliesManager.ts @@ -0,0 +1,690 @@ +import * as vscode from "vscode" +import { type ModeConfig } from "@roo-code/types" +import { logger } from "../../utils/logging" +import { t } from "../../i18n" + +/** + * Interface for mode validation result + */ +export interface ModeValidationResult { + valid: string[] + invalid: string[] + missing: string[] + warnings: string[] +} + +/** + * Interface for family conflict resolution options + */ +export interface ConflictResolutionOptions { + removeInvalidModes?: boolean + addMissingModes?: boolean + strategy: 'strict' | 'lenient' | 'warn-only' +} + +/** + * Interface for a mode family configuration + */ +export interface ModeFamily { + id: string + name: string + description?: string + enabledModeSlugs: string[] + isDefault?: boolean + createdAt: number + updatedAt: number +} + +/** + * Interface for the complete mode families configuration + */ +export interface ModeFamiliesConfig { + activeFamilyId?: string + families: Record + lastUpdated: number +} + +/** + * Manager class for handling mode families functionality + * Allows users to organize custom modes into groups and control which modes are active + */ +export class ModeFamiliesManager { + private static readonly STORAGE_KEY = "modeFamilies" + private static readonly CACHE_TTL = 10_000 // 10 seconds + + private cachedConfig: ModeFamiliesConfig | null = null + private cachedAt: number = 0 + private disposables: vscode.Disposable[] = [] + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly onUpdate: () => Promise, + ) { + // Initialize with comprehensive family setup for new installations + this.initializeFamilies().catch((error) => { + logger.error("Failed to initialize mode families", { error }) + }) + } + + /** + * Comprehensive initialization for both new and existing installations + * Creates default family if none exist, validates existing families, and handles migrations + */ + private async initializeFamilies(): Promise { + try { + let config = await this.loadConfigFromStorage() + + // If no families exist, create comprehensive default family + if (!config.families || Object.keys(config.families).length === 0) { + await this.createDefaultAllModesFamily() + return + } + + // Validate existing families for conflicts and missing modes + const validationResults = await this.validateAllFamilies(config) + + if (validationResults.hasConflicts) { + logger.warn("Found mode conflicts in existing families", { + invalidModes: validationResults.invalidModes, + missingModes: validationResults.missingModes + }) + + // Apply conflict resolution based on strategy + config = await this.resolveFamilyConflicts(config, { + removeInvalidModes: true, + addMissingModes: false, + strategy: 'lenient' + }) + } + + // Ensure default family exists and is up to date + await this.ensureDefaultFamily(config) + + // Update configuration if changes were made + if (validationResults.hasConflicts || await this.needsDefaultFamilyUpdate(config)) { + await this.saveConfigToStorage(config) + logger.info("Updated mode families during initialization") + } + + } catch (error) { + logger.error("Error during family initialization", { error }) + } + } + + /** + * Create the default "All Modes" family with all available modes + */ + private async createDefaultAllModesFamily(): Promise { + try { + const allModes = await this.getAllAvailableModes() + const modeSlugs = allModes.map(mode => mode.slug) + + const defaultFamily: ModeFamily = { + id: "default-all-modes", + name: "All Modes", + description: "All available modes are enabled", + enabledModeSlugs: [], // Empty array means all modes are enabled + isDefault: true, + createdAt: Date.now(), + updatedAt: Date.now(), + } + + const config: ModeFamiliesConfig = { + activeFamilyId: defaultFamily.id, + families: { + [defaultFamily.id]: defaultFamily, + }, + lastUpdated: Date.now(), + } + + await this.saveConfigToStorage(config) + logger.info("Created comprehensive default mode family", { + totalModes: modeSlugs.length, + modeSlugs + }) + } catch (error) { + logger.error("Error creating default family", { error }) + } + } + + /** + * Enhanced mode validation with detailed feedback + */ + public async validateFamilyModes(family: ModeFamily): Promise { + try { + const allModes = await this.getAllAvailableModes() + const availableSlugs = new Set(allModes.map(mode => mode.slug)) + const familySlugs = new Set(family.enabledModeSlugs) + + const valid: string[] = [] + const invalid: string[] = [] + const missing: string[] = [] + const warnings: string[] = [] + + // Check each mode in the family + for (const slug of familySlugs) { + if (availableSlugs.has(slug)) { + valid.push(slug) + } else { + invalid.push(slug) + } + } + + // Check for missing modes that could be added + for (const mode of allModes) { + if (!familySlugs.has(mode.slug)) { + missing.push(mode.slug) + } + } + + // Generate warnings for potential issues + if (invalid.length > 0) { + warnings.push(`${invalid.length} mode(s) in family no longer exist: ${invalid.join(', ')}`) + } + + if (missing.length > 0 && familySlugs.size > 0) { + warnings.push(`${missing.length} available mode(s) not included in family: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? '...' : ''}`) + } + + return { valid, invalid, missing, warnings } + } catch (error) { + logger.error("Error validating family modes", { familyId: family.id, error }) + return { + valid: [], + invalid: family.enabledModeSlugs, + missing: [], + warnings: [`Validation error: ${error instanceof Error ? error.message : String(error)}`] + } + } + } + + /** + * Validate all families for conflicts and issues + */ + private async validateAllFamilies(config: ModeFamiliesConfig): Promise<{ + hasConflicts: boolean + invalidModes: string[] + missingModes: string[] + }> { + const allModes = await this.getAllAvailableModes() + const availableSlugs = new Set(allModes.map(mode => mode.slug)) + + let hasConflicts = false + const invalidModes: string[] = [] + const missingModes: string[] = [] + + for (const family of Object.values(config.families)) { + const validation = await this.validateFamilyModes(family) + + if (validation.invalid.length > 0) { + hasConflicts = true + invalidModes.push(...validation.invalid) + } + + if (validation.missing.length > 0) { + missingModes.push(...validation.missing) + } + } + + return { hasConflicts, invalidModes: [...new Set(invalidModes)], missingModes: [...new Set(missingModes)] } + } + + /** + * Resolve conflicts in families based on resolution strategy + */ + private async resolveFamilyConflicts( + config: ModeFamiliesConfig, + options: ConflictResolutionOptions + ): Promise { + const updatedConfig = { ...config } + updatedConfig.families = { ...config.families } + + for (const [familyId, family] of Object.entries(updatedConfig.families)) { + const validation = await this.validateFamilyModes(family) + + if (validation.invalid.length === 0 && validation.missing.length === 0) { + continue // No conflicts to resolve + } + + const updatedFamily = { ...family } + + // Remove invalid modes if requested + if (options.removeInvalidModes && validation.invalid.length > 0) { + updatedFamily.enabledModeSlugs = family.enabledModeSlugs.filter( + slug => !validation.invalid.includes(slug) + ) + logger.info("Removed invalid modes from family", { + familyId, + removedModes: validation.invalid + }) + } + + // Add missing modes if requested (but not for default family with empty enabledModeSlugs) + if (options.addMissingModes && + validation.missing.length > 0 && + !(family.isDefault && family.enabledModeSlugs.length === 0)) { + updatedFamily.enabledModeSlugs = [ + ...new Set([...family.enabledModeSlugs, ...validation.missing]) + ] + logger.info("Added missing modes to family", { + familyId, + addedModes: validation.missing + }) + } + + updatedFamily.updatedAt = Date.now() + updatedConfig.families[familyId] = updatedFamily + } + + updatedConfig.lastUpdated = Date.now() + return updatedConfig + } + + /** + * Check if default family needs updating with new modes + */ + private async needsDefaultFamilyUpdate(config: ModeFamiliesConfig): Promise { + const defaultFamily = Object.values(config.families).find(f => f.isDefault) + if (!defaultFamily || defaultFamily.enabledModeSlugs.length > 0) { + return false // Only update default family if it has empty enabledModeSlugs (meaning all modes) + } + + const allModes = await this.getAllAvailableModes() + const currentModeCount = allModes.length + + // If the number of modes has changed, we might need to update + // This is a simple heuristic - in a real implementation, you might track mode versions + return currentModeCount > 0 // For now, always check if there are modes available + } + + /** + * Ensure default family exists and is current + */ + private async ensureDefaultFamily(config: ModeFamiliesConfig): Promise { + const defaultFamily = Object.values(config.families).find(f => f.isDefault) + + if (!defaultFamily) { + logger.warn("No default family found, creating one") + await this.createDefaultAllModesFamily() + return + } + + // Update default family if it needs it + if (await this.needsDefaultFamilyUpdate(config)) { + const allModes = await this.getAllAvailableModes() + const modeSlugs = allModes.map(mode => mode.slug) + + // Update the default family (keep empty enabledModeSlugs to mean "all modes") + defaultFamily.updatedAt = Date.now() + config.lastUpdated = Date.now() + + logger.info("Updated default family", { totalModes: modeSlugs.length }) + } + } + + /** + * Load configuration from VS Code global state storage + */ + private async loadConfigFromStorage(): Promise { + const stored = this.context.globalState.get(ModeFamiliesManager.STORAGE_KEY) + + if (!stored) { + return { + families: {}, + lastUpdated: Date.now(), + } + } + + return stored + } + + /** + * Save configuration to VS Code global state storage + */ + private async saveConfigToStorage(config: ModeFamiliesConfig): Promise { + await this.context.globalState.update(ModeFamiliesManager.STORAGE_KEY, config) + this.clearCache() + await this.onUpdate() + } + + /** + * Clear the configuration cache + */ + private clearCache(): void { + this.cachedConfig = null + this.cachedAt = 0 + } + + /** + * Check if the cache is still valid + */ + private isCacheValid(): boolean { + return this.cachedConfig !== null && (Date.now() - this.cachedAt) < ModeFamiliesManager.CACHE_TTL + } + + /** + * Get the current mode families configuration + */ + private async getConfig(): Promise { + if (this.isCacheValid() && this.cachedConfig) { + return this.cachedConfig + } + + const config = await this.loadConfigFromStorage() + this.cachedConfig = config + this.cachedAt = Date.now() + + return config + } + + /** + * Generate a unique ID for a new family + */ + private generateFamilyId(name: string): string { + // Create a slug-like ID from the name + const slug = name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .trim() + + // Add timestamp to ensure uniqueness + return `${slug}-${Date.now()}` + } + + /** + * Validate family name uniqueness + */ + private async validateFamilyName(name: string, excludeId?: string): Promise { + const config = await this.getConfig() + return !Object.values(config.families).some( + (family) => family.name.toLowerCase() === name.toLowerCase() && family.id !== excludeId + ) + } + + /** + * Get all mode families + */ + public async getFamilies(): Promise { + try { + const config = await this.getConfig() + return Object.values(config.families) + } catch (error) { + logger.error("Failed to get mode families", { error }) + return [] + } + } + + /** + * Create a new mode family with enhanced validation + */ + public async createFamily( + name: string, + description?: string, + enabledModeSlugs: string[] = [], + ): Promise<{ success: boolean; family?: ModeFamily; error?: string }> { + try { + // Validate inputs + if (!name || name.trim().length === 0) { + return { success: false, error: "Family name is required" } + } + + // Check for duplicate names + if (!(await this.validateFamilyName(name))) { + return { success: false, error: "A family with this name already exists" } + } + + // Validate mode slugs if provided + if (enabledModeSlugs.length > 0) { + const allModes = await this.getAllAvailableModes() + const availableSlugs = new Set(allModes.map(mode => mode.slug)) + + const invalidSlugs = enabledModeSlugs.filter(slug => !availableSlugs.has(slug)) + if (invalidSlugs.length > 0) { + return { + success: false, + error: `The following modes do not exist: ${invalidSlugs.join(', ')}` + } + } + } + + // Create the new family + const family: ModeFamily = { + id: this.generateFamilyId(name), + name: name.trim(), + description: description?.trim(), + enabledModeSlugs, + createdAt: Date.now(), + updatedAt: Date.now(), + } + + // Update configuration + const config = await this.getConfig() + config.families[family.id] = family + config.lastUpdated = Date.now() + + await this.saveConfigToStorage(config) + + logger.info("Created new mode family", { familyId: family.id, name }) + return { success: true, family } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error("Failed to create mode family", { name, error: errorMessage }) + return { success: false, error: `Failed to create family: ${errorMessage}` } + } + } + + /** + * Update an existing mode family with enhanced validation + */ + public async updateFamily( + id: string, + updates: Partial>, + ): Promise<{ success: boolean; family?: ModeFamily; error?: string }> { + try { + const config = await this.getConfig() + + if (!config.families[id]) { + return { success: false, error: "Family not found" } + } + + // Validate name uniqueness if name is being updated + if (updates.name && !(await this.validateFamilyName(updates.name, id))) { + return { success: false, error: "A family with this name already exists" } + } + + // Validate mode slugs if being updated + if (updates.enabledModeSlugs) { + const allModes = await this.getAllAvailableModes() + const availableSlugs = new Set(allModes.map(mode => mode.slug)) + + const invalidSlugs = updates.enabledModeSlugs.filter(slug => !availableSlugs.has(slug)) + if (invalidSlugs.length > 0) { + return { + success: false, + error: `The following modes do not exist: ${invalidSlugs.join(', ')}` + } + } + } + + // Update the family + const updatedFamily: ModeFamily = { + ...config.families[id], + ...updates, + updatedAt: Date.now(), + } + + config.families[id] = updatedFamily + config.lastUpdated = Date.now() + + await this.saveConfigToStorage(config) + + logger.info("Updated mode family", { familyId: id }) + return { success: true, family: updatedFamily } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error("Failed to update mode family", { id, error: errorMessage }) + return { success: false, error: `Failed to update family: ${errorMessage}` } + } + } + + /** + * Delete a mode family + */ + public async deleteFamily(id: string): Promise<{ success: boolean; error?: string }> { + try { + const config = await this.getConfig() + + if (!config.families[id]) { + return { success: false, error: "Family not found" } + } + + // Don't allow deletion of the default family + if (config.families[id].isDefault) { + return { success: false, error: "Cannot delete the default family" } + } + + // If this is the active family, switch to default + if (config.activeFamilyId === id) { + const defaultFamily = Object.values(config.families).find(f => f.isDefault) + if (defaultFamily) { + config.activeFamilyId = defaultFamily.id + } else { + config.activeFamilyId = undefined + } + } + + // Delete the family + delete config.families[id] + config.lastUpdated = Date.now() + + await this.saveConfigToStorage(config) + + logger.info("Deleted mode family", { familyId: id }) + return { success: true } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error("Failed to delete mode family", { id, error: errorMessage }) + return { success: false, error: `Failed to delete family: ${errorMessage}` } + } + } + + /** + * Set the active family + */ + public async setActiveFamily(id: string): Promise<{ success: boolean; error?: string }> { + try { + const config = await this.getConfig() + + if (!config.families[id]) { + return { success: false, error: "Family not found" } + } + + config.activeFamilyId = id + config.lastUpdated = Date.now() + + await this.saveConfigToStorage(config) + + logger.info("Set active mode family", { familyId: id }) + return { success: true } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error("Failed to set active family", { id, error: errorMessage }) + return { success: false, error: `Failed to set active family: ${errorMessage}` } + } + } + + /** + * Get the currently active family + */ + public async getActiveFamily(): Promise { + try { + const config = await this.getConfig() + + if (!config.activeFamilyId || !config.families[config.activeFamilyId]) { + return null + } + + return config.families[config.activeFamilyId] + } catch (error) { + logger.error("Failed to get active family", { error }) + return null + } + } + + /** + * Get filtered modes based on the active family + */ + public async getFilteredModes(allModes: ModeConfig[]): Promise { + try { + const activeFamily = await this.getActiveFamily() + + if (!activeFamily) { + // No active family, return all modes + return allModes + } + + // If the family has no specific enabled modes, return all modes + if (activeFamily.enabledModeSlugs.length === 0) { + return allModes + } + + // Filter modes based on enabled mode slugs + const enabledSlugs = new Set(activeFamily.enabledModeSlugs) + return allModes.filter(mode => enabledSlugs.has(mode.slug)) + } catch (error) { + logger.error("Failed to get filtered modes", { error }) + // Return all modes on error to maintain functionality + return allModes + } + } + + /** + * Get all available modes (both built-in and custom) + * This is a helper method to get modes from the existing system + */ + private async getAllAvailableModes(): Promise { + try { + // Import built-in modes + const { DEFAULT_MODES } = await import("../../shared/modes") + + // Get custom modes from storage (simplified - in real implementation, + // this would integrate with CustomModesManager) + const customModes = this.context.globalState.get("customModes") || [] + + // Combine built-in and custom modes + return [...DEFAULT_MODES, ...customModes] + } catch (error) { + logger.error("Failed to get all available modes", { error }) + // Return empty array as fallback + return [] + } + } + + /** + * Reset all families to default state with comprehensive setup + */ + public async resetFamilies(): Promise<{ success: boolean; error?: string }> { + try { + await this.createDefaultAllModesFamily() + + logger.info("Reset mode families to default state") + return { success: true } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error("Failed to reset mode families", { error: errorMessage }) + return { success: false, error: `Failed to reset families: ${errorMessage}` } + } + } + + /** + * Clean up resources + */ + public dispose(): void { + for (const disposable of this.disposables) { + disposable.dispose() + } + this.disposables = [] + } +} \ No newline at end of file diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2c20d0939c..16db7b3fce 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -86,6 +86,7 @@ import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/provi import { ContextProxy } from "../config/ContextProxy" import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" +import { ModeFamiliesManager } from "../config/ModeFamiliesManager" import { Task } from "../task/Task" import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt" @@ -147,6 +148,7 @@ export class ClineProvider public readonly latestAnnouncementId = "sep-2025-code-supernova-1m" // Code Supernova 1M context window announcement public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager + public readonly modeFamiliesManager: ModeFamiliesManager constructor( readonly context: vscode.ExtensionContext, @@ -174,10 +176,16 @@ export class ClineProvider this.providerSettingsManager = new ProviderSettingsManager(this.context) - this.customModesManager = new CustomModesManager(this.context, async () => { + // Initialize ModeFamiliesManager first + this.modeFamiliesManager = new ModeFamiliesManager(this.context, async () => { await this.postStateToWebview() }) + // Initialize CustomModesManager with ModeFamiliesManager dependency + this.customModesManager = new CustomModesManager(this.context, async () => { + await this.postStateToWebview() + }, this.modeFamiliesManager) + // Initialize MCP Hub through the singleton manager McpServerManager.getInstance(this.context, this) .then((hub) => { @@ -609,6 +617,7 @@ export class ClineProvider this.mcpHub = undefined this.marketplaceManager?.cleanup() this.customModesManager?.dispose() + this.modeFamiliesManager?.dispose() this.log("Disposed all disposables") ClineProvider.activeInstances.delete(this) diff --git a/webview-ui/src/components/modes/FamilyContextIndicator.tsx b/webview-ui/src/components/modes/FamilyContextIndicator.tsx new file mode 100644 index 0000000000..ddcf967548 --- /dev/null +++ b/webview-ui/src/components/modes/FamilyContextIndicator.tsx @@ -0,0 +1,109 @@ +import React from "react" +import { Badge } from "@src/components/ui/badge" +import { Button } from "@src/components/ui/button" +import { StandardTooltip } from "@src/components/ui/standard-tooltip" +import { ModeFamily } from "@src/components/settings/types" +import { vscode } from "@src/utils/vscode" + +interface FamilyContextIndicatorProps { + activeFamily: ModeFamily | null + hasFamilies: boolean + onManageFamilies?: () => void + className?: string +} + +export const FamilyContextIndicator: React.FC = ({ + activeFamily, + hasFamilies, + onManageFamilies, + className = "", +}) => { + // If no families exist, show helpful indicator + if (!hasFamilies) { + return ( +
+ No families + + + +
+ ) + } + + // If family mode is not active, show indicator with option to enable + if (!activeFamily) { + return ( +
+ + All Modes + + + + +
+ ) + } + + // Show active family context + return ( +
+
+ + + {activeFamily.name} + + + ({activeFamily.enabledModes.length} modes) + +
+ +
+ + + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/webview-ui/src/components/modes/FamilySwitcher.tsx b/webview-ui/src/components/modes/FamilySwitcher.tsx new file mode 100644 index 0000000000..dee2c96823 --- /dev/null +++ b/webview-ui/src/components/modes/FamilySwitcher.tsx @@ -0,0 +1,107 @@ +import React from "react" +import { Button } from "@src/components/ui/button" +import { Badge } from "@src/components/ui/badge" +import { StandardTooltip } from "@src/components/ui/standard-tooltip" +import { ModeFamily } from "@src/components/settings/types" +import { ChevronDown, Check } from "lucide-react" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@src/components/ui/popover" +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, +} from "@src/components/ui/command" + +interface FamilySwitcherProps { + families: ModeFamily[] + activeFamily: ModeFamily | null + onFamilyChange: (familyId: string | null) => void + className?: string +} + +export const FamilySwitcher: React.FC = ({ + families, + activeFamily, + onFamilyChange, + className = "", +}) => { + const [open, setOpen] = React.useState(false) + + // Don't show switcher if no families exist + if (families.length === 0) { + return null + } + + return ( +
+ + + + + + + + No families found. + + {/* "All Modes" option */} + { + onFamilyChange(null) + setOpen(false) + }}> +
+ All Modes + {!activeFamily && ( + + )} +
+
+ + {/* Family options */} + {families.map((family) => ( + { + onFamilyChange(family.id) + setOpen(false) + }}> +
+
+ {family.name} + + {family.enabledModes.length} + +
+ {family.id === activeFamily?.id && ( + + )} +
+
+ ))} +
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index c50996585f..ca3a61013f 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -48,6 +48,8 @@ import { StandardTooltip, } from "@src/components/ui" import { DeleteModeDialog } from "@src/components/modes/DeleteModeDialog" +import { FamilyContextIndicator } from "@src/components/modes/FamilyContextIndicator" +import { FamilySwitcher } from "@src/components/modes/FamilySwitcher" import { useEscapeKey } from "@src/hooks/useEscapeKey" // Get all available groups that should show in prompts view @@ -67,7 +69,7 @@ function getGroupName(group: GroupEntry): ToolGroup { const ModesView = ({ onDone }: ModesViewProps) => { const { t } = useAppTranslation() - const { + const { customModePrompts, listApiConfigMeta, currentApiConfigName, @@ -75,7 +77,11 @@ const ModesView = ({ onDone }: ModesViewProps) => { customInstructions, setCustomInstructions, customModes, - } = useExtensionState() + modeFamilies, + activeFamily, + setActiveFamily, + } = useExtensionState() + // Use a local state to track the visually active mode // This prevents flickering when switching modes rapidly by: @@ -515,7 +521,18 @@ const ModesView = ({ onDone }: ModesViewProps) => {
e.stopPropagation()} className="flex justify-between items-center mb-3"> -

{t("prompts:modes.title")}

+
+

{t("prompts:modes.title")}

+ 0} + onManageFamilies={() => { + vscode.postMessage({ + type: "openCustomModesSettings", + }) + }} + /> +
-
- +
+ { + if (familyId) { + const family = modeFamilies?.config.families.find(f => f.id === familyId) + if (family) { + setActiveFamily(family) + vscode.postMessage({ + type: "setActiveModeFamily", + familyId: family.id, + }) + } + } else { + setActiveFamily(null) + vscode.postMessage({ + type: "setActiveModeFamily", + familyId: null, + }) + } + }} + /> + + + + )} + + + + + + + + +
+
+ + {/* Family Description */} + {family.description && ( +

+ {family.description} +

+ )} + + {/* Mode Toggle Grid */} +
+ + onModeToggle(family.id, modeSlug, enabled) + } + /> +
+ + {/* Family Metadata */} +
+ + Created: {new Date(family.createdAt).toLocaleDateString()} + + {family.updatedAt !== family.createdAt && ( + + Updated: {new Date(family.updatedAt).toLocaleDateString()} + + )} +
+
+ ))} +
+ ) +} \ No newline at end of file diff --git a/webview-ui/src/components/settings/ModeFamiliesSettings.tsx b/webview-ui/src/components/settings/ModeFamiliesSettings.tsx new file mode 100644 index 0000000000..0fedb6ec0e --- /dev/null +++ b/webview-ui/src/components/settings/ModeFamiliesSettings.tsx @@ -0,0 +1,319 @@ +import React, { useState, useEffect, useCallback } from "react" +import { Plus, Settings, Trash2, Users, Edit, Check, X } from "lucide-react" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { vscode } from "@src/utils/vscode" +import { Button } from "@src/components/ui/button" +import { Input } from "@src/components/ui/input" +import { Badge } from "@src/components/ui/badge" +import { StandardTooltip } from "@src/components/ui/standard-tooltip" +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogCancel, + AlertDialogAction, +} from "@src/components/ui/alert-dialog" +import { SectionHeader } from "./SectionHeader" +import { Section } from "./Section" +import { ModeFamiliesState, ModeFamily } from "./types" +import { ModeFamilyEditor } from "./ModeFamilyEditor" +import { FamilyList } from "./FamilyList" +import { ModeToggleGrid } from "./ModeToggleGrid" + +export const ModeFamiliesSettings: React.FC = () => { + const { t } = useAppTranslation() + + // State management for mode families + const [familiesState, setFamiliesState] = useState({ + config: { + families: [], + activeFamilyId: undefined, + }, + availableModes: [], + isLoading: true, + error: undefined, + }) + + // UI state + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) + const [editingFamily, setEditingFamily] = useState(null) + const [deletingFamily, setDeletingFamily] = useState(null) + const [newFamilyName, setNewFamilyName] = useState("") + const [newFamilyDescription, setNewFamilyDescription] = useState("") + + // Handle messages from VS Code + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + switch (message.type) { + case "modeFamiliesResponse": + setFamiliesState(message.state) + break + case "modeFamiliesUpdated": + // Refresh the families state + vscode.postMessage({ type: "getModeFamilies" }) + break + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, []) + + // Request initial data + useEffect(() => { + vscode.postMessage({ type: "getModeFamilies" }) + }, []) + + // Handle family creation + const handleCreateFamily = useCallback(() => { + if (!newFamilyName.trim()) return + + const newFamily: Omit = { + name: newFamilyName.trim(), + description: newFamilyDescription.trim() || undefined, + enabledModes: [], + isActive: false, + } + + vscode.postMessage({ + type: "createModeFamily", + family: newFamily, + }) + + // Reset form + setNewFamilyName("") + setNewFamilyDescription("") + setIsCreateDialogOpen(false) + }, [newFamilyName, newFamilyDescription]) + + // Handle family update + const handleUpdateFamily = useCallback((familyId: string, updates: Partial) => { + vscode.postMessage({ + type: "updateModeFamily", + familyId, + updates, + }) + }, []) + + // Handle family deletion + const handleDeleteFamily = useCallback((familyId: string) => { + vscode.postMessage({ + type: "deleteModeFamily", + familyId, + }) + setDeletingFamily(null) + }, []) + + // Handle setting active family + const handleSetActiveFamily = useCallback((familyId: string) => { + vscode.postMessage({ + type: "setActiveModeFamily", + familyId, + }) + }, []) + + // Handle mode toggle in family + const handleModeToggle = useCallback((familyId: string, modeSlug: string, enabled: boolean) => { + const family = familiesState.config.families.find(f => f.id === familyId) + if (!family) return + + const updatedModes = enabled + ? [...family.enabledModes, modeSlug] + : family.enabledModes.filter(mode => mode !== modeSlug) + + handleUpdateFamily(familyId, { enabledModes: updatedModes }) + }, [familiesState.config.families, handleUpdateFamily]) + + // Generate unique family name if needed + const generateUniqueFamilyName = useCallback((baseName: string): string => { + const existingNames = familiesState.config.families.map(f => f.name) + if (!existingNames.includes(baseName)) return baseName + + let counter = 2 + let newName = `${baseName} ${counter}` + while (existingNames.includes(newName)) { + counter++ + newName = `${baseName} ${counter}` + } + return newName + }, [familiesState.config.families]) + + // Handle quick family creation + const handleQuickCreate = useCallback(() => { + const baseName = t("settings:modeFamilies.defaultFamilyName") + const uniqueName = generateUniqueFamilyName(baseName) + + setNewFamilyName(uniqueName) + setNewFamilyDescription("") + setIsCreateDialogOpen(true) + }, [generateUniqueFamilyName, t]) + + if (familiesState.isLoading) { + return ( +
+
+ {t("settings:common.loading")} +
+
+ ) + } + + return ( +
+ +
+ +
{t("settings:modeFamilies.title")}
+
+
+ +
+ {/* Header with description and create button */} +
+
+ {t("settings:modeFamilies.description")} +
+ +
+
+ + {t("settings:modeFamilies.totalFamilies", { + count: familiesState.config.families.length + })} + + {familiesState.config.activeFamilyId && ( + + {familiesState.config.families.find(f => f.id === familiesState.config.activeFamilyId)?.name} + {" "} + {t("settings:modeFamilies.active")} + + )} +
+ + +
+
+ + {/* Error display */} + {familiesState.error && ( +
+ {familiesState.error} +
+ )} + + {/* Family List */} + +
+ + {/* Create Family Dialog */} + + + + + {t("settings:modeFamilies.createDialog.title")} + + + {t("settings:modeFamilies.createDialog.description")} + + + +
+
+ + setNewFamilyName(e.target.value)} + placeholder={t("settings:modeFamilies.createDialog.namePlaceholder")} + className="w-full" + /> +
+ +
+ + setNewFamilyDescription(e.target.value)} + placeholder={t("settings:modeFamilies.createDialog.descriptionPlaceholder")} + className="w-full" + /> +
+
+ + + + {t("settings:common.cancel")} + + + {t("settings:modeFamilies.createDialog.create")} + + +
+
+ + {/* Edit Family Modal/Sheet */} + {editingFamily && ( + setEditingFamily(null)} + onSave={(updates) => { + handleUpdateFamily(editingFamily.id, updates) + setEditingFamily(null) + }} + /> + )} + + {/* Delete Confirmation Dialog */} + setDeletingFamily(null)}> + + + + {t("settings:modeFamilies.deleteDialog.title")} + + + {t("settings:modeFamilies.deleteDialog.description", { + familyName: deletingFamily?.name || "" + })} + + + + + {t("settings:common.cancel")} + + deletingFamily && handleDeleteFamily(deletingFamily.id)} + className="bg-vscode-errorForeground hover:bg-vscode-errorForeground/90" + > + {t("settings:common.delete")} + + + + +
+ ) +} + +export default ModeFamiliesSettings \ No newline at end of file diff --git a/webview-ui/src/components/settings/ModeFamilyEditor.tsx b/webview-ui/src/components/settings/ModeFamilyEditor.tsx new file mode 100644 index 0000000000..e9869a657c --- /dev/null +++ b/webview-ui/src/components/settings/ModeFamilyEditor.tsx @@ -0,0 +1,199 @@ +import React, { useState, useEffect } from "react" +import { X, Save } from "lucide-react" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { Button } from "@src/components/ui/button" +import { Input } from "@src/components/ui/input" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@src/components/ui/dialog" +import { ModeFamily } from "./types" +import { ModeToggleGrid } from "./ModeToggleGrid" + +interface ModeFamilyEditorProps { + family: ModeFamily + availableModes: Array<{ + slug: string + name: string + isBuiltIn: boolean + }> + isOpen: boolean + onClose: () => void + onSave: (updates: Partial) => void +} + +export const ModeFamilyEditor: React.FC = ({ + family, + availableModes, + isOpen, + onClose, + onSave, +}) => { + const { t } = useAppTranslation() + + // Local state for editing + const [name, setName] = useState(family.name) + const [description, setDescription] = useState(family.description || "") + const [enabledModes, setEnabledModes] = useState(family.enabledModes) + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + + // Reset state when family changes + useEffect(() => { + setName(family.name) + setDescription(family.description || "") + setEnabledModes(family.enabledModes) + setHasUnsavedChanges(false) + }, [family]) + + // Track changes + useEffect(() => { + const hasChanges = + name !== family.name || + description !== (family.description || "") || + JSON.stringify(enabledModes.sort()) !== JSON.stringify(family.enabledModes.sort()) + + setHasUnsavedChanges(hasChanges) + }, [name, description, enabledModes, family]) + + // Handle mode toggle + const handleModeToggle = (modeSlug: string, enabled: boolean) => { + if (enabled) { + setEnabledModes(prev => [...prev, modeSlug]) + } else { + setEnabledModes(prev => prev.filter(mode => mode !== modeSlug)) + } + } + + // Handle save + const handleSave = () => { + const updates: Partial = { + name: name.trim(), + description: description.trim() || undefined, + enabledModes, + updatedAt: Date.now(), + } + + onSave(updates) + } + + // Handle close with unsaved changes check + const handleClose = () => { + if (hasUnsavedChanges) { + const confirmed = window.confirm(t("settings:modeFamilies.unsavedChangesWarning")) + if (!confirmed) return + } + onClose() + } + + if (!isOpen) return null + + return ( + + + + + Edit Family: {family.name} + + + +
+ {/* Basic Information */} +
+

+ {t("settings:modeFamilies.editDialog.basicInfo")} +

+ +
+
+ + setName(e.target.value)} + placeholder={t("settings:modeFamilies.editDialog.namePlaceholder")} + className="w-full" + /> +
+ +
+ + setDescription(e.target.value)} + placeholder={t("settings:modeFamilies.editDialog.descriptionPlaceholder")} + className="w-full" + /> +
+
+
+ + {/* Mode Selection */} +
+

+ {t("settings:modeFamilies.editDialog.modeSelection")} +

+ +
+ {t("settings:modeFamilies.editDialog.modeSelectionDescription")} +
+ + +
+ + {/* Summary */} +
+

+ {t("settings:modeFamilies.editDialog.summary")} +

+
+

Name: {name}

+

Description: + {description || t("settings:modeFamilies.editDialog.noDescription")} +

+

Modes enabled: + {enabledModes.length} of {availableModes.length} +

+
+
+
+ + {/* Footer */} +
+
+ {hasUnsavedChanges ? ( + + {t("settings:modeFamilies.unsavedChanges")} + + ) : ( + t("settings:modeFamilies.allChangesSaved") + )} +
+ +
+ + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/webview-ui/src/components/settings/ModeToggleGrid.tsx b/webview-ui/src/components/settings/ModeToggleGrid.tsx new file mode 100644 index 0000000000..d073e7b124 --- /dev/null +++ b/webview-ui/src/components/settings/ModeToggleGrid.tsx @@ -0,0 +1,133 @@ +import React from "react" +import { Badge } from "@src/components/ui/badge" +import { Button } from "@src/components/ui/button" +import { StandardTooltip } from "@src/components/ui/standard-tooltip" +import { ModeFamily } from "./types" + +interface ModeToggleGridProps { + family: ModeFamily + availableModes: Array<{ + slug: string + name: string + isBuiltIn: boolean + }> + onModeToggle: (modeSlug: string, enabled: boolean) => void +} + +export const ModeToggleGrid: React.FC = ({ + family, + availableModes, + onModeToggle, +}) => { + // Group modes by type for better organization + const builtInModes = availableModes.filter(mode => mode.isBuiltIn) + const customModes = availableModes.filter(mode => !mode.isBuiltIn) + + return ( +
+ {/* Built-in Modes Section */} + {builtInModes.length > 0 && ( +
+

+ Built-in Modes +

+
+ {builtInModes.map((mode) => { + const isEnabled = family.enabledModes.includes(mode.slug) + return ( +
+
+ + {mode.isBuiltIn ? "Built-in" : "Custom"} + + + {mode.name} + +
+ +
+ ) + })} +
+
+ )} + + {/* Custom Modes Section */} + {customModes.length > 0 && ( +
+

+ Custom Modes +

+
+ {customModes.map((mode) => { + const isEnabled = family.enabledModes.includes(mode.slug) + return ( +
+
+ + {mode.isBuiltIn ? "Built-in" : "Custom"} + + + {mode.name} + +
+ +
+ ) + })} +
+
+ )} + + {/* Empty State */} + {availableModes.length === 0 && ( +
+

No modes available

+
+ )} + + {/* Summary */} + {family.enabledModes.length > 0 && ( +
+ {family.enabledModes.length} of {availableModes.length} modes enabled +
+ )} +
+ ) +} \ No newline at end of file diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 93b1b39e50..7337f669db 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -25,6 +25,7 @@ import { LucideIcon, SquareSlash, Glasses, + Users, } from "lucide-react" import type { ProviderSettings, ExperimentId, TelemetrySetting } from "@roo-code/types" @@ -68,6 +69,7 @@ import { Section } from "./Section" import PromptsSettings from "./PromptsSettings" import { SlashCommandsSettings } from "./SlashCommandsSettings" import { UISettings } from "./UISettings" +import { ModeFamiliesSettings } from "./ModeFamiliesSettings" export const settingsTabsContainer = "flex flex-1 overflow-hidden [&.narrow_.tab-label]:hidden" export const settingsTabList = @@ -93,6 +95,7 @@ const sectionNames = [ "ui", "experimental", "language", + "modeFamilies", "about", ] as const @@ -481,6 +484,7 @@ const SettingsView = forwardRef(({ onDone, t { id: "ui", icon: Glasses }, { id: "experimental", icon: FlaskConical }, { id: "language", icon: Globe }, + { id: "modeFamilies", icon: Users }, { id: "about", icon: Info }, ], [], // No dependencies needed now @@ -807,6 +811,9 @@ const SettingsView = forwardRef(({ onDone, t )} + {/* Mode Families Section */} + {activeTab === "modeFamilies" && } + {/* About Section */} {activeTab === "about" && ( diff --git a/webview-ui/src/components/settings/types.ts b/webview-ui/src/components/settings/types.ts index f4fa4c7832..9b7f79b5ed 100644 --- a/webview-ui/src/components/settings/types.ts +++ b/webview-ui/src/components/settings/types.ts @@ -8,3 +8,70 @@ export type SetCachedStateField = ( ) => void export type SetExperimentEnabled = (id: ExperimentId, enabled: boolean) => void + +// Mode Families Types +export interface ModeFamily { + /** Unique identifier for the family */ + id: string + /** Display name for the family */ + name: string + /** Optional description */ + description?: string + /** Mode slugs that are enabled in this family */ + enabledModes: string[] + /** Whether this family is currently active */ + isActive: boolean + /** Creation timestamp */ + createdAt: number + /** Last modification timestamp */ + updatedAt: number +} + +export interface ModeFamilyConfig { + /** All available families */ + families: ModeFamily[] + /** Currently active family ID */ + activeFamilyId?: string +} + +export interface ModeFamiliesState { + /** Current family configuration */ + config: ModeFamilyConfig + /** Available modes for family assignment */ + availableModes: Array<{ + slug: string + name: string + isBuiltIn: boolean + }> + /** Loading state */ + isLoading: boolean + /** Error state */ + error?: string +} + +// VS Code message types for mode families +export interface ModeFamilyMessage { + type: "createModeFamily" + family: Omit +} + +export interface UpdateModeFamilyMessage { + type: "updateModeFamily" + familyId: string + updates: Partial> +} + +export interface DeleteModeFamilyMessage { + type: "deleteModeFamily" + familyId: string +} + +export interface SetActiveModeFamilyMessage { + type: "setActiveModeFamily" + familyId: string +} + +export interface ModeFamiliesResponse { + type: "modeFamiliesResponse" + state: ModeFamiliesState +} \ No newline at end of file diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 542b2385c0..ea040b2d50 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -24,6 +24,7 @@ import { RouterModels } from "@roo/api" import { vscode } from "@src/utils/vscode" import { convertTextMateToHljs } from "@src/utils/textMateToHljs" +import { ModeFamily, ModeFamilyConfig, ModeFamiliesState } from "@src/components/settings/types" export interface ExtensionStateContextType extends ExtensionState { historyPreviewCollapsed?: boolean // Add the new state property @@ -158,6 +159,11 @@ export interface ExtensionStateContextType extends ExtensionState { setMaxDiagnosticMessages: (value: number) => void includeTaskHistoryInEnhance?: boolean setIncludeTaskHistoryInEnhance: (value: boolean) => void + // Mode Families state + modeFamilies?: ModeFamiliesState + setModeFamilies: (value: ModeFamiliesState) => void + activeFamily?: ModeFamily | null + setActiveFamily: (family: ModeFamily | null) => void } export const ExtensionStateContext = createContext(undefined) @@ -266,6 +272,15 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode maxDiagnosticMessages: 50, openRouterImageApiKey: "", openRouterImageGenerationSelectedModel: "", + // Mode Families state + modeFamilies: { + config: { + families: [], + activeFamilyId: undefined, + }, + availableModes: [], + isLoading: false, + }, }) const [didHydrateState, setDidHydrateState] = useState(false) @@ -559,6 +574,21 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }, includeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance, + modeFamilies: state.modeFamilies, + setModeFamilies: (value) => setState((prevState) => ({ ...prevState, modeFamilies: value })), + activeFamily: state.modeFamilies?.config.families.find(f => f.id === state.modeFamilies.config.activeFamilyId) || null, + setActiveFamily: (family) => { + setState((prevState) => ({ + ...prevState, + modeFamilies: { + ...prevState.modeFamilies!, + config: { + ...prevState.modeFamilies!.config, + activeFamilyId: family?.id, + }, + }, + })) + }, } return {children}