diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index 4c15acf536..f6f701a25d 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -14,7 +14,6 @@ export const experimentIds = [ "runSlashCommand", "multipleNativeToolCalls", "customTools", - "hooks", ] as const export const experimentIdsSchema = z.enum(experimentIds) @@ -33,7 +32,6 @@ export const experimentsSchema = z.object({ runSlashCommand: z.boolean().optional(), multipleNativeToolCalls: z.boolean().optional(), customTools: z.boolean().optional(), - hooks: z.boolean().optional(), }) export type Experiments = z.infer diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 790c3ce630..0402162f67 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -540,23 +540,17 @@ export class Task extends EventEmitter implements TaskLike { } }) - // Initialize tool execution hooks (only if hooks experiment is enabled) - const hooksExperimentEnabled = experiments.isEnabled(experimentsConfig ?? {}, EXPERIMENT_IDS.HOOKS) + // Initialize tool execution hooks this.toolExecutionHooks = createToolExecutionHooks( - hooksExperimentEnabled ? (provider.getHookManager() ?? null) : null, + provider.getHookManager() ?? null, (status) => provider.postHookStatusToWebview(status), async (type, text) => { await this.say(type as ClineSay, text) }, - // Getter for global hooksEnabled state - checks both experiment and user setting + // Getter for global hooksEnabled state () => { - const experimentEnabled = experiments.isEnabled( - provider.contextProxy.getValue("experiments") ?? {}, - EXPERIMENT_IDS.HOOKS, - ) // Default to true if hooksEnabled is undefined (backwards compatibility) - const userEnabled = provider.contextProxy.getValue("hooksEnabled") ?? true - return experimentEnabled && userEnabled + return provider.contextProxy.getValue("hooksEnabled") ?? true }, ) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 786ce1fd41..749112113b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -56,7 +56,7 @@ import { findLast } from "../../shared/array" import { supportPrompt } from "../../shared/support-prompt" import { GlobalFileNames } from "../../shared/globalFileNames" import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes" -import { experimentDefault, experiments, EXPERIMENT_IDS } from "../../shared/experiments" +import { experimentDefault } from "../../shared/experiments" import { formatLanguage } from "../../shared/language" import { WebviewMessage } from "../../shared/WebviewMessage" import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels" @@ -144,6 +144,9 @@ export class ClineProvider protected mcpHub?: McpHub // Change from private to protected protected skillsManager?: SkillsManager protected hookManager?: IHookManager + private hookFileWatchers: vscode.FileSystemWatcher[] = [] + private hookReloadTimeout?: NodeJS.Timeout + private static readonly HOOK_RELOAD_DEBOUNCE_MS = 500 private marketplaceManager: MarketplaceManager private mdmService?: MdmService private taskCreationCallback: (task: Task) => void @@ -623,6 +626,7 @@ export class ClineProvider this.mcpHub = undefined await this.skillsManager?.dispose() this.skillsManager = undefined + this.disposeHookManager() this.marketplaceManager?.cleanup() this.customModesManager?.dispose() this.log("Disposed all disposables") @@ -2654,6 +2658,7 @@ export class ClineProvider * Initialize the Hook Manager for lifecycle hooks. * This loads hooks configuration from project/.roo/hooks/ files. * Only initializes if the hooks experiment is enabled. + * Sets up file watchers for automatic config reloading. */ public async initializeHookManager(): Promise { const cwd = this.currentWorkspacePath || getWorkspacePath() @@ -2665,12 +2670,6 @@ export class ClineProvider try { const state = await this.getState() - // Check if hooks experiment is enabled - if (!experiments.isEnabled(state?.experiments ?? {}, EXPERIMENT_IDS.HOOKS)) { - this.log("[HookManager] Hooks experiment is disabled, skipping initialization") - return - } - this.hookManager = createHookManager({ cwd, mode: state?.mode, @@ -2685,6 +2684,12 @@ export class ClineProvider // Load hooks configuration await this.hookManager.loadHooksConfig() this.log("[HookManager] Hooks loaded successfully") + + // Set up file watchers for hook configuration files + this.setupHookFileWatchers(cwd, state?.mode) + + // Notify webview of hook state + await this.postStateToWebview() } catch (error) { this.log( `[HookManager] Failed to initialize hooks: ${error instanceof Error ? error.message : String(error)}`, @@ -2694,6 +2699,109 @@ export class ClineProvider } } + /** + * Set up file watchers for hook configuration files. + * Watches .roo/hooks/, ~/.roo/hooks/, and mode-specific directories. + */ + private setupHookFileWatchers(cwd: string, mode?: string): void { + // Clean up any existing watchers first + this.disposeHookFileWatchers() + + const watchPatterns: string[] = [] + + // Project hooks: .roo/hooks/*.{json,yaml,yml} + const projectHooksPattern = new vscode.RelativePattern( + vscode.Uri.file(path.join(cwd, ".roo", "hooks")), + "*.{json,yaml,yml}", + ) + watchPatterns.push(".roo/hooks/*.{json,yaml,yml}") + + // Mode-specific hooks: .roo/hooks-{mode}/*.{json,yaml,yml} + let modeHooksPattern: vscode.RelativePattern | undefined + if (mode) { + modeHooksPattern = new vscode.RelativePattern( + vscode.Uri.file(path.join(cwd, ".roo", `hooks-${mode}`)), + "*.{json,yaml,yml}", + ) + watchPatterns.push(`.roo/hooks-${mode}/*.{json,yaml,yml}`) + } + + // Global hooks: ~/.roo/hooks/*.{json,yaml,yml} + const globalHooksPattern = new vscode.RelativePattern( + vscode.Uri.file(path.join(os.homedir(), ".roo", "hooks")), + "*.{json,yaml,yml}", + ) + watchPatterns.push("~/.roo/hooks/*.{json,yaml,yml}") + + this.log(`[HookManager] Setting up file watchers for: ${watchPatterns.join(", ")}`) + + // Create debounced reload handler + const debouncedReload = () => { + if (this.hookReloadTimeout) { + clearTimeout(this.hookReloadTimeout) + } + this.hookReloadTimeout = setTimeout(async () => { + this.log("[HookManager] Config file changed, reloading hooks...") + await this.reloadHooksConfig() + await this.postStateToWebview() + }, ClineProvider.HOOK_RELOAD_DEBOUNCE_MS) + } + + // Create watchers for each pattern + const projectWatcher = vscode.workspace.createFileSystemWatcher(projectHooksPattern) + projectWatcher.onDidCreate(debouncedReload) + projectWatcher.onDidChange(debouncedReload) + projectWatcher.onDidDelete(debouncedReload) + this.hookFileWatchers.push(projectWatcher) + + if (modeHooksPattern) { + const modeWatcher = vscode.workspace.createFileSystemWatcher(modeHooksPattern) + modeWatcher.onDidCreate(debouncedReload) + modeWatcher.onDidChange(debouncedReload) + modeWatcher.onDidDelete(debouncedReload) + this.hookFileWatchers.push(modeWatcher) + } + + const globalWatcher = vscode.workspace.createFileSystemWatcher(globalHooksPattern) + globalWatcher.onDidCreate(debouncedReload) + globalWatcher.onDidChange(debouncedReload) + globalWatcher.onDidDelete(debouncedReload) + this.hookFileWatchers.push(globalWatcher) + + this.log(`[HookManager] File watchers set up successfully (${this.hookFileWatchers.length} watchers)`) + } + + /** + * Dispose hook file watchers. + */ + private disposeHookFileWatchers(): void { + if (this.hookReloadTimeout) { + clearTimeout(this.hookReloadTimeout) + this.hookReloadTimeout = undefined + } + + for (const watcher of this.hookFileWatchers) { + watcher.dispose() + } + this.hookFileWatchers = [] + } + + /** + * Dispose the Hook Manager and clean up resources. + * Called when hooks experiment is disabled. + */ + public disposeHookManager(): void { + this.log("[HookManager] Disposing hook manager...") + + // Dispose file watchers + this.disposeHookFileWatchers() + + // Clear the hook manager reference + this.hookManager = undefined + + this.log("[HookManager] Hook manager disposed") + } + /** * Get the Hook Manager instance. */ diff --git a/src/core/webview/__tests__/ClineProvider.hooks-dynamic-init.spec.ts b/src/core/webview/__tests__/ClineProvider.hooks-dynamic-init.spec.ts new file mode 100644 index 0000000000..512441e612 --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.hooks-dynamic-init.spec.ts @@ -0,0 +1,349 @@ +// npx vitest run core/webview/__tests__/ClineProvider.hooks-dynamic-init.spec.ts + +import type { IHookManager } from "../../../services/hooks/types" + +// Mock vscode before importing ClineProvider +vi.mock("vscode", () => { + const mockFileSystemWatcher = { + onDidCreate: vi.fn().mockReturnValue({ dispose: vi.fn() }), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), + onDidDelete: vi.fn().mockReturnValue({ dispose: vi.fn() }), + dispose: vi.fn(), + } + + return { + window: { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), + showTextDocument: vi.fn().mockResolvedValue(undefined), + createWebviewPanel: vi.fn(), + }, + workspace: { + workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], + openTextDocument: vi.fn().mockResolvedValue({ uri: { fsPath: "/mock/file" } }), + createFileSystemWatcher: vi.fn().mockReturnValue(mockFileSystemWatcher), + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn(), + update: vi.fn(), + }), + }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), + }, + Uri: { + file: vi.fn((path: string) => ({ fsPath: path })), + joinPath: vi.fn((_base: { fsPath: string }, ...segments: string[]) => ({ + fsPath: `${_base.fsPath}/${segments.join("/")}`, + })), + }, + RelativePattern: vi.fn().mockImplementation((_base: unknown, _pattern: string) => ({ _base, _pattern })), + EventEmitter: vi.fn().mockImplementation(() => ({ + event: vi.fn(), + fire: vi.fn(), + dispose: vi.fn(), + })), + ConfigurationTarget: { + Global: 1, + Workspace: 2, + }, + ViewColumn: { + One: 1, + }, + } +}) + +vi.mock("fs/promises", () => ({ + default: { + mkdir: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + writeFile: vi.fn().mockResolvedValue(undefined), + access: vi.fn().mockResolvedValue(undefined), + readdir: vi.fn().mockResolvedValue([]), + }, + mkdir: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + writeFile: vi.fn().mockResolvedValue(undefined), + access: vi.fn().mockResolvedValue(undefined), + readdir: vi.fn().mockResolvedValue([]), +})) + +vi.mock("os", () => ({ + default: { + homedir: vi.fn().mockReturnValue("/mock/home"), + }, + homedir: vi.fn().mockReturnValue("/mock/home"), +})) + +vi.mock("../../../services/hooks", () => ({ + createHookManager: vi.fn().mockImplementation(() => createMockHookManager()), +})) + +vi.mock("../../../utils/path", () => ({ + getWorkspacePath: vi.fn().mockReturnValue("/mock/workspace"), +})) + +import * as vscode from "vscode" +import { createHookManager } from "../../../services/hooks" + +// Create mock HookManager +const createMockHookManager = (): IHookManager => ({ + loadHooksConfig: vi.fn().mockResolvedValue({ + hooksByEvent: new Map(), + hooksById: new Map(), + loadedAt: new Date(), + disabledHookIds: new Set(), + hasProjectHooks: false, + }), + reloadHooksConfig: vi.fn().mockResolvedValue(undefined), + getEnabledHooks: vi.fn().mockReturnValue([]), + executeHooks: vi.fn().mockResolvedValue({ + results: [], + blocked: false, + totalDuration: 0, + }), + setHookEnabled: vi.fn().mockResolvedValue(undefined), + getHookExecutionHistory: vi.fn().mockReturnValue([]), + getConfigSnapshot: vi.fn().mockReturnValue({ + hooksByEvent: new Map(), + hooksById: new Map(), + loadedAt: new Date(), + disabledHookIds: new Set(), + hasProjectHooks: false, + }), +}) + +describe("ClineProvider - Hook Dynamic Initialization", () => { + let mockHookManager: IHookManager + + beforeEach(() => { + vi.clearAllMocks() + mockHookManager = createMockHookManager() + vi.mocked(createHookManager).mockReturnValue(mockHookManager) + vi.mocked(vscode.workspace.createFileSystemWatcher).mockClear() + }) + + describe("initializeHookManager", () => { + it("should always initialize hook manager (hooks is a core feature)", async () => { + // Create a mock provider-like object to test the logic + const cwd = "/mock/workspace" + const state = { mode: "code" } + + // Hooks are always initialized - no experiment check needed + const newHookManager = createHookManager({ + cwd, + mode: state.mode, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }) + + await newHookManager.loadHooksConfig() + + expect(createHookManager).toHaveBeenCalledWith({ + cwd, + mode: state.mode, + logger: expect.any(Object), + }) + expect(newHookManager.loadHooksConfig).toHaveBeenCalled() + }) + + it("should set up file watchers for hook configuration files", async () => { + const cwd = "/mock/workspace" + const mode = "code" + + // Simulate setupHookFileWatchers + // Project hooks pattern + const projectPattern = new vscode.RelativePattern(vscode.Uri.file(`${cwd}/.roo/hooks`), "*.{json,yaml,yml}") + + // Mode-specific hooks pattern + const modePattern = new vscode.RelativePattern( + vscode.Uri.file(`${cwd}/.roo/hooks-${mode}`), + "*.{json,yaml,yml}", + ) + + // Global hooks pattern + const globalPattern = new vscode.RelativePattern( + vscode.Uri.file("/mock/home/.roo/hooks"), + "*.{json,yaml,yml}", + ) + + // Create watchers + vscode.workspace.createFileSystemWatcher(projectPattern) + vscode.workspace.createFileSystemWatcher(modePattern) + vscode.workspace.createFileSystemWatcher(globalPattern) + + expect(vscode.workspace.createFileSystemWatcher).toHaveBeenCalledTimes(3) + expect(vscode.RelativePattern).toHaveBeenCalledWith(expect.any(Object), "*.{json,yaml,yml}") + }) + + it("should register change event handlers on file watchers", async () => { + const watcher = vscode.workspace.createFileSystemWatcher({} as vscode.GlobPattern) + + watcher.onDidCreate(vi.fn()) + watcher.onDidChange(vi.fn()) + watcher.onDidDelete(vi.fn()) + + expect(watcher.onDidCreate).toHaveBeenCalled() + expect(watcher.onDidChange).toHaveBeenCalled() + expect(watcher.onDidDelete).toHaveBeenCalled() + }) + }) + + describe("disposeHookManager", () => { + it("should dispose all file watchers", () => { + const hookFileWatchers: { dispose: ReturnType }[] = [ + { dispose: vi.fn() }, + { dispose: vi.fn() }, + { dispose: vi.fn() }, + ] + + // Simulate disposeHookFileWatchers + for (const watcher of hookFileWatchers) { + watcher.dispose() + } + + expect(hookFileWatchers[0].dispose).toHaveBeenCalled() + expect(hookFileWatchers[1].dispose).toHaveBeenCalled() + expect(hookFileWatchers[2].dispose).toHaveBeenCalled() + }) + + it("should clear the hook manager reference", () => { + let hookManager: IHookManager | undefined = createMockHookManager() + + // Simulate disposeHookManager + hookManager = undefined + + expect(hookManager).toBeUndefined() + }) + + it("should clear pending reload timeout", () => { + vi.useFakeTimers() + + let hookReloadTimeout: NodeJS.Timeout | undefined = setTimeout(() => {}, 500) + + // Simulate clearing timeout in disposeHookFileWatchers + if (hookReloadTimeout) { + clearTimeout(hookReloadTimeout) + hookReloadTimeout = undefined + } + + expect(hookReloadTimeout).toBeUndefined() + + vi.useRealTimers() + }) + }) + + describe("debounced reload", () => { + it("should debounce multiple file change events", async () => { + vi.useFakeTimers() + + const HOOK_RELOAD_DEBOUNCE_MS = 500 + const mockReload = vi.fn() + let hookReloadTimeout: NodeJS.Timeout | undefined + + // Simulate debounced reload function + const debouncedReload = () => { + if (hookReloadTimeout) { + clearTimeout(hookReloadTimeout) + } + hookReloadTimeout = setTimeout(async () => { + await mockReload() + }, HOOK_RELOAD_DEBOUNCE_MS) + } + + // Trigger multiple change events rapidly + debouncedReload() + debouncedReload() + debouncedReload() + + // Should not have called reload yet + expect(mockReload).not.toHaveBeenCalled() + + // Advance time by debounce duration + await vi.advanceTimersByTimeAsync(HOOK_RELOAD_DEBOUNCE_MS) + + // Should have called reload exactly once + expect(mockReload).toHaveBeenCalledTimes(1) + + vi.useRealTimers() + }) + + it("should reset debounce timer on each new change event", async () => { + vi.useFakeTimers() + + const HOOK_RELOAD_DEBOUNCE_MS = 500 + const mockReload = vi.fn() + let hookReloadTimeout: NodeJS.Timeout | undefined + + const debouncedReload = () => { + if (hookReloadTimeout) { + clearTimeout(hookReloadTimeout) + } + hookReloadTimeout = setTimeout(async () => { + await mockReload() + }, HOOK_RELOAD_DEBOUNCE_MS) + } + + // First change + debouncedReload() + + // Advance time partially + await vi.advanceTimersByTimeAsync(300) + + // Second change should reset the timer + debouncedReload() + + // Advance time by original debounce (should not trigger because timer was reset) + await vi.advanceTimersByTimeAsync(300) + expect(mockReload).not.toHaveBeenCalled() + + // Advance remaining time + await vi.advanceTimersByTimeAsync(200) + expect(mockReload).toHaveBeenCalledTimes(1) + + vi.useRealTimers() + }) + }) +}) + +describe("File Watcher Patterns", () => { + it("should watch project hooks directory with correct pattern", () => { + const cwd = "/test/workspace" + const expectedPath = `${cwd}/.roo/hooks` + const expectedPattern = "*.{json,yaml,yml}" + + new vscode.RelativePattern(vscode.Uri.file(expectedPath), expectedPattern) + + expect(vscode.Uri.file).toHaveBeenCalledWith(expectedPath) + expect(vscode.RelativePattern).toHaveBeenCalledWith(expect.any(Object), expectedPattern) + }) + + it("should watch mode-specific hooks directory when mode is provided", () => { + const cwd = "/test/workspace" + const mode = "architect" + const expectedPath = `${cwd}/.roo/hooks-${mode}` + + new vscode.RelativePattern(vscode.Uri.file(expectedPath), "*.{json,yaml,yml}") + + expect(vscode.Uri.file).toHaveBeenCalledWith(expectedPath) + }) + + it("should watch global hooks directory", () => { + const homedir = "/mock/home" + const expectedPath = `${homedir}/.roo/hooks` + + new vscode.RelativePattern(vscode.Uri.file(expectedPath), "*.{json,yaml,yml}") + + expect(vscode.Uri.file).toHaveBeenCalledWith(expectedPath) + }) + + it("should support json, yaml, and yml file extensions", () => { + const expectedPattern = "*.{json,yaml,yml}" + new vscode.RelativePattern(vscode.Uri.file("/any/path"), expectedPattern) + + expect(vscode.RelativePattern).toHaveBeenCalledWith(expect.any(Object), expectedPattern) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 056500e79a..539102273c 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -640,18 +640,6 @@ export const webviewMessageHandler = async ( ...oldExperiments, ...(value as Record), } - - // Check if hooks experiment was just enabled - const newExperiments = newValue as Record - if ( - !experiments.isEnabled(oldExperiments, EXPERIMENT_IDS.HOOKS) && - experiments.isEnabled(newExperiments, EXPERIMENT_IDS.HOOKS) - ) { - // Initialize HookManager when hooks experiment is enabled - provider.initializeHookManager().catch((error) => { - provider.log(`Failed to initialize Hook Manager after experiment enable: ${error}`) - }) - } } else if (key === "customSupportPrompts") { if (!value) { continue @@ -3355,11 +3343,6 @@ export const webviewMessageHandler = async ( // ===================================================================== case "hooksReloadConfig": { - // Check if hooks experiment is enabled - const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault - if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) { - break - } // Reload hooks configuration from all sources const hookManager = provider.getHookManager() if (hookManager) { @@ -3377,11 +3360,6 @@ export const webviewMessageHandler = async ( } case "hooksSetEnabled": { - // Check if hooks experiment is enabled - const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault - if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) { - break - } // Enable or disable a specific hook const hookManager = provider.getHookManager() if (hookManager && message.hookId && typeof message.hookEnabled === "boolean") { @@ -3399,11 +3377,6 @@ export const webviewMessageHandler = async ( } case "hooksSetAllEnabled": { - // Check if hooks experiment is enabled - const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault - if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) { - break - } // Enable or disable hooks globally via the master toggle. // This is stored in global state and checked before executing any hook. if (typeof message.hooksEnabled === "boolean") { @@ -3421,11 +3394,6 @@ export const webviewMessageHandler = async ( } case "hooksOpenConfigFolder": { - // Check if hooks experiment is enabled - const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault - if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) { - break - } // Open the hooks configuration folder in VS Code const source = message.hooksSource ?? "project" try { @@ -3456,11 +3424,6 @@ export const webviewMessageHandler = async ( } case "hooksDeleteHook": { - // Check if hooks experiment is enabled - const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault - if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) { - break - } const hookManager = provider.getHookManager() if (!hookManager || !message.hookId) { break @@ -3567,11 +3530,6 @@ export const webviewMessageHandler = async ( } case "hooksOpenHookFile": { - // Check if hooks experiment is enabled - const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault - if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) { - break - } const { filePath: hookFilePath } = message if (!hookFilePath) { return @@ -3595,12 +3553,6 @@ export const webviewMessageHandler = async ( } case "hooksCreateNew": { - // Check if hooks experiment is enabled - const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault - if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) { - break - } - try { const cwd = provider.cwd const hooksPath = path.join(cwd, ".roo", "hooks") diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index 18a3f5a09b..0b43302611 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -33,7 +33,6 @@ describe("experiments", () => { runSlashCommand: false, multipleNativeToolCalls: false, customTools: false, - hooks: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) }) @@ -47,7 +46,6 @@ describe("experiments", () => { runSlashCommand: false, multipleNativeToolCalls: false, customTools: false, - hooks: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(true) }) @@ -61,7 +59,6 @@ describe("experiments", () => { runSlashCommand: false, multipleNativeToolCalls: false, customTools: false, - hooks: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) }) diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index 3e5f1a7ce2..ad3aeca863 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -8,7 +8,6 @@ export const EXPERIMENT_IDS = { RUN_SLASH_COMMAND: "runSlashCommand", MULTIPLE_NATIVE_TOOL_CALLS: "multipleNativeToolCalls", CUSTOM_TOOLS: "customTools", - HOOKS: "hooks", } as const satisfies Record type _AssertExperimentIds = AssertEqual>> @@ -27,7 +26,6 @@ export const experimentConfigsMap: Record = { RUN_SLASH_COMMAND: { enabled: false }, MULTIPLE_NATIVE_TOOL_CALLS: { enabled: false }, CUSTOM_TOOLS: { enabled: false }, - HOOKS: { enabled: false }, } export const experimentDefault = Object.fromEntries( diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index ae29e830d7..01c4db6fa7 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -66,18 +66,15 @@ const App = () => { cloudOrganizations, renderContext, mdmCompliant, - experiments, } = useExtensionState() - // Hooks auto-reload + // Hooks auto-reload (always enabled since hooks is a permanent feature) useEffect(() => { - if (experiments?.hooks) { - const intervalId = setInterval(() => { - vscode.postMessage({ type: "hooksReloadConfig" }) - }, 5000) - return () => clearInterval(intervalId) - } - }, [experiments?.hooks]) + const intervalId = setInterval(() => { + vscode.postMessage({ type: "hooksReloadConfig" }) + }, 5000) + return () => clearInterval(intervalId) + }, []) // Create a persistent state manager const marketplaceStateManager = useMemo(() => new MarketplaceViewStateManager(), []) diff --git a/webview-ui/src/__tests__/App.spec.tsx b/webview-ui/src/__tests__/App.spec.tsx index 00666e8ed9..c35bae815e 100644 --- a/webview-ui/src/__tests__/App.spec.tsx +++ b/webview-ui/src/__tests__/App.spec.tsx @@ -173,14 +173,14 @@ describe("App", () => { }) }) - it("auto-reloads hooks config every 5 seconds when hooks experiment is enabled", () => { + it("auto-reloads hooks config every 5 seconds", () => { vi.useFakeTimers() mockUseExtensionState.mockReturnValue({ didHydrateState: true, showWelcome: false, shouldShowAnnouncement: false, - experiments: { hooks: true }, + experiments: {}, language: "en", telemetrySetting: "enabled", }) @@ -212,29 +212,6 @@ describe("App", () => { vi.useRealTimers() }) - it("does not auto-reload hooks config when hooks experiment is disabled", () => { - vi.useFakeTimers() - - mockUseExtensionState.mockReturnValue({ - didHydrateState: true, - showWelcome: false, - shouldShowAnnouncement: false, - experiments: { hooks: false }, - language: "en", - telemetrySetting: "enabled", - }) - - render() - - // Advance time by 5s - act(() => { - vi.advanceTimersByTime(5000) - }) - expect(vscode.postMessage).not.toHaveBeenCalledWith({ type: "hooksReloadConfig" }) - - vi.useRealTimers() - }) - afterEach(() => { cleanup() window.removeEventListener("message", () => {}) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index b7cf9efdec..9cc234c981 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -521,8 +521,8 @@ const SettingsView = forwardRef(({ onDone, t } }, []) - const sections: { id: SectionName; icon: LucideIcon }[] = useMemo(() => { - const allSections: { id: SectionName; icon: LucideIcon }[] = [ + const sections: { id: SectionName; icon: LucideIcon }[] = useMemo( + () => [ { id: "providers", icon: Plug }, { id: "modes", icon: Users2 }, { id: "mcp", icon: Server }, @@ -539,10 +539,9 @@ const SettingsView = forwardRef(({ onDone, t { id: "experimental", icon: FlaskConical }, { id: "language", icon: Globe }, { id: "about", icon: Info }, - ] - // Filter out hooks section if the experiment is not enabled - return allSections.filter((section) => section.id !== "hooks" || experiments?.hooks === true) - }, [experiments?.hooks]) + ], + [], + ) // Update target section logic to set active tab useEffect(() => { @@ -885,8 +884,8 @@ const SettingsView = forwardRef(({ onDone, t {/* MCP Section */} {renderTab === "mcp" && } - {/* Hooks Section - only render if experiment is enabled */} - {renderTab === "hooks" && experiments?.hooks === true && } + {/* Hooks Section */} + {renderTab === "hooks" && } {/* Prompts Section */} {renderTab === "prompts" && ( diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index a83ec803ba..daf02a4fc0 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -897,7 +897,7 @@ }, "HOOKS": { "name": "Habilitar Hooks", - "description": "Utilitza ordres de shell personalitzades per automatitzar accions abans o després de l'execució d'eines. (Cal reiniciar després de desar la configuració)" + "description": "Utilitza ordres de shell personalitzades per automatitzar accions abans o després de l'execució d'eines." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 73abda2d4e..d536d617ed 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -897,7 +897,7 @@ }, "HOOKS": { "name": "Hooks aktivieren", - "description": "Verwende benutzerdefinierte Shell-Befehle, um Aktionen vor oder nach der Tool-Ausführung zu automatisieren. (Neustart erforderlich nach dem Speichern der Einstellungen)" + "description": "Verwende benutzerdefinierte Shell-Befehle, um Aktionen vor oder nach der Tool-Ausführung zu automatisieren." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 63e64c7bee..9822c0c85f 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -906,7 +906,7 @@ }, "HOOKS": { "name": "Enable Hooks", - "description": "Use custom shell commands to automate actions before or after tool execution. (Restart required after saving settings)" + "description": "Use custom shell commands to automate actions before or after tool execution." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index bd52c726e7..8c257bd0da 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -897,7 +897,7 @@ }, "HOOKS": { "name": "Habilitar Hooks", - "description": "Usa comandos de shell personalizados para automatizar acciones antes o después de la ejecución de herramientas. (Se requiere reinicio después de guardar la configuración)" + "description": "Usa comandos de shell personalizados para automatizar acciones antes o después de la ejecución de herramientas." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 64376fd05e..d6e87f5d9e 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -898,7 +898,7 @@ }, "HOOKS": { "name": "Abilita Hooks", - "description": "Usa comandi shell personalizzati per automatizzare azioni prima o dopo l'esecuzione degli strumenti. (Richiede riavvio dopo aver salvato le impostazioni)" + "description": "Usa comandi shell personalizzati per automatizzare azioni prima o dopo l'esecuzione degli strumenti." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index c562ced587..9a89abfc92 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -898,7 +898,7 @@ }, "HOOKS": { "name": "Hook'ları Etkinleştir", - "description": "Araç yürütmeden önce veya sonra eylemleri otomatikleştirmek için özel kabuk komutları kullanın. (Ayarları kaydettikten sonra yeniden başlatma gerekli)" + "description": "Araç yürütmeden önce veya sonra eylemleri otomatikleştirmek için özel kabuk komutları kullanın." } }, "promptCaching": {