Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useState } from "react";
import { useAPI } from "@/browser/contexts/API";
import { useProjectContext } from "@/browser/contexts/ProjectContext";
import { useSettings } from "@/browser/contexts/SettingsContext";
import {
Trash2,
Play,
Expand Down Expand Up @@ -174,10 +175,11 @@ const ToolAllowlistSection: React.FC<{
export const ProjectSettingsSection: React.FC = () => {
const { api } = useAPI();
const { projects } = useProjectContext();
const { initialProjectPath } = useSettings();
const projectList = Array.from(projects.keys());

// Core state
const [selectedProject, setSelectedProject] = useState<string>("");
const [selectedProject, setSelectedProject] = useState<string>(initialProjectPath ?? "");
const [servers, setServers] = useState<Record<string, MCPServerInfo>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -225,12 +227,14 @@ export const ProjectSettingsSection: React.FC = () => {
setIdleHoursInput(idleHours?.toString() ?? "24");
}, [idleHours]);

// Set default project when projects load
// Set project when initialProjectPath changes or default to first project
useEffect(() => {
if (projectList.length > 0 && !selectedProject) {
if (initialProjectPath && projectList.includes(initialProjectPath)) {
setSelectedProject(initialProjectPath);
} else if (projectList.length > 0 && !selectedProject) {
setSelectedProject(projectList[0]);
}
}, [projectList, selectedProject]);
}, [projectList, selectedProject, initialProjectPath]);

const refresh = useCallback(async () => {
if (!api || !selectedProject) return;
Expand Down
15 changes: 5 additions & 10 deletions src/browser/components/WorkspaceHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Pencil, Server } from "lucide-react";
import { GitStatusIndicator } from "./GitStatusIndicator";
import { RuntimeBadge } from "./RuntimeBadge";
import { BranchSelector } from "./BranchSelector";
import { WorkspaceMCPModal } from "./WorkspaceMCPModal";

import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
import { useGitStatus } from "@/browser/stores/GitStatusStore";
Expand All @@ -13,6 +13,7 @@ import type { RuntimeConfig } from "@/common/types/runtime";
import { useTutorial } from "@/browser/contexts/TutorialContext";
import { useOpenTerminal } from "@/browser/hooks/useOpenTerminal";
import { useOpenInEditor } from "@/browser/hooks/useOpenInEditor";
import { useSettings } from "@/browser/contexts/SettingsContext";

interface WorkspaceHeaderProps {
workspaceId: string;
Expand All @@ -36,8 +37,8 @@ export const WorkspaceHeader: React.FC<WorkspaceHeaderProps> = ({
const gitStatus = useGitStatus(workspaceId);
const { canInterrupt } = useWorkspaceSidebarState(workspaceId);
const { startSequence: startTutorial, isSequenceCompleted } = useTutorial();
const { open: openSettings } = useSettings();
const [editorError, setEditorError] = useState<string | null>(null);
const [mcpModalOpen, setMcpModalOpen] = useState(false);

const handleOpenTerminal = useCallback(() => {
openTerminal(workspaceId, runtimeConfig);
Expand Down Expand Up @@ -96,15 +97,15 @@ export const WorkspaceHeader: React.FC<WorkspaceHeaderProps> = ({
<Button
variant="ghost"
size="icon"
onClick={() => setMcpModalOpen(true)}
onClick={() => openSettings("projects", projectPath)}
className="text-muted hover:text-foreground h-6 w-6 shrink-0"
data-testid="workspace-mcp-button"
>
<Server className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="center">
Configure MCP servers for this workspace
Configure MCP servers for this project
</TooltipContent>
</Tooltip>
<Tooltip>
Expand Down Expand Up @@ -141,12 +142,6 @@ export const WorkspaceHeader: React.FC<WorkspaceHeaderProps> = ({
</TooltipContent>
</Tooltip>
</div>
<WorkspaceMCPModal
workspaceId={workspaceId}
projectPath={projectPath}
open={mcpModalOpen}
onOpenChange={setMcpModalOpen}
/>
</div>
);
};
11 changes: 8 additions & 3 deletions src/browser/contexts/SettingsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import React, {
interface SettingsContextValue {
isOpen: boolean;
activeSection: string;
open: (section?: string) => void;
/** Pre-selected project path when opening the projects section */
initialProjectPath: string | null;
open: (section?: string, projectPath?: string) => void;
close: () => void;
setActiveSection: (section: string) => void;
}
Expand All @@ -28,9 +30,11 @@ const DEFAULT_SECTION = "general";
export function SettingsProvider(props: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const [activeSection, setActiveSection] = useState(DEFAULT_SECTION);
const [initialProjectPath, setInitialProjectPath] = useState<string | null>(null);

const open = useCallback((section?: string) => {
const open = useCallback((section?: string, projectPath?: string) => {
if (section) setActiveSection(section);
setInitialProjectPath(projectPath ?? null);
setIsOpen(true);
}, []);

Expand All @@ -42,11 +46,12 @@ export function SettingsProvider(props: { children: ReactNode }) {
() => ({
isOpen,
activeSection,
initialProjectPath,
open,
close,
setActiveSection,
}),
[isOpen, activeSection, open, close]
[isOpen, activeSection, initialProjectPath, open, close]
);

return <SettingsContext.Provider value={value}>{props.children}</SettingsContext.Provider>;
Expand Down
139 changes: 21 additions & 118 deletions src/browser/stories/App.mcp.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,20 +158,24 @@ async function openProjectSettings(canvasElement: HTMLElement): Promise<void> {
mcpHeading.scrollIntoView({ block: "start" });
}

/** Open the workspace MCP modal */
async function openWorkspaceMCPModal(canvasElement: HTMLElement): Promise<void> {
/** Open project settings by clicking MCP button in workspace header */
async function openProjectSettingsViaMCPButton(canvasElement: HTMLElement): Promise<void> {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);

// Wait for workspace header to load
await canvas.findByTestId("workspace-header", {}, { timeout: 10000 });

// Click the MCP server button in the header
// Click the MCP server button in the header - now opens project settings
const mcpButton = await canvas.findByTestId("workspace-mcp-button");
await userEvent.click(mcpButton);

// Wait for dialog
// Wait for settings dialog
await body.findByRole("dialog");

// Scroll to MCP Servers section
const mcpHeading = await body.findByText("MCP Servers");
mcpHeading.scrollIntoView({ behavior: "instant", block: "start" });
}

// ═══════════════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -283,11 +287,11 @@ export const ProjectSettingsWithToolAllowlist: AppStory = {
};

// ═══════════════════════════════════════════════════════════════════════════════
// WORKSPACE MCP MODAL STORIES
// MCP BUTTON NAVIGATION STORIES
// ═══════════════════════════════════════════════════════════════════════════════

/** Workspace MCP modal with servers from project (no overrides) */
export const WorkspaceMCPNoOverrides: AppStory = {
/** MCP button in header opens project settings with MCP servers visible */
export const MCPButtonOpensProjectSettings: AppStory = {
render: () => (
<AppWithMocks
setup={() =>
Expand All @@ -306,48 +310,18 @@ export const WorkspaceMCPNoOverrides: AppStory = {
/>
),
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
await openWorkspaceMCPModal(canvasElement);
await openProjectSettingsViaMCPButton(canvasElement);

const body = within(canvasElement.ownerDocument.body);

// Both servers should be shown and enabled
// Both servers should be shown in project settings
await body.findByText("mux");
await body.findByText("posthog");
},
};

/** Workspace MCP modal - server disabled at project level, can be enabled */
export const WorkspaceMCPProjectDisabledServer: AppStory = {
render: () => (
<AppWithMocks
setup={() =>
setupMCPStory({
servers: {
mux: { command: "npx -y @anthropics/mux-server", disabled: false },
posthog: { command: "npx -y posthog-mcp-server", disabled: true },
},
testResults: {
mux: MOCK_TOOLS,
posthog: POSTHOG_TOOLS,
},
preCacheTools: true,
})
}
/>
),
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
await openWorkspaceMCPModal(canvasElement);

const body = within(canvasElement.ownerDocument.body);

// posthog should show "(disabled at project level)" but switch should still be toggleable
await body.findByText("posthog");
await body.findByText(/disabled at project level/i);
},
};

/** Workspace MCP modal - server disabled at project level, enabled at workspace level */
export const WorkspaceMCPEnabledOverride: AppStory = {
/** MCP button opens project settings with disabled server */
export const MCPButtonWithDisabledServer: AppStory = {
render: () => (
<AppWithMocks
setup={() =>
Expand All @@ -356,44 +330,6 @@ export const WorkspaceMCPEnabledOverride: AppStory = {
mux: { command: "npx -y @anthropics/mux-server", disabled: false },
posthog: { command: "npx -y posthog-mcp-server", disabled: true },
},
workspaceOverrides: {
enabledServers: ["posthog"],
},
testResults: {
mux: MOCK_TOOLS,
posthog: POSTHOG_TOOLS,
},
preCacheTools: true,
})
}
/>
),
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
await openWorkspaceMCPModal(canvasElement);

const body = within(canvasElement.ownerDocument.body);

// posthog should be enabled despite project-level disable
await body.findByText("posthog");
await body.findByText(/disabled at project level/i);

// The switch should be ON (enabled at workspace level)
},
};

/** Workspace MCP modal - server enabled at project level, disabled at workspace level */
export const WorkspaceMCPDisabledOverride: AppStory = {
render: () => (
<AppWithMocks
setup={() =>
setupMCPStory({
servers: {
mux: { command: "npx -y @anthropics/mux-server", disabled: false },
posthog: { command: "npx -y posthog-mcp-server", disabled: false },
},
workspaceOverrides: {
disabledServers: ["posthog"],
},
testResults: {
mux: MOCK_TOOLS,
posthog: POSTHOG_TOOLS,
Expand All @@ -404,54 +340,21 @@ export const WorkspaceMCPDisabledOverride: AppStory = {
/>
),
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
await openWorkspaceMCPModal(canvasElement);
await openProjectSettingsViaMCPButton(canvasElement);

const body = within(canvasElement.ownerDocument.body);

// mux should be enabled, posthog should be disabled
// Both servers should be visible
await body.findByText("mux");
await body.findByText("posthog");
},
};

/** Workspace MCP modal with tool allowlist filtering */
export const WorkspaceMCPWithToolAllowlist: AppStory = {
render: () => (
<AppWithMocks
setup={() =>
setupMCPStory({
servers: {
posthog: { command: "npx -y posthog-mcp-server", disabled: false },
},
workspaceOverrides: {
toolAllowlist: {
posthog: ["docs-search", "error-details", "list-errors"],
},
},
testResults: {
posthog: POSTHOG_TOOLS,
},
preCacheTools: true,
})
}
/>
),
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
await openWorkspaceMCPModal(canvasElement);

const body = within(canvasElement.ownerDocument.body);
await body.findByText("posthog");

// Should show filtered tool count
await body.findByText(/3 of 14 tools enabled/i);
},
};

// ═══════════════════════════════════════════════════════════════════════════════
// INTERACTION STORIES
// ═══════════════════════════════════════════════════════════════════════════════

/** Interact with tool selector - click All/None buttons */
/** Interact with tool selector in project settings - click All/None buttons */
export const ToolSelectorInteraction: AppStory = {
render: () => (
<AppWithMocks
Expand All @@ -469,7 +372,7 @@ export const ToolSelectorInteraction: AppStory = {
/>
),
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
await openWorkspaceMCPModal(canvasElement);
await openProjectSettingsViaMCPButton(canvasElement);

const body = within(canvasElement.ownerDocument.body);

Expand All @@ -489,7 +392,7 @@ export const ToolSelectorInteraction: AppStory = {
},
};

/** Toggle server enabled state in workspace modal */
/** Toggle server enabled state in project settings */
export const ToggleServerEnabled: AppStory = {
render: () => (
<AppWithMocks
Expand All @@ -509,7 +412,7 @@ export const ToggleServerEnabled: AppStory = {
/>
),
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
await openWorkspaceMCPModal(canvasElement);
await openProjectSettingsViaMCPButton(canvasElement);

const body = within(canvasElement.ownerDocument.body);

Expand Down
Loading