fix(extensions): preserve mcp dialog state on runtime hydration
This commit is contained in:
parent
1b1c27e9c6
commit
88ac0acdf8
5 changed files with 195 additions and 11 deletions
|
|
@ -29,6 +29,7 @@ import {
|
||||||
getDefaultMcpSharedScope,
|
getDefaultMcpSharedScope,
|
||||||
getMcpScopeLabel,
|
getMcpScopeLabel,
|
||||||
isProjectScopedMcpScope,
|
isProjectScopedMcpScope,
|
||||||
|
isSharedMcpScope,
|
||||||
} from '@shared/utils/mcpScopes';
|
} from '@shared/utils/mcpScopes';
|
||||||
import { Plus, Server, Trash2 } from 'lucide-react';
|
import { Plus, Server, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -95,6 +96,8 @@ export const CustomMcpServerDialog = ({
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [installing, setInstalling] = useState(false);
|
const [installing, setInstalling] = useState(false);
|
||||||
const autoFilledValuesRef = useRef<Record<string, string>>({});
|
const autoFilledValuesRef = useRef<Record<string, string>>({});
|
||||||
|
const wasOpenRef = useRef(false);
|
||||||
|
const previousDefaultSharedScopeRef = useRef<Scope>(defaultSharedScope);
|
||||||
const envVarLookupNames = envVars
|
const envVarLookupNames = envVars
|
||||||
.map((entry) => entry.key.trim())
|
.map((entry) => entry.key.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
@ -112,7 +115,8 @@ export const CustomMcpServerDialog = ({
|
||||||
|
|
||||||
// Reset on open
|
// Reset on open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
const justOpened = open && !wasOpenRef.current;
|
||||||
|
if (justOpened) {
|
||||||
setServerName('');
|
setServerName('');
|
||||||
setTransportMode('stdio');
|
setTransportMode('stdio');
|
||||||
setScope(defaultSharedScope);
|
setScope(defaultSharedScope);
|
||||||
|
|
@ -126,8 +130,30 @@ export const CustomMcpServerDialog = ({
|
||||||
setInstalling(false);
|
setInstalling(false);
|
||||||
autoFilledValuesRef.current = {};
|
autoFilledValuesRef.current = {};
|
||||||
}
|
}
|
||||||
|
wasOpenRef.current = open;
|
||||||
|
if (!open) {
|
||||||
|
previousDefaultSharedScopeRef.current = defaultSharedScope;
|
||||||
|
}
|
||||||
}, [defaultSharedScope, open]);
|
}, [defaultSharedScope, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
previousDefaultSharedScopeRef.current = defaultSharedScope;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousDefaultSharedScope = previousDefaultSharedScopeRef.current;
|
||||||
|
if (
|
||||||
|
previousDefaultSharedScope !== defaultSharedScope &&
|
||||||
|
scope === previousDefaultSharedScope &&
|
||||||
|
isSharedMcpScope(scope)
|
||||||
|
) {
|
||||||
|
setScope(defaultSharedScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
previousDefaultSharedScopeRef.current = defaultSharedScope;
|
||||||
|
}, [defaultSharedScope, open, scope]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && isProjectScopedMcpScope(scope) && !projectPath) {
|
if (open && isProjectScopedMcpScope(scope) && !projectPath) {
|
||||||
setScope(defaultSharedScope);
|
setScope(defaultSharedScope);
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import {
|
||||||
getDefaultMcpSharedScope,
|
getDefaultMcpSharedScope,
|
||||||
getMcpScopeLabel,
|
getMcpScopeLabel,
|
||||||
isProjectScopedMcpScope,
|
isProjectScopedMcpScope,
|
||||||
|
isSharedMcpScope,
|
||||||
} from '@shared/utils/mcpScopes';
|
} from '@shared/utils/mcpScopes';
|
||||||
import {
|
import {
|
||||||
getMcpInstallationSummaryLabel,
|
getMcpInstallationSummaryLabel,
|
||||||
|
|
@ -93,6 +94,7 @@ export const McpServerDetailDialog = ({
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
|
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
|
||||||
const autoFilledValuesRef = useRef<Record<string, string>>({});
|
const autoFilledValuesRef = useRef<Record<string, string>>({});
|
||||||
|
const previousDefaultSharedScopeRef = useRef<Scope>(defaultSharedScope);
|
||||||
const normalizedInstalledEntries = installedEntries.length
|
const normalizedInstalledEntries = installedEntries.length
|
||||||
? installedEntries
|
? installedEntries
|
||||||
: installedEntry
|
: installedEntry
|
||||||
|
|
@ -143,21 +145,34 @@ export const McpServerDetailDialog = ({
|
||||||
setImgError(false);
|
setImgError(false);
|
||||||
setAutoFilledFields(new Set());
|
setAutoFilledFields(new Set());
|
||||||
autoFilledValuesRef.current = {};
|
autoFilledValuesRef.current = {};
|
||||||
}, [
|
}, [open, preferredInstalledEntry?.name, preferredInstalledEntry?.scope, server?.id]);
|
||||||
defaultSharedScope,
|
|
||||||
open,
|
|
||||||
preferredInstalledEntry?.name,
|
|
||||||
preferredInstalledEntry?.scope,
|
|
||||||
server?.id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!server || !open) {
|
if (!open) {
|
||||||
|
previousDefaultSharedScopeRef.current = defaultSharedScope;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setServerName(selectedInstalledEntry?.name ?? sanitizeMcpServerName(server.name));
|
const previousDefaultSharedScope = previousDefaultSharedScopeRef.current;
|
||||||
}, [open, scope, selectedInstalledEntry?.name, server]);
|
if (
|
||||||
|
previousDefaultSharedScope !== defaultSharedScope &&
|
||||||
|
!preferredInstalledEntry &&
|
||||||
|
scope === previousDefaultSharedScope &&
|
||||||
|
isSharedMcpScope(scope)
|
||||||
|
) {
|
||||||
|
setScope(defaultSharedScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
previousDefaultSharedScopeRef.current = defaultSharedScope;
|
||||||
|
}, [defaultSharedScope, open, preferredInstalledEntry, scope]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!server || !open || !selectedInstalledEntry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setServerName(selectedInstalledEntry.name);
|
||||||
|
}, [open, selectedInstalledEntry, server]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && isProjectScopedMcpScope(scope) && !projectPath) {
|
if (open && isProjectScopedMcpScope(scope) && !projectPath) {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ export function getDefaultMcpSharedScope(flavor?: CliFlavor | null): McpSharedSc
|
||||||
return flavor === 'agent_teams_orchestrator' ? 'global' : 'user';
|
return flavor === 'agent_teams_orchestrator' ? 'global' : 'user';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isSharedMcpScope(scope?: string): scope is McpSharedScope {
|
||||||
|
return scope === 'user' || scope === 'global';
|
||||||
|
}
|
||||||
|
|
||||||
export function isProjectScopedMcpScope(scope?: string): scope is 'project' | 'local' {
|
export function isProjectScopedMcpScope(scope?: string): scope is 'project' | 'local' {
|
||||||
return scope === 'project' || scope === 'local';
|
return scope === 'project' || scope === 'local';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,63 @@ describe('CustomMcpServerDialog project scope', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves entered values when multimodel scope metadata loads after open', async () => {
|
||||||
|
storeState.cliStatus = null;
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(CustomMcpServerDialog, {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
projectPath: null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const nameInput = host.querySelector('#custom-name') as HTMLInputElement;
|
||||||
|
const packageInput = host.querySelector('#custom-npm') as HTMLInputElement;
|
||||||
|
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setNativeValue(nameInput, 'late-hydration-server', 'input');
|
||||||
|
setNativeValue(packageInput, '@example/late-hydration', 'input');
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scopeSelect.value).toBe('user');
|
||||||
|
|
||||||
|
storeState.cliStatus = { flavor: 'agent_teams_orchestrator' };
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(CustomMcpServerDialog, {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
projectPath: null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((host.querySelector('#custom-name') as HTMLInputElement).value).toBe(
|
||||||
|
'late-hydration-server'
|
||||||
|
);
|
||||||
|
expect((host.querySelector('#custom-npm') as HTMLInputElement).value).toBe(
|
||||||
|
'@example/late-hydration'
|
||||||
|
);
|
||||||
|
expect((host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement).value).toBe(
|
||||||
|
'global'
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('disables installation when the runtime declares MCP writes unavailable', async () => {
|
it('disables installation when the runtime declares MCP writes unavailable', async () => {
|
||||||
storeState.cliStatus = {
|
storeState.cliStatus = {
|
||||||
flavor: 'agent_teams_orchestrator',
|
flavor: 'agent_teams_orchestrator',
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,15 @@ function makeServer(): McpCatalogItem {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setInputValue(element: HTMLInputElement | HTMLSelectElement, value: string): void {
|
||||||
|
const prototype =
|
||||||
|
element instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype;
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
|
||||||
|
descriptor?.set?.call(element, value);
|
||||||
|
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
describe('McpServerDetailDialog installed entry handling', () => {
|
describe('McpServerDetailDialog installed entry handling', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
|
@ -416,6 +425,79 @@ describe('McpServerDetailDialog installed entry handling', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves edited fields when multimodel scope metadata loads after open', async () => {
|
||||||
|
storeState.cliStatus = null;
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
const server = makeServer();
|
||||||
|
server.envVars = [{ name: 'CONTEXT7_API_KEY', isSecret: true }];
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(McpServerDetailDialog, {
|
||||||
|
server,
|
||||||
|
isInstalled: false,
|
||||||
|
installedEntry: null,
|
||||||
|
installedEntries: [],
|
||||||
|
diagnostic: null,
|
||||||
|
diagnosticsLoading: false,
|
||||||
|
projectPath: null,
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const serverNameInput = host.querySelector('#server-name') as HTMLInputElement;
|
||||||
|
const envValueInput = host.querySelector('input[type="password"]') as HTMLInputElement;
|
||||||
|
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setInputValue(serverNameInput, 'late-hydration-context7');
|
||||||
|
setInputValue(envValueInput, 'secret');
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scopeSelect.value).toBe('user');
|
||||||
|
|
||||||
|
storeState.cliStatus = { flavor: 'agent_teams_orchestrator' };
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(McpServerDetailDialog, {
|
||||||
|
server,
|
||||||
|
isInstalled: false,
|
||||||
|
installedEntry: null,
|
||||||
|
installedEntries: [],
|
||||||
|
diagnostic: null,
|
||||||
|
diagnosticsLoading: false,
|
||||||
|
projectPath: null,
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((host.querySelector('#server-name') as HTMLInputElement).value).toBe(
|
||||||
|
'late-hydration-context7'
|
||||||
|
);
|
||||||
|
expect((host.querySelector('input[type="password"]') as HTMLInputElement).value).toBe(
|
||||||
|
'secret'
|
||||||
|
);
|
||||||
|
expect((host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement).value).toBe(
|
||||||
|
'global'
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('passes project path for project-scoped installs and uninstalls', async () => {
|
it('passes project path for project-scoped installs and uninstalls', async () => {
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue