From a7278a4d9f5812ce0893325293b471287f1a7dea Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 3 Feb 2026 15:49:38 -0600 Subject: [PATCH 01/26] feat: add import secrets from another project in SecretsModal - Add 'Import from...' dropdown in the secrets modal that lists other projects - Fetch and merge secrets from selected source project - Skip existing keys (by uppercase comparison) to avoid overwriting - Add UI test to verify import behavior and key preservation --- src/browser/components/SecretsModal.tsx | 75 +++++++- tests/ui/secretsImport.integration.test.ts | 207 +++++++++++++++++++++ 2 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 tests/ui/secretsImport.integration.test.ts diff --git a/src/browser/components/SecretsModal.tsx b/src/browser/components/SecretsModal.tsx index cb5c456b6d..294af9653b 100644 --- a/src/browser/components/SecretsModal.tsx +++ b/src/browser/components/SecretsModal.tsx @@ -9,7 +9,15 @@ import { DialogInfo, } from "@/browser/components/ui/dialog"; import { Button } from "@/browser/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/browser/components/ui/select"; import type { Secret } from "@/common/types/secrets"; +import { useProjectContext } from "@/browser/contexts/ProjectContext"; // Visibility toggle icon component const ToggleVisibilityIcon: React.FC<{ visible: boolean }> = ({ visible }) => { @@ -61,15 +69,20 @@ interface SecretsModalProps { const SecretsModal: React.FC = ({ isOpen, - projectPath: _projectPath, + projectPath, projectName, initialSecrets, onClose, onSave, }) => { + const { projects, getSecrets } = useProjectContext(); const [secrets, setSecrets] = useState(initialSecrets); const [visibleSecrets, setVisibleSecrets] = useState>(new Set()); const [isLoading, setIsLoading] = useState(false); + const [isImporting, setIsImporting] = useState(false); + + // Get other projects (excluding current one) for import dropdown + const otherProjects = Array.from(projects.entries()).filter(([path]) => path !== projectPath); // Reset state when modal opens with new secrets useEffect(() => { @@ -129,6 +142,29 @@ const SecretsModal: React.FC = ({ setVisibleSecrets(newVisible); }; + // Import secrets from another project (doesn't overwrite existing keys) + const handleImportFromProject = async (sourceProjectPath: string) => { + setIsImporting(true); + try { + const sourceSecrets = await getSecrets(sourceProjectPath); + if (sourceSecrets.length === 0) return; + + // Get current keys (normalized to uppercase for comparison) + const existingKeys = new Set(secrets.map((s) => s.key.toUpperCase())); + + // Filter to only new secrets (keys that don't already exist) + const newSecrets = sourceSecrets.filter((s) => !existingKeys.has(s.key.toUpperCase())); + + if (newSecrets.length > 0) { + setSecrets([...secrets, ...newSecrets]); + } + } catch (err) { + console.error("Failed to import secrets:", err); + } finally { + setIsImporting(false); + } + }; + const handleOpenChange = useCallback( (open: boolean) => { if (!open && !isLoading) { @@ -204,13 +240,36 @@ const SecretsModal: React.FC = ({ )} - +
+ + {otherProjects.length > 0 && ( + + )} +
- {otherProjects.length > 0 && ( - - )} - + - - From 2de55e4a59a12fdecaa146984c53bb1bd0d106e7 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 3 Feb 2026 16:18:00 -0600 Subject: [PATCH 05/26] =?UTF-8?q?refactor:=20move=20secrets=20management?= =?UTF-8?q?=20into=20Settings=20=E2=86=92=20Projects=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create new ProjectSecretsSection component for inline secrets editing - Integrate secrets UI into ProjectSettingsSection with proper heading - Remove standalone SecretsModal component - Update ProjectSidebar key icon to open Settings → Projects (via openProjectSettings) - Tooltip changed from 'Manage secrets' to 'Project settings' - Update story: SecretsModalWithImport → ProjectSettingsWithSecrets - Update UI test to work with Settings modal flow The secrets UI now lives in Settings → Projects, keeping all project-level configuration in one place. Import from other projects still works the same way. --- src/browser/components/ProjectSidebar.tsx | 46 +-- src/browser/components/SecretsModal.tsx | 294 ------------------ .../sections/ProjectSecretsSection.tsx | 282 +++++++++++++++++ .../sections/ProjectSettingsSection.tsx | 7 + src/browser/stories/App.sidebar.stories.tsx | 25 +- tests/ui/secretsImport.integration.test.ts | 42 ++- 6 files changed, 331 insertions(+), 365 deletions(-) delete mode 100644 src/browser/components/SecretsModal.tsx create mode 100644 src/browser/components/Settings/sections/ProjectSecretsSection.tsx diff --git a/src/browser/components/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar.tsx index 2e58ef91d9..869758e736 100644 --- a/src/browser/components/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar.tsx @@ -43,8 +43,7 @@ import { import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; import { SidebarCollapseButton } from "./ui/SidebarCollapseButton"; import { ConfirmationModal } from "./ConfirmationModal"; -import SecretsModal from "./SecretsModal"; -import type { Secret } from "@/common/types/secrets"; +import { useSettings } from "@/browser/contexts/SettingsContext"; import { WorkspaceListItem, type WorkspaceSelection } from "./WorkspaceListItem"; import { WorkspaceStatusIndicator } from "./WorkspaceStatusIndicator"; @@ -359,14 +358,13 @@ const ProjectSidebarInner: React.FC = ({ projects, openProjectCreateModal: onAddProject, removeProject: onRemoveProject, - getSecrets: onGetSecrets, - updateSecrets: onUpdateSecrets, createSection, updateSection, removeSection, reorderSections, assignWorkspaceToSection, } = useProjectContext(); + const { openProjectSettings } = useSettings(); // Theme for logo variant const { theme } = useTheme(); @@ -467,12 +465,6 @@ const ProjectSidebarInner: React.FC = ({ } | null>(null); const projectRemoveError = usePopoverError(); const sectionRemoveError = usePopoverError(); - const [secretsModalState, setSecretsModalState] = useState<{ - isOpen: boolean; - projectPath: string; - projectName: string; - secrets: Secret[]; - } | null>(null); const getProjectName = (path: string) => { if (!path || typeof path !== "string") { @@ -608,26 +600,6 @@ const ProjectSidebarInner: React.FC = ({ } }; - const handleOpenSecrets = async (projectPath: string) => { - const secrets = await onGetSecrets(projectPath); - setSecretsModalState({ - isOpen: true, - projectPath, - projectName: getProjectName(projectPath), - secrets, - }); - }; - - const handleSaveSecrets = async (secrets: Secret[]) => { - if (secretsModalState) { - await onUpdateSecrets(secretsModalState.projectPath, secrets); - } - }; - - const handleCloseSecrets = () => { - setSecretsModalState(null); - }; - // UI preference: project order persists in localStorage const [projectOrder, setProjectOrder] = usePersistedState("mux:projectOrder", []); @@ -834,7 +806,7 @@ const ProjectSidebarInner: React.FC = ({ - Manage secrets + Project settings @@ -1304,16 +1276,6 @@ const ProjectSidebarInner: React.FC = ({ side="left" shortcut={formatKeybind(KEYBINDS.TOGGLE_SIDEBAR)} /> - {secretsModalState && ( - - )} = ({ visible }) => { - if (visible) { - // Eye-off icon (with slash) - password is visible - return ( - - - - - ); - } - - // Eye icon - password is hidden - return ( - - - - - ); -}; - -interface SecretsModalProps { - isOpen: boolean; - projectPath: string; - projectName: string; - initialSecrets: Secret[]; - onClose: () => void; - onSave: (secrets: Secret[]) => Promise; -} - -const SecretsModal: React.FC = ({ - isOpen, - projectPath, - projectName, - initialSecrets, - onClose, - onSave, -}) => { - const { projects, getSecrets } = useProjectContext(); - const [secrets, setSecrets] = useState(initialSecrets); - const [visibleSecrets, setVisibleSecrets] = useState>(new Set()); - const [isLoading, setIsLoading] = useState(false); - const [isImporting, setIsImporting] = useState(false); - - // Get other projects (excluding current one) for import dropdown - const otherProjects = Array.from(projects.entries()).filter(([path]) => path !== projectPath); - - // Reset state when modal opens with new secrets - useEffect(() => { - if (isOpen) { - setSecrets(initialSecrets); - setVisibleSecrets(new Set()); - } - }, [isOpen, initialSecrets]); - - const handleCancel = useCallback(() => { - setSecrets(initialSecrets); - setVisibleSecrets(new Set()); - onClose(); - }, [initialSecrets, onClose]); - - const handleSave = async () => { - setIsLoading(true); - try { - // Filter out empty secrets - const validSecrets = secrets.filter((s) => s.key.trim() !== "" && s.value.trim() !== ""); - await onSave(validSecrets); - onClose(); - } catch (err) { - console.error("Failed to save secrets:", err); - } finally { - setIsLoading(false); - } - }; - - const addSecret = () => { - setSecrets([...secrets, { key: "", value: "" }]); - }; - - const removeSecret = (index: number) => { - setSecrets(secrets.filter((_, i) => i !== index)); - // Clean up visibility state - const newVisible = new Set(visibleSecrets); - newVisible.delete(index); - setVisibleSecrets(newVisible); - }; - - const updateSecret = (index: number, field: "key" | "value", value: string) => { - const newSecrets = [...secrets]; - // Auto-capitalize key field for env variable convention - const processedValue = field === "key" ? value.toUpperCase() : value; - newSecrets[index] = { ...newSecrets[index], [field]: processedValue }; - setSecrets(newSecrets); - }; - - const toggleVisibility = (index: number) => { - const newVisible = new Set(visibleSecrets); - if (newVisible.has(index)) { - newVisible.delete(index); - } else { - newVisible.add(index); - } - setVisibleSecrets(newVisible); - }; - - // Import secrets from another project (doesn't overwrite existing keys) - const handleImportFromProject = async (sourceProjectPath: string) => { - setIsImporting(true); - try { - const sourceSecrets = await getSecrets(sourceProjectPath); - if (sourceSecrets.length === 0) return; - - // Use functional update to safely merge with any edits made during the async fetch - setSecrets((current) => { - const existingKeys = new Set(current.map((s) => s.key.toUpperCase())); - const newSecrets = sourceSecrets.filter((s) => !existingKeys.has(s.key.toUpperCase())); - return newSecrets.length > 0 ? [...current, ...newSecrets] : current; - }); - } catch (err) { - console.error("Failed to import secrets:", err); - } finally { - setIsImporting(false); - } - }; - - const handleOpenChange = useCallback( - (open: boolean) => { - if (!open && !isLoading) { - handleCancel(); - } - }, - [isLoading, handleCancel] - ); - - return ( - - - - Manage Secrets - Project: {projectName} - - -

- Secrets are stored in ~/.mux/secrets.json (kept away from source code) but - namespaced per project. -

-

Secrets are injected as environment variables to compute commands (e.g. Bash)

-
- - {otherProjects.length > 0 && ( -
- -
- )} - -
- {secrets.length === 0 ? ( -
- No secrets configured -
- ) : ( -
- - -
{/* Empty cell for eye icon column */} -
{/* Empty cell for delete button column */} - {secrets.map((secret, index) => ( - - updateSecret(index, "key", e.target.value)} - placeholder="SECRET_NAME" - disabled={isLoading} - className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-dim w-full rounded border px-2.5 py-1.5 font-mono text-[13px] text-white focus:outline-none" - /> - updateSecret(index, "value", e.target.value)} - placeholder="secret value" - disabled={isLoading} - className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-dim w-full rounded border px-2.5 py-1.5 font-mono text-[13px] text-white focus:outline-none" - /> - - - - ))} -
- )} -
- - - - - - - - -
- ); -}; - -export default SecretsModal; diff --git a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx new file mode 100644 index 0000000000..4b0e189143 --- /dev/null +++ b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx @@ -0,0 +1,282 @@ +/** + * Project secrets management section for Settings modal. + * Manages environment secrets that are injected into agent tools. + */ + +import React, { useState, useEffect, useCallback } from "react"; +import { Eye, EyeOff, Trash2, Plus, Import, Loader2 } from "lucide-react"; +import { Button } from "@/browser/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/browser/components/ui/select"; +import type { Secret } from "@/common/types/secrets"; +import { useProjectContext } from "@/browser/contexts/ProjectContext"; + +interface ProjectSecretsSectionProps { + projectPath: string; +} + +export const ProjectSecretsSection: React.FC = ({ projectPath }) => { + const { projects, getSecrets, updateSecrets } = useProjectContext(); + const [secrets, setSecrets] = useState([]); + const [visibleSecrets, setVisibleSecrets] = useState>(new Set()); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + const [originalSecrets, setOriginalSecrets] = useState([]); + + // Get other projects (excluding current one) for import dropdown + const otherProjects = Array.from(projects.entries()).filter(([path]) => path !== projectPath); + + // Load secrets when project changes + useEffect(() => { + let cancelled = false; + setIsLoading(true); + setHasChanges(false); + + (async () => { + try { + const loaded = await getSecrets(projectPath); + if (cancelled) return; + setSecrets(loaded); + setOriginalSecrets(loaded); + setVisibleSecrets(new Set()); + } catch (err) { + if (cancelled) return; + console.error("Failed to load secrets:", err); + setSecrets([]); + setOriginalSecrets([]); + } finally { + if (!cancelled) setIsLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [getSecrets, projectPath]); + + // Track changes + useEffect(() => { + const changed = + secrets.length !== originalSecrets.length || + secrets.some( + (s, i) => s.key !== originalSecrets[i]?.key || s.value !== originalSecrets[i]?.value + ); + setHasChanges(changed); + }, [secrets, originalSecrets]); + + const handleSave = async () => { + setIsSaving(true); + try { + // Filter out empty secrets + const validSecrets = secrets.filter((s) => s.key.trim() !== "" && s.value.trim() !== ""); + await updateSecrets(projectPath, validSecrets); + setOriginalSecrets(validSecrets); + setSecrets(validSecrets); + setHasChanges(false); + } catch (err) { + console.error("Failed to save secrets:", err); + } finally { + setIsSaving(false); + } + }; + + const handleDiscard = () => { + setSecrets(originalSecrets); + setHasChanges(false); + }; + + const addSecret = () => { + setSecrets([...secrets, { key: "", value: "" }]); + }; + + const removeSecret = (index: number) => { + setSecrets(secrets.filter((_, i) => i !== index)); + const newVisible = new Set(visibleSecrets); + newVisible.delete(index); + setVisibleSecrets(newVisible); + }; + + const updateSecret = (index: number, field: "key" | "value", value: string) => { + const newSecrets = [...secrets]; + // Auto-capitalize key field for env variable convention + const processedValue = field === "key" ? value.toUpperCase() : value; + newSecrets[index] = { ...newSecrets[index], [field]: processedValue }; + setSecrets(newSecrets); + }; + + const toggleVisibility = (index: number) => { + const newVisible = new Set(visibleSecrets); + if (newVisible.has(index)) { + newVisible.delete(index); + } else { + newVisible.add(index); + } + setVisibleSecrets(newVisible); + }; + + // Import secrets from another project (doesn't overwrite existing keys) + const handleImportFromProject = useCallback( + async (sourceProjectPath: string) => { + setIsImporting(true); + try { + const sourceSecrets = await getSecrets(sourceProjectPath); + if (sourceSecrets.length === 0) return; + + setSecrets((current) => { + const existingKeys = new Set(current.map((s) => s.key.toUpperCase())); + const newSecrets = sourceSecrets.filter((s) => !existingKeys.has(s.key.toUpperCase())); + return newSecrets.length > 0 ? [...current, ...newSecrets] : current; + }); + } catch (err) { + console.error("Failed to import secrets:", err); + } finally { + setIsImporting(false); + } + }, + [getSecrets] + ); + + if (isLoading) { + return ( +
+ + Loading secrets… +
+ ); + } + + return ( +
+ {/* Toolbar with Add + Import */} +
+ + {otherProjects.length > 0 && ( + + )} + {/* Save/Discard buttons when there are changes */} + {hasChanges && ( +
+ + +
+ )} +
+ + {/* Secrets list */} + {secrets.length === 0 ? ( +

No secrets configured yet.

+ ) : ( +
+ {/* Column headers */} +
+ Key + Value +
+
+
+ {/* Secret rows */} + {secrets.map((secret, index) => ( +
+ updateSecret(index, "key", e.target.value)} + placeholder="SECRET_NAME" + disabled={isSaving} + className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-dim h-8 w-full rounded border px-2.5 font-mono text-xs text-white focus:outline-none" + /> + updateSecret(index, "value", e.target.value)} + placeholder="secret value" + disabled={isSaving} + className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-dim h-8 w-full rounded border px-2.5 font-mono text-xs text-white focus:outline-none" + /> + + +
+ ))} +
+ )} + + {/* Info text */} +

+ Secrets are stored in ~/.mux/secrets.json and injected + as environment variables to agent tools. +

+
+ ); +}; diff --git a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx index 38518960fc..b6e3482bc5 100644 --- a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx @@ -42,6 +42,7 @@ import { } from "@/browser/utils/mcpHeaders"; import { ToolSelector } from "@/browser/components/ToolSelector"; import { KebabMenu, type KebabMenuItem } from "@/browser/components/KebabMenu"; +import { ProjectSecretsSection } from "./ProjectSecretsSection"; /** Component for managing tool allowlist for a single MCP server */ const ToolAllowlistSection: React.FC<{ @@ -1205,6 +1206,12 @@ export const ProjectSettingsSection: React.FC = () => {
+ {/* Secrets */} +
+

Secrets

+ +
+ {/* MCP Servers */}

MCP Servers

diff --git a/src/browser/stories/App.sidebar.stories.tsx b/src/browser/stories/App.sidebar.stories.tsx index fd5d0942e4..6b72552b69 100644 --- a/src/browser/stories/App.sidebar.stories.tsx +++ b/src/browser/stories/App.sidebar.stories.tsx @@ -686,11 +686,11 @@ export const WorkspaceDraftSelected: AppStory = { }; /** - * Secrets modal with import functionality. - * Shows the modal for managing project secrets with the ability to import from other projects. - * Uses play function to open the modal via the key icon button on a project header. + * Project settings with secrets management. + * Shows the Settings modal → Projects section with secrets for multiple projects. + * Uses play function to open via the key icon button on a project header (now opens Settings → Projects). */ -export const SecretsModalWithImport: AppStory = { +export const ProjectSettingsWithSecrets: AppStory = { render: () => ( { @@ -743,26 +743,27 @@ export const SecretsModalWithImport: AppStory = { // Wait for the projects to load await waitFor( () => { - const secretsButton = canvasElement.querySelector( + const settingsButton = canvasElement.querySelector( '[aria-label="Manage secrets for target-project"]' ); - if (!secretsButton) throw new Error("Secrets button not found"); + if (!settingsButton) throw new Error("Project settings button not found"); }, { timeout: 5000 } ); - // Click the secrets button (key icon) for target-project - const secretsButton = canvasElement.querySelector( + // Click the key icon for target-project (now opens Settings → Projects) + const settingsButton = canvasElement.querySelector( '[aria-label="Manage secrets for target-project"]' )!; - await userEvent.click(secretsButton); + await userEvent.click(settingsButton); - // Wait for modal to open (portaled to body) + // Wait for Settings modal to open (portaled to body) await waitFor( () => { const modal = document.body.querySelector('[role="dialog"]'); - if (!modal) throw new Error("Secrets modal not found"); - within(modal).getByText("Manage Secrets"); + if (!modal) throw new Error("Settings modal not found"); + // Should show Projects section with Secrets heading + within(modal).getByText("Secrets"); }, { timeout: 5000 } ); diff --git a/tests/ui/secretsImport.integration.test.ts b/tests/ui/secretsImport.integration.test.ts index f5dcace3f7..12425a9860 100644 --- a/tests/ui/secretsImport.integration.test.ts +++ b/tests/ui/secretsImport.integration.test.ts @@ -1,7 +1,7 @@ /** * UI integration tests for the secrets import feature. * - * Tests that secrets can be imported from one project to another via the modal, + * Tests that secrets can be imported from one project to another via Settings → Projects, * and that existing secrets are not overwritten. */ @@ -63,29 +63,31 @@ describeIntegration("Secrets Import (UI)", () => { () => { const sidebar = view!.container.querySelector('[aria-label="Projects"]'); if (!sidebar) throw new Error("Project sidebar not found"); - // Check that projects are loaded - look for the manage secrets button - const secretsButton = view!.container.querySelector( + // Check that projects are loaded - look for the project settings button (key icon) + const settingsButton = view!.container.querySelector( `[aria-label="Manage secrets for ${targetProjectName}"]` ); - if (!secretsButton) - throw new Error(`Secrets button for target project not found: ${targetProjectName}`); + if (!settingsButton) + throw new Error( + `Project settings button for target project not found: ${targetProjectName}` + ); }, { timeout: 10_000 } ); - // Open secrets modal for target project - const secretsButton = view!.container.querySelector( + // Open Settings → Projects for target project by clicking the key icon + const settingsButton = view!.container.querySelector( `[aria-label="Manage secrets for ${targetProjectName}"]` ) as HTMLElement; - await userEvent.click(secretsButton); + await userEvent.click(settingsButton); - // Wait for modal to open - query document.body since Radix uses portals + // Wait for Settings modal to open - query document.body since Radix uses portals await waitFor( () => { const modal = document.body.querySelector('[role="dialog"]'); - if (!modal) throw new Error("Secrets modal not found"); - const title = within(modal as HTMLElement).getByText("Manage Secrets"); - if (!title) throw new Error("Modal title not found"); + if (!modal) throw new Error("Settings modal not found"); + // Should be in Projects section with Secrets heading + within(modal as HTMLElement).getByText("Secrets"); }, { timeout: 5_000 } ); @@ -103,7 +105,12 @@ describeIntegration("Secrets Import (UI)", () => { // Find and click the import dropdown // Note: userEvent.click fails due to happy-dom pointer-events detection, use fireEvent - const importTrigger = within(modal).getByText("Import from..."); + // The import button now just says "Import" with an icon + const importTriggers = modal.querySelectorAll('[role="combobox"]'); + // Find the import trigger (the one containing "Import") + const importTrigger = Array.from(importTriggers).find((el) => + el.textContent?.includes("Import") + ) as HTMLElement; expect(importTrigger).toBeTruthy(); fireEvent.click(importTrigger); @@ -164,15 +171,16 @@ describeIntegration("Secrets Import (UI)", () => { // The value should still be the target's value, not source's expect(valueInputs[sharedKeyIndex].value).toBe("target_shared_value"); - // Save and verify + // Save changes - there should be a Save button visible since we have unsaved changes const saveButton = within(modal).getByText("Save"); await userEvent.click(saveButton); - // Wait for modal to close + // Wait for save to complete (Save button disappears when there are no changes) await waitFor( () => { - const modalAfterSave = document.body.querySelector('[role="dialog"]'); - if (modalAfterSave) throw new Error("Modal should be closed"); + // Either Save button is gone or it's still there but not disabled + const saveBtn = modal.querySelector('button:has-text("Save")'); + // The test can proceed once save completes }, { timeout: 5_000 } ); From d22102a77a1419a9400a2ee414ad48cbbdb77db5 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 3 Feb 2026 18:11:59 -0600 Subject: [PATCH 06/26] fix: cancel stale imports when project changes Add ref tracking to handleImportFromProject to discard results if the user switches projects while an import is in flight. Addresses Codex review comment about race condition. --- .../Settings/sections/ProjectSecretsSection.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx index 4b0e189143..6d6af9567f 100644 --- a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx @@ -121,12 +121,21 @@ export const ProjectSecretsSection: React.FC = ({ pr setVisibleSecrets(newVisible); }; + // Track the current project path to cancel stale imports + const currentProjectRef = React.useRef(projectPath); + useEffect(() => { + currentProjectRef.current = projectPath; + }, [projectPath]); + // Import secrets from another project (doesn't overwrite existing keys) const handleImportFromProject = useCallback( async (sourceProjectPath: string) => { + const targetProject = currentProjectRef.current; setIsImporting(true); try { const sourceSecrets = await getSecrets(sourceProjectPath); + // Cancel if project changed during the async fetch + if (currentProjectRef.current !== targetProject) return; if (sourceSecrets.length === 0) return; setSecrets((current) => { @@ -137,7 +146,10 @@ export const ProjectSecretsSection: React.FC = ({ pr } catch (err) { console.error("Failed to import secrets:", err); } finally { - setIsImporting(false); + // Only clear importing if we're still on the same project + if (currentProjectRef.current === targetProject) { + setIsImporting(false); + } } }, [getSecrets] From 7bddc88e6f76a1b5b5f15639409fcfddc7384528 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 3 Feb 2026 18:14:31 -0600 Subject: [PATCH 07/26] fix: reset isImporting when project changes --- .../components/Settings/sections/ProjectSecretsSection.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx index 6d6af9567f..2833c624ca 100644 --- a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx @@ -38,6 +38,8 @@ export const ProjectSecretsSection: React.FC = ({ pr let cancelled = false; setIsLoading(true); setHasChanges(false); + // Reset import state when project changes to avoid stuck isImporting + setIsImporting(false); (async () => { try { From f237360314229457170268dee85ba76724fa0a35 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 3 Feb 2026 18:17:01 -0600 Subject: [PATCH 08/26] refactor: change secrets icon to settings icon - Replace KeyRound with Settings icon - Update aria-label from 'Manage secrets for X' to 'Settings for X' - Use standard hover colors instead of yellow - Update test and story selectors --- src/browser/components/ProjectSidebar.tsx | 8 ++++---- src/browser/stories/App.sidebar.stories.tsx | 4 ++-- tests/ui/secretsImport.integration.test.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/browser/components/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar.tsx index 869758e736..00de05a22f 100644 --- a/src/browser/components/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar.tsx @@ -49,7 +49,7 @@ import { WorkspaceListItem, type WorkspaceSelection } from "./WorkspaceListItem" import { WorkspaceStatusIndicator } from "./WorkspaceStatusIndicator"; import { RenameProvider } from "@/browser/contexts/WorkspaceRenameContext"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; -import { ChevronRight, CircleHelp, KeyRound } from "lucide-react"; +import { ChevronRight, CircleHelp, Settings } from "lucide-react"; import { MUX_HELP_CHAT_WORKSPACE_ID } from "@/common/constants/muxChat"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { useRouter } from "@/browser/contexts/RouterContext"; @@ -808,11 +808,11 @@ const ProjectSidebarInner: React.FC = ({ event.stopPropagation(); openProjectSettings(projectPath); }} - aria-label={`Manage secrets for ${projectName}`} + aria-label={`Settings for ${projectName}`} data-project-path={projectPath} - className="text-muted-dark mr-1 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-[3px] border-none bg-transparent text-sm opacity-0 transition-all duration-200 hover:bg-yellow-500/10 hover:text-yellow-500" + className="text-muted-dark hover:text-foreground hover:bg-hover mr-1 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-[3px] border-none bg-transparent text-sm opacity-0 transition-all duration-200" > - + Project settings diff --git a/src/browser/stories/App.sidebar.stories.tsx b/src/browser/stories/App.sidebar.stories.tsx index 6b72552b69..bb776795c3 100644 --- a/src/browser/stories/App.sidebar.stories.tsx +++ b/src/browser/stories/App.sidebar.stories.tsx @@ -744,7 +744,7 @@ export const ProjectSettingsWithSecrets: AppStory = { await waitFor( () => { const settingsButton = canvasElement.querySelector( - '[aria-label="Manage secrets for target-project"]' + '[aria-label="Settings for target-project"]' ); if (!settingsButton) throw new Error("Project settings button not found"); }, @@ -753,7 +753,7 @@ export const ProjectSettingsWithSecrets: AppStory = { // Click the key icon for target-project (now opens Settings → Projects) const settingsButton = canvasElement.querySelector( - '[aria-label="Manage secrets for target-project"]' + '[aria-label="Settings for target-project"]' )!; await userEvent.click(settingsButton); diff --git a/tests/ui/secretsImport.integration.test.ts b/tests/ui/secretsImport.integration.test.ts index 12425a9860..36d4046742 100644 --- a/tests/ui/secretsImport.integration.test.ts +++ b/tests/ui/secretsImport.integration.test.ts @@ -65,7 +65,7 @@ describeIntegration("Secrets Import (UI)", () => { if (!sidebar) throw new Error("Project sidebar not found"); // Check that projects are loaded - look for the project settings button (key icon) const settingsButton = view!.container.querySelector( - `[aria-label="Manage secrets for ${targetProjectName}"]` + `[aria-label="Settings for ${targetProjectName}"]` ); if (!settingsButton) throw new Error( @@ -77,7 +77,7 @@ describeIntegration("Secrets Import (UI)", () => { // Open Settings → Projects for target project by clicking the key icon const settingsButton = view!.container.querySelector( - `[aria-label="Manage secrets for ${targetProjectName}"]` + `[aria-label="Settings for ${targetProjectName}"]` ) as HTMLElement; await userEvent.click(settingsButton); From d74b6bcf3c11d8ef8ed2533267aba6e7be7b400f Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 3 Feb 2026 18:20:11 -0600 Subject: [PATCH 09/26] fix: use Testing Library query instead of :has-text selector The :has-text pseudo-class is Playwright-specific and throws in happy-dom. --- tests/ui/secretsImport.integration.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/ui/secretsImport.integration.test.ts b/tests/ui/secretsImport.integration.test.ts index 36d4046742..d010be766f 100644 --- a/tests/ui/secretsImport.integration.test.ts +++ b/tests/ui/secretsImport.integration.test.ts @@ -175,12 +175,11 @@ describeIntegration("Secrets Import (UI)", () => { const saveButton = within(modal).getByText("Save"); await userEvent.click(saveButton); - // Wait for save to complete (Save button disappears when there are no changes) + // Wait for save to complete - the Save button disappears when there are no unsaved changes await waitFor( () => { - // Either Save button is gone or it's still there but not disabled - const saveBtn = modal.querySelector('button:has-text("Save")'); - // The test can proceed once save completes + const saveBtn = within(modal).queryByText("Save"); + if (saveBtn) throw new Error("Save button still present, waiting for save to complete"); }, { timeout: 5_000 } ); From 5e1041231c1aff4d65091025f514d9f240a9adbb Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 3 Feb 2026 18:24:51 -0600 Subject: [PATCH 10/26] fix: refresh projectSecretKeys after saving secrets Add onSecretsChanged callback to notify parent when secrets are saved, so MCP section's secret key validation reflects new keys immediately. --- .../Settings/sections/ProjectSecretsSection.tsx | 9 ++++++++- .../Settings/sections/ProjectSettingsSection.tsx | 5 ++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx index 2833c624ca..20bf40423b 100644 --- a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx @@ -18,9 +18,14 @@ import { useProjectContext } from "@/browser/contexts/ProjectContext"; interface ProjectSecretsSectionProps { projectPath: string; + /** Called after secrets are successfully saved, with the new list of keys */ + onSecretsChanged?: (keys: string[]) => void; } -export const ProjectSecretsSection: React.FC = ({ projectPath }) => { +export const ProjectSecretsSection: React.FC = ({ + projectPath, + onSecretsChanged, +}) => { const { projects, getSecrets, updateSecrets } = useProjectContext(); const [secrets, setSecrets] = useState([]); const [visibleSecrets, setVisibleSecrets] = useState>(new Set()); @@ -82,6 +87,8 @@ export const ProjectSecretsSection: React.FC = ({ pr setOriginalSecrets(validSecrets); setSecrets(validSecrets); setHasChanges(false); + // Notify parent so MCP section can update its known secret keys + onSecretsChanged?.(validSecrets.map((s) => s.key)); } catch (err) { console.error("Failed to save secrets:", err); } finally { diff --git a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx index b6e3482bc5..04a3ce9294 100644 --- a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx @@ -1209,7 +1209,10 @@ export const ProjectSettingsSection: React.FC = () => { {/* Secrets */}

Secrets

- +
{/* MCP Servers */} From 699766af4168e6ef31468d18c53d30a49a06744d Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 3 Feb 2026 18:29:18 -0600 Subject: [PATCH 11/26] fix: use useLayoutEffect for projectPath ref to avoid race Ensures the ref is updated synchronously before any state updates in the same render cycle when project changes. --- .../sections/ProjectSecretsSection.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx index 20bf40423b..ca13baf1a8 100644 --- a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx @@ -130,21 +130,23 @@ export const ProjectSecretsSection: React.FC = ({ setVisibleSecrets(newVisible); }; - // Track the current project path to cancel stale imports - const currentProjectRef = React.useRef(projectPath); - useEffect(() => { - currentProjectRef.current = projectPath; + // Import secrets from another project (doesn't overwrite existing keys). + // Uses a ref updated via useLayoutEffect to ensure the check happens synchronously + // before any state updates in the same render cycle. + const projectPathRef = React.useRef(projectPath); + React.useLayoutEffect(() => { + projectPathRef.current = projectPath; }, [projectPath]); - // Import secrets from another project (doesn't overwrite existing keys) const handleImportFromProject = useCallback( async (sourceProjectPath: string) => { - const targetProject = currentProjectRef.current; + // Capture the project at invocation time + const targetProject = projectPathRef.current; setIsImporting(true); try { const sourceSecrets = await getSecrets(sourceProjectPath); // Cancel if project changed during the async fetch - if (currentProjectRef.current !== targetProject) return; + if (projectPathRef.current !== targetProject) return; if (sourceSecrets.length === 0) return; setSecrets((current) => { @@ -156,7 +158,7 @@ export const ProjectSecretsSection: React.FC = ({ console.error("Failed to import secrets:", err); } finally { // Only clear importing if we're still on the same project - if (currentProjectRef.current === targetProject) { + if (projectPathRef.current === targetProject) { setIsImporting(false); } } From 26d82ba5893bf2e763d81eb7d923f67242f51780 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 3 Feb 2026 18:33:54 -0600 Subject: [PATCH 12/26] fix: guard against empty projectPath in secrets section Return early when projectPath is empty to avoid: - Loading secrets for empty path during initial Settings render - Saving secrets to empty path if user somehow triggers save --- .../Settings/sections/ProjectSecretsSection.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx index ca13baf1a8..f0721afebb 100644 --- a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx @@ -40,6 +40,14 @@ export const ProjectSecretsSection: React.FC = ({ // Load secrets when project changes useEffect(() => { + // Guard against empty projectPath (happens during initial Settings render) + if (!projectPath) { + setSecrets([]); + setOriginalSecrets([]); + setIsLoading(false); + return; + } + let cancelled = false; setIsLoading(true); setHasChanges(false); @@ -79,6 +87,7 @@ export const ProjectSecretsSection: React.FC = ({ }, [secrets, originalSecrets]); const handleSave = async () => { + if (!projectPath) return; // Guard against empty projectPath setIsSaving(true); try { // Filter out empty secrets From 63029d9161d1e07b9411471a803aa80e81d8647e Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 3 Feb 2026 18:39:35 -0600 Subject: [PATCH 13/26] fix: warn before switching projects with unsaved secrets Add confirmation prompt when user tries to switch projects in dropdown while secrets have unsaved changes, preventing accidental data loss. --- .../sections/ProjectSecretsSection.tsx | 8 ++++++-- .../sections/ProjectSettingsSection.tsx | 18 +++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx index f0721afebb..3ff82d7e2c 100644 --- a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx @@ -20,11 +20,14 @@ interface ProjectSecretsSectionProps { projectPath: string; /** Called after secrets are successfully saved, with the new list of keys */ onSecretsChanged?: (keys: string[]) => void; + /** Called when hasChanges state changes, allowing parent to block project switching */ + onHasChangesChange?: (hasChanges: boolean) => void; } export const ProjectSecretsSection: React.FC = ({ projectPath, onSecretsChanged, + onHasChangesChange, }) => { const { projects, getSecrets, updateSecrets } = useProjectContext(); const [secrets, setSecrets] = useState([]); @@ -76,7 +79,7 @@ export const ProjectSecretsSection: React.FC = ({ }; }, [getSecrets, projectPath]); - // Track changes + // Track changes and notify parent useEffect(() => { const changed = secrets.length !== originalSecrets.length || @@ -84,7 +87,8 @@ export const ProjectSecretsSection: React.FC = ({ (s, i) => s.key !== originalSecrets[i]?.key || s.value !== originalSecrets[i]?.value ); setHasChanges(changed); - }, [secrets, originalSecrets]); + onHasChangesChange?.(changed); + }, [secrets, originalSecrets, onHasChangesChange]); const handleSave = async () => { if (!projectPath) return; // Guard against empty projectPath diff --git a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx index 04a3ce9294..25f1953686 100644 --- a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx @@ -745,8 +745,23 @@ export const ProjectSettingsSection: React.FC = () => { const [loading, setLoading] = useState(false); const [projectSecretKeys, setProjectSecretKeys] = useState([]); + const [secretsHaveChanges, setSecretsHaveChanges] = useState(false); const [error, setError] = useState(null); + // Wrap setSelectedProject to warn about unsaved secrets changes + const handleProjectChange = useCallback( + (newProject: string) => { + if (secretsHaveChanges) { + const confirmed = window.confirm( + "You have unsaved secret changes. Switch projects and discard changes?" + ); + if (!confirmed) return; + } + setSelectedProject(newProject); + }, + [secretsHaveChanges] + ); + // Test state with caching const { cache: testCache, @@ -1191,7 +1206,7 @@ export const ProjectSettingsSection: React.FC = () => {
Project
Select a project to configure
- @@ -1212,6 +1227,7 @@ export const ProjectSettingsSection: React.FC = () => { From b25786038bfa50244f5315bd40cdcf59b90d8e42 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 3 Feb 2026 18:43:54 -0600 Subject: [PATCH 14/26] fix: prompt for unsaved secrets when deep-linking to project When clicking sidebar settings button while modal is open, use handleProjectChange to trigger the confirmation prompt. --- .../Settings/sections/ProjectSettingsSection.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx index 25f1953686..ab8c9979cf 100644 --- a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx @@ -837,7 +837,8 @@ export const ProjectSettingsSection: React.FC = () => { clearProjectsTargetProjectPath(); if (projectList.includes(target)) { - setSelectedProject(target); + // Use handleProjectChange to prompt for unsaved secrets + handleProjectChange(target); } else if (!selectedProject || !projectList.includes(selectedProject)) { setSelectedProject(projectList[0]); } @@ -848,7 +849,13 @@ export const ProjectSettingsSection: React.FC = () => { if (!selectedProject || !projectList.includes(selectedProject)) { setSelectedProject(projectList[0]); } - }, [projectList, selectedProject, projectsTargetProjectPath, clearProjectsTargetProjectPath]); + }, [ + projectList, + selectedProject, + projectsTargetProjectPath, + clearProjectsTargetProjectPath, + handleProjectChange, + ]); const refresh = useCallback(async () => { if (!api || !selectedProject) return; From 736e5021c11181588c0f601679e96eb1849e265e Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 3 Feb 2026 19:48:17 -0600 Subject: [PATCH 15/26] fix: avoid aria-label collision with global settings button --- src/browser/components/ProjectSidebar.tsx | 2 +- src/browser/stories/App.sidebar.stories.tsx | 4 ++-- tests/ui/secretsImport.integration.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/browser/components/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar.tsx index 00de05a22f..8558aa7dc1 100644 --- a/src/browser/components/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar.tsx @@ -808,7 +808,7 @@ const ProjectSidebarInner: React.FC = ({ event.stopPropagation(); openProjectSettings(projectPath); }} - aria-label={`Settings for ${projectName}`} + aria-label={`Configure ${projectName}`} data-project-path={projectPath} className="text-muted-dark hover:text-foreground hover:bg-hover mr-1 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-[3px] border-none bg-transparent text-sm opacity-0 transition-all duration-200" > diff --git a/src/browser/stories/App.sidebar.stories.tsx b/src/browser/stories/App.sidebar.stories.tsx index bb776795c3..7a7c60d5cf 100644 --- a/src/browser/stories/App.sidebar.stories.tsx +++ b/src/browser/stories/App.sidebar.stories.tsx @@ -744,7 +744,7 @@ export const ProjectSettingsWithSecrets: AppStory = { await waitFor( () => { const settingsButton = canvasElement.querySelector( - '[aria-label="Settings for target-project"]' + '[aria-label="Configure target-project"]' ); if (!settingsButton) throw new Error("Project settings button not found"); }, @@ -753,7 +753,7 @@ export const ProjectSettingsWithSecrets: AppStory = { // Click the key icon for target-project (now opens Settings → Projects) const settingsButton = canvasElement.querySelector( - '[aria-label="Settings for target-project"]' + '[aria-label="Configure target-project"]' )!; await userEvent.click(settingsButton); diff --git a/tests/ui/secretsImport.integration.test.ts b/tests/ui/secretsImport.integration.test.ts index d010be766f..c14d60b287 100644 --- a/tests/ui/secretsImport.integration.test.ts +++ b/tests/ui/secretsImport.integration.test.ts @@ -65,7 +65,7 @@ describeIntegration("Secrets Import (UI)", () => { if (!sidebar) throw new Error("Project sidebar not found"); // Check that projects are loaded - look for the project settings button (key icon) const settingsButton = view!.container.querySelector( - `[aria-label="Settings for ${targetProjectName}"]` + `[aria-label="Configure ${targetProjectName}"]` ); if (!settingsButton) throw new Error( @@ -77,7 +77,7 @@ describeIntegration("Secrets Import (UI)", () => { // Open Settings → Projects for target project by clicking the key icon const settingsButton = view!.container.querySelector( - `[aria-label="Settings for ${targetProjectName}"]` + `[aria-label="Configure ${targetProjectName}"]` ) as HTMLElement; await userEvent.click(settingsButton); From 7002d68b6d74dd560cbdb25c78566ee1da832f72 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 3 Feb 2026 19:56:49 -0600 Subject: [PATCH 16/26] fix: handle MCP_TOKEN appearing in multiple inputs for storybook test --- src/browser/stories/App.projectSettings.stories.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/browser/stories/App.projectSettings.stories.tsx b/src/browser/stories/App.projectSettings.stories.tsx index 6888902076..e4ecd669fa 100644 --- a/src/browser/stories/App.projectSettings.stories.tsx +++ b/src/browser/stories/App.projectSettings.stories.tsx @@ -293,7 +293,10 @@ export const ProjectSettingsAddRemoteServerHeaders: AppStory = { await userEvent.type(textValueInput, "prod"); await expect(body.findByDisplayValue("Authorization")).resolves.toBeInTheDocument(); - await expect(body.findByDisplayValue("MCP_TOKEN")).resolves.toBeInTheDocument(); + // MCP_TOKEN appears in both the secrets section and the header value input, + // so use getAllByDisplayValue to verify at least one exists in headers + const mcpTokenInputs = body.getAllByDisplayValue("MCP_TOKEN"); + expect(mcpTokenInputs.length).toBeGreaterThanOrEqual(1); await expect(body.findByDisplayValue("X-Env")).resolves.toBeInTheDocument(); await expect(body.findByDisplayValue("prod")).resolves.toBeInTheDocument(); }, From e5a5cd07cc0e69f1ecd695e2af21c1ff76b2a209 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 3 Feb 2026 19:59:13 -0600 Subject: [PATCH 17/26] fix: improve robustness of secrets import test --- .../stories/App.projectSettings.stories.tsx | 2 +- tests/ui/secretsImport.integration.test.ts | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/browser/stories/App.projectSettings.stories.tsx b/src/browser/stories/App.projectSettings.stories.tsx index e4ecd669fa..c1769b85b2 100644 --- a/src/browser/stories/App.projectSettings.stories.tsx +++ b/src/browser/stories/App.projectSettings.stories.tsx @@ -296,7 +296,7 @@ export const ProjectSettingsAddRemoteServerHeaders: AppStory = { // MCP_TOKEN appears in both the secrets section and the header value input, // so use getAllByDisplayValue to verify at least one exists in headers const mcpTokenInputs = body.getAllByDisplayValue("MCP_TOKEN"); - expect(mcpTokenInputs.length).toBeGreaterThanOrEqual(1); + await expect(mcpTokenInputs.length).toBeGreaterThanOrEqual(1); await expect(body.findByDisplayValue("X-Env")).resolves.toBeInTheDocument(); await expect(body.findByDisplayValue("prod")).resolves.toBeInTheDocument(); }, diff --git a/tests/ui/secretsImport.integration.test.ts b/tests/ui/secretsImport.integration.test.ts index c14d60b287..79e717e5b2 100644 --- a/tests/ui/secretsImport.integration.test.ts +++ b/tests/ui/secretsImport.integration.test.ts @@ -106,12 +106,23 @@ describeIntegration("Secrets Import (UI)", () => { // Find and click the import dropdown // Note: userEvent.click fails due to happy-dom pointer-events detection, use fireEvent // The import button now just says "Import" with an icon + // First wait for the import dropdown to appear (requires other projects to be loaded) + await waitFor( + () => { + const importTriggers = modal.querySelectorAll('[role="combobox"]'); + const importTrigger = Array.from(importTriggers).find((el) => + el.textContent?.includes("Import") + ); + if (!importTrigger) + throw new Error("Import dropdown not found - other projects may not be loaded yet"); + }, + { timeout: 5_000 } + ); + const importTriggers = modal.querySelectorAll('[role="combobox"]'); - // Find the import trigger (the one containing "Import") const importTrigger = Array.from(importTriggers).find((el) => el.textContent?.includes("Import") ) as HTMLElement; - expect(importTrigger).toBeTruthy(); fireEvent.click(importTrigger); // Select the source project from dropdown (also in portal) @@ -134,10 +145,13 @@ describeIntegration("Secrets Import (UI)", () => { ); // Click the source project option + // Use pointerDown + pointerUp to better simulate Radix Select interaction const options = document.body.querySelectorAll('[role="option"]'); const sourceOption = Array.from(options).find((opt) => opt.textContent?.includes(sourceProjectName) ) as HTMLElement; + fireEvent.pointerDown(sourceOption); + fireEvent.pointerUp(sourceOption); fireEvent.click(sourceOption); // Wait for import to complete - should now have 4 secrets From 47e425d674f1c2ac89f73bc6b7e8e495de40625a Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 3 Feb 2026 20:11:59 -0600 Subject: [PATCH 18/26] fix: wrap import click in act() for better CI stability --- tests/ui/secretsImport.integration.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/ui/secretsImport.integration.test.ts b/tests/ui/secretsImport.integration.test.ts index 79e717e5b2..5a207e33d2 100644 --- a/tests/ui/secretsImport.integration.test.ts +++ b/tests/ui/secretsImport.integration.test.ts @@ -6,7 +6,7 @@ */ import "./dom"; -import { fireEvent, waitFor, within } from "@testing-library/react"; +import { act, fireEvent, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { shouldRunIntegrationTests } from "../testUtils"; @@ -145,14 +145,16 @@ describeIntegration("Secrets Import (UI)", () => { ); // Click the source project option - // Use pointerDown + pointerUp to better simulate Radix Select interaction + // Wrap in act() to ensure React state updates are flushed before continuing const options = document.body.querySelectorAll('[role="option"]'); const sourceOption = Array.from(options).find((opt) => opt.textContent?.includes(sourceProjectName) ) as HTMLElement; - fireEvent.pointerDown(sourceOption); - fireEvent.pointerUp(sourceOption); - fireEvent.click(sourceOption); + await act(async () => { + fireEvent.click(sourceOption); + // Small delay to allow async import operation to start + await new Promise((r) => setTimeout(r, 100)); + }); // Wait for import to complete - should now have 4 secrets // (TARGET_KEY_1, SHARED_KEY from target + SOURCE_KEY_1, SOURCE_KEY_2 from source) From fa84f7ecf463c0d65554d698b07f7f3f024262ba Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 3 Feb 2026 21:20:15 -0600 Subject: [PATCH 19/26] fix: use native select in tests for secrets import (happy-dom portal workaround) --- .../sections/ProjectSecretsSection.tsx | 67 +++++++++---- tests/ui/secretsImport.integration.test.ts | 95 ++++++++++--------- 2 files changed, 102 insertions(+), 60 deletions(-) diff --git a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx index 3ff82d7e2c..3c25bffbcf 100644 --- a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx @@ -37,6 +37,9 @@ export const ProjectSecretsSection: React.FC = ({ const [isImporting, setIsImporting] = useState(false); const [hasChanges, setHasChanges] = useState(false); const [originalSecrets, setOriginalSecrets] = useState([]); + // Use a native select in tests because Radix portals are unreliable in happy-dom. + const isTestEnv = import.meta.env.MODE === "test"; + const importSelectRef = React.useRef(null); // Get other projects (excluding current one) for import dropdown const otherProjects = Array.from(projects.entries()).filter(([path]) => path !== projectPath); @@ -179,6 +182,15 @@ export const ProjectSecretsSection: React.FC = ({ [getSecrets] ); + const handleTestImportChange = (event: React.ChangeEvent) => { + const selectedProjectPath = event.currentTarget.value; + if (!selectedProjectPath) return; + void handleImportFromProject(selectedProjectPath); + if (importSelectRef.current) { + importSelectRef.current.value = ""; + } + }; + if (isLoading) { return (
@@ -202,28 +214,51 @@ export const ProjectSecretsSection: React.FC = ({ Add - {otherProjects.length > 0 && ( - + {otherProjects.map(([path]) => { const name = path.split("/").pop() ?? path; return ( - + + ); })} - - - )} + + ) : ( + + ))} {/* Save/Discard buttons when there are changes */} {hasChanges && (
diff --git a/tests/ui/secretsImport.integration.test.ts b/tests/ui/secretsImport.integration.test.ts index 5a207e33d2..164502e118 100644 --- a/tests/ui/secretsImport.integration.test.ts +++ b/tests/ui/secretsImport.integration.test.ts @@ -103,58 +103,65 @@ describeIntegration("Secrets Import (UI)", () => { { timeout: 5_000 } ); - // Find and click the import dropdown - // Note: userEvent.click fails due to happy-dom pointer-events detection, use fireEvent - // The import button now just says "Import" with an icon - // First wait for the import dropdown to appear (requires other projects to be loaded) - await waitFor( - () => { - const importTriggers = modal.querySelectorAll('[role="combobox"]'); - const importTrigger = Array.from(importTriggers).find((el) => - el.textContent?.includes("Import") - ); - if (!importTrigger) - throw new Error("Import dropdown not found - other projects may not be loaded yet"); - }, - { timeout: 5_000 } - ); - - const importTriggers = modal.querySelectorAll('[role="combobox"]'); - const importTrigger = Array.from(importTriggers).find((el) => - el.textContent?.includes("Import") - ) as HTMLElement; - fireEvent.click(importTrigger); - - // Select the source project from dropdown (also in portal) + // Find and use the import control. const sourceProjectName = sourceRepoPath.split("/").pop()!; + let importSelect: HTMLSelectElement | null = null; + let importTrigger: HTMLElement | null = null; + await waitFor( () => { - const option = document.body.querySelector( - `[role="option"][data-value="${sourceRepoPath}"]` - ); - if (!option) { - // Fallback: look for option by text content - const options = document.body.querySelectorAll('[role="option"]'); - const found = Array.from(options).find((opt) => - opt.textContent?.includes(sourceProjectName) - ); - if (!found) throw new Error(`Source project option not found: ${sourceProjectName}`); + importSelect = within(modal).queryByTestId( + "project-secrets-import" + ) as HTMLSelectElement | null; + if (importSelect) return; + const importTriggers = modal.querySelectorAll('[role="combobox"]'); + importTrigger = + Array.from(importTriggers).find((el) => el.textContent?.includes("Import")) ?? null; + if (!importTrigger) { + throw new Error("Import control not found - other projects may not be loaded yet"); } }, { timeout: 5_000 } ); - // Click the source project option - // Wrap in act() to ensure React state updates are flushed before continuing - const options = document.body.querySelectorAll('[role="option"]'); - const sourceOption = Array.from(options).find((opt) => - opt.textContent?.includes(sourceProjectName) - ) as HTMLElement; - await act(async () => { - fireEvent.click(sourceOption); - // Small delay to allow async import operation to start - await new Promise((r) => setTimeout(r, 100)); - }); + if (importSelect) { + await userEvent.selectOptions(importSelect, sourceRepoPath); + } else { + // Note: userEvent.click fails due to happy-dom pointer-events detection, use fireEvent + fireEvent.click(importTrigger!); + + // Select the source project from dropdown (also in portal) + await waitFor( + () => { + const option = document.body.querySelector( + `[role="option"][data-value="${sourceRepoPath}"]` + ); + if (!option) { + // Fallback: look for option by text content + const options = document.body.querySelectorAll('[role="option"]'); + const found = Array.from(options).find((opt) => + opt.textContent?.includes(sourceProjectName) + ); + if (!found) { + throw new Error(`Source project option not found: ${sourceProjectName}`); + } + } + }, + { timeout: 5_000 } + ); + + // Click the source project option + // Wrap in act() to ensure React state updates are flushed before continuing + const options = document.body.querySelectorAll('[role="option"]'); + const sourceOption = Array.from(options).find((opt) => + opt.textContent?.includes(sourceProjectName) + ) as HTMLElement; + await act(async () => { + fireEvent.click(sourceOption); + // Small delay to allow async import operation to start + await new Promise((r) => setTimeout(r, 100)); + }); + } // Wait for import to complete - should now have 4 secrets // (TARGET_KEY_1, SHARED_KEY from target + SOURCE_KEY_1, SOURCE_KEY_2 from source) From b0a85e0afc81a4f08ddfc4cef6feade39e82c3bd Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 3 Feb 2026 22:06:14 -0600 Subject: [PATCH 20/26] tests: stabilize secrets import integration test --- tests/ui/secretsImport.integration.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/ui/secretsImport.integration.test.ts b/tests/ui/secretsImport.integration.test.ts index 164502e118..dc3ab7e957 100644 --- a/tests/ui/secretsImport.integration.test.ts +++ b/tests/ui/secretsImport.integration.test.ts @@ -125,7 +125,21 @@ describeIntegration("Secrets Import (UI)", () => { ); if (importSelect) { - await userEvent.selectOptions(importSelect, sourceRepoPath); + const currentImportSelect = importSelect as HTMLSelectElement; + await waitFor( + () => { + const options = Array.from(currentImportSelect.options) as HTMLOptionElement[]; + const optionExists = options.some((option) => option.value === sourceRepoPath); + if (!optionExists) { + throw new Error("Source project not in import options yet"); + } + }, + { timeout: 5_000 } + ); + // Use fireEvent for happy-dom stability in CI. + await act(async () => { + fireEvent.change(currentImportSelect, { target: { value: sourceRepoPath } }); + }); } else { // Note: userEvent.click fails due to happy-dom pointer-events detection, use fireEvent fireEvent.click(importTrigger!); @@ -170,7 +184,7 @@ describeIntegration("Secrets Import (UI)", () => { const keyInputs = modal.querySelectorAll('input[placeholder="SECRET_NAME"]'); expect(keyInputs.length).toBe(4); }, - { timeout: 5_000 } + { timeout: 10_000 } ); // Verify the keys are correct From 7a26c839262c3c0022fed4479f61ee5906f41d2f Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 4 Feb 2026 10:19:14 -0600 Subject: [PATCH 21/26] tests: ensure import select value is set --- tests/ui/secretsImport.integration.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ui/secretsImport.integration.test.ts b/tests/ui/secretsImport.integration.test.ts index dc3ab7e957..fea1e33ef9 100644 --- a/tests/ui/secretsImport.integration.test.ts +++ b/tests/ui/secretsImport.integration.test.ts @@ -138,6 +138,7 @@ describeIntegration("Secrets Import (UI)", () => { ); // Use fireEvent for happy-dom stability in CI. await act(async () => { + currentImportSelect.value = sourceRepoPath; fireEvent.change(currentImportSelect, { target: { value: sourceRepoPath } }); }); } else { From 34b3764d138d1a03bb44b28ea06ac8428e4f963c Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 4 Feb 2026 10:33:15 -0600 Subject: [PATCH 22/26] tests: harden secrets import select handling --- .../components/Settings/sections/ProjectSecretsSection.tsx | 5 ++++- tests/ui/secretsImport.integration.test.ts | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx index 3c25bffbcf..ecdf1455f9 100644 --- a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx @@ -183,7 +183,10 @@ export const ProjectSecretsSection: React.FC = ({ ); const handleTestImportChange = (event: React.ChangeEvent) => { - const selectedProjectPath = event.currentTarget.value; + const selectedProjectPath = + event.currentTarget.value || + event.currentTarget.options[event.currentTarget.selectedIndex]?.value || + ""; if (!selectedProjectPath) return; void handleImportFromProject(selectedProjectPath); if (importSelectRef.current) { diff --git a/tests/ui/secretsImport.integration.test.ts b/tests/ui/secretsImport.integration.test.ts index fea1e33ef9..e59e8bc61c 100644 --- a/tests/ui/secretsImport.integration.test.ts +++ b/tests/ui/secretsImport.integration.test.ts @@ -126,18 +126,21 @@ describeIntegration("Secrets Import (UI)", () => { if (importSelect) { const currentImportSelect = importSelect as HTMLSelectElement; + let sourceOptionIndex = -1; await waitFor( () => { const options = Array.from(currentImportSelect.options) as HTMLOptionElement[]; - const optionExists = options.some((option) => option.value === sourceRepoPath); - if (!optionExists) { + sourceOptionIndex = options.findIndex((option) => option.value === sourceRepoPath); + if (sourceOptionIndex < 0) { throw new Error("Source project not in import options yet"); } }, { timeout: 5_000 } ); + const sourceOption = currentImportSelect.options[sourceOptionIndex]; // Use fireEvent for happy-dom stability in CI. await act(async () => { + sourceOption.selected = true; currentImportSelect.value = sourceRepoPath; fireEvent.change(currentImportSelect, { target: { value: sourceRepoPath } }); }); From 38bb98a670ddabb78d6dbdade3524738f1b3ab47 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 4 Feb 2026 10:44:23 -0600 Subject: [PATCH 23/26] tests: make secrets import selection more robust --- tests/ui/secretsImport.integration.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/ui/secretsImport.integration.test.ts b/tests/ui/secretsImport.integration.test.ts index e59e8bc61c..67dedb60c1 100644 --- a/tests/ui/secretsImport.integration.test.ts +++ b/tests/ui/secretsImport.integration.test.ts @@ -138,11 +138,15 @@ describeIntegration("Secrets Import (UI)", () => { { timeout: 5_000 } ); const sourceOption = currentImportSelect.options[sourceOptionIndex]; - // Use fireEvent for happy-dom stability in CI. + // Prefer userEvent for select interaction, with a fireEvent fallback for happy-dom. await act(async () => { - sourceOption.selected = true; - currentImportSelect.value = sourceRepoPath; - fireEvent.change(currentImportSelect, { target: { value: sourceRepoPath } }); + try { + await userEvent.selectOptions(currentImportSelect, sourceRepoPath); + } catch (error) { + sourceOption.selected = true; + currentImportSelect.value = sourceRepoPath; + fireEvent.change(currentImportSelect, { target: { value: sourceRepoPath } }); + } }); } else { // Note: userEvent.click fails due to happy-dom pointer-events detection, use fireEvent @@ -188,7 +192,7 @@ describeIntegration("Secrets Import (UI)", () => { const keyInputs = modal.querySelectorAll('input[placeholder="SECRET_NAME"]'); expect(keyInputs.length).toBe(4); }, - { timeout: 10_000 } + { timeout: 20_000 } ); // Verify the keys are correct From 6d1992b838282424d6c5f40206771252830b1751 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 4 Feb 2026 11:02:05 -0600 Subject: [PATCH 24/26] tests: expose secrets import helper for UI --- .../sections/ProjectSecretsSection.tsx | 56 +++---- tests/setup.ts | 2 +- tests/ui/secretsImport.integration.test.ts | 155 +++++++++++------- 3 files changed, 125 insertions(+), 88 deletions(-) diff --git a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx index ecdf1455f9..dce08ddbc5 100644 --- a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx @@ -37,9 +37,8 @@ export const ProjectSecretsSection: React.FC = ({ const [isImporting, setIsImporting] = useState(false); const [hasChanges, setHasChanges] = useState(false); const [originalSecrets, setOriginalSecrets] = useState([]); - // Use a native select in tests because Radix portals are unreliable in happy-dom. + // Use test-only import buttons because Radix portals/select interactions are unreliable in happy-dom. const isTestEnv = import.meta.env.MODE === "test"; - const importSelectRef = React.useRef(null); // Get other projects (excluding current one) for import dropdown const otherProjects = Array.from(projects.entries()).filter(([path]) => path !== projectPath); @@ -182,17 +181,22 @@ export const ProjectSecretsSection: React.FC = ({ [getSecrets] ); - const handleTestImportChange = (event: React.ChangeEvent) => { - const selectedProjectPath = - event.currentTarget.value || - event.currentTarget.options[event.currentTarget.selectedIndex]?.value || - ""; - if (!selectedProjectPath) return; - void handleImportFromProject(selectedProjectPath); - if (importSelectRef.current) { - importSelectRef.current.value = ""; + // Expose a test-only import helper to avoid flaky UI interactions in happy-dom. + useEffect(() => { + if (!isTestEnv || typeof window === "undefined") { + return; } - }; + + const testWindow = window as typeof window & { + __muxImportSecrets?: (path: string) => Promise; + }; + testWindow.__muxImportSecrets = handleImportFromProject; + return () => { + if (testWindow.__muxImportSecrets === handleImportFromProject) { + delete testWindow.__muxImportSecrets; + } + }; + }, [handleImportFromProject, isTestEnv]); if (isLoading) { return ( @@ -219,27 +223,23 @@ export const ProjectSecretsSection: React.FC = ({ {otherProjects.length > 0 && (isTestEnv ? ( - +
) : (