feat (ui) - use acp to manage global config and session extensions (#9948)
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
import type { ExtensionResponse, ExtensionEntry } from '../api';
|
||||
import type { GooseExtension, GooseExtensionEntry, McpServer } from '@aaif/goose-sdk';
|
||||
import type { ExtensionConfig, ExtensionEntry } from '../api';
|
||||
import type { GooseExtension, GooseExtensionEntry } from '@aaif/goose-sdk';
|
||||
import { getAcpClient } from './acpConnection';
|
||||
|
||||
export type ConfiguredExtensionEntry = ExtensionEntry & { configKey?: string };
|
||||
|
||||
export interface ConfiguredExtensionsResponse {
|
||||
extensions: ConfiguredExtensionEntry[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function gooseExtensionName(extension: GooseExtension): string {
|
||||
return extension.type === 'mcp' ? extension.server.name : extension.name;
|
||||
}
|
||||
@@ -10,63 +17,54 @@ function headersToRecord(headers: { name: string; value: string }[] = []) {
|
||||
return Object.fromEntries(headers.map(({ name, value }) => [name, value]));
|
||||
}
|
||||
|
||||
function mcpServerToExtension(
|
||||
server: McpServer,
|
||||
entry: GooseExtensionEntry
|
||||
): ExtensionEntry | null {
|
||||
const extension = entry.extension;
|
||||
if (extension.type !== 'mcp') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ('command' in server) {
|
||||
return {
|
||||
type: 'stdio',
|
||||
enabled: entry.enabled,
|
||||
name: server.name,
|
||||
description: extension.description ?? '',
|
||||
cmd: server.command,
|
||||
args: server.args,
|
||||
env_keys: extension.envKeys ?? [],
|
||||
timeout: extension.timeout,
|
||||
bundled: extension.bundled,
|
||||
};
|
||||
}
|
||||
|
||||
if ('url' in server) {
|
||||
return {
|
||||
type: 'streamable_http',
|
||||
enabled: entry.enabled,
|
||||
name: server.name,
|
||||
description: extension.description ?? '',
|
||||
uri: server.url,
|
||||
headers: headersToRecord(server.headers),
|
||||
env_keys: extension.envKeys ?? [],
|
||||
timeout: extension.timeout,
|
||||
socket: extension.socket,
|
||||
bundled: extension.bundled,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function gooseExtensionEntryToExtensionEntry(entry: GooseExtensionEntry): ExtensionEntry | null {
|
||||
const extension = entry.extension;
|
||||
|
||||
export function gooseExtensionToExtensionConfig(extension: GooseExtension): ExtensionConfig | null {
|
||||
switch (extension.type) {
|
||||
case 'builtin':
|
||||
case 'platform':
|
||||
return {
|
||||
...extension,
|
||||
description: extension.description ?? '',
|
||||
enabled: entry.enabled,
|
||||
};
|
||||
case 'mcp':
|
||||
return mcpServerToExtension(extension.server, entry);
|
||||
case 'mcp': {
|
||||
const server = extension.server;
|
||||
if ('command' in server) {
|
||||
return {
|
||||
type: 'stdio',
|
||||
name: server.name,
|
||||
description: extension.description ?? '',
|
||||
cmd: server.command,
|
||||
args: server.args,
|
||||
env_keys: extension.envKeys ?? [],
|
||||
timeout: extension.timeout,
|
||||
bundled: extension.bundled,
|
||||
};
|
||||
}
|
||||
if ('url' in server) {
|
||||
return {
|
||||
type: 'streamable_http',
|
||||
name: server.name,
|
||||
description: extension.description ?? '',
|
||||
uri: server.url,
|
||||
headers: headersToRecord(server.headers),
|
||||
env_keys: extension.envKeys ?? [],
|
||||
timeout: extension.timeout,
|
||||
socket: extension.socket,
|
||||
bundled: extension.bundled,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
function gooseExtensionEntryToExtensionEntry(
|
||||
entry: GooseExtensionEntry
|
||||
): ConfiguredExtensionEntry | null {
|
||||
const config = gooseExtensionToExtensionConfig(entry.extension);
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
return { ...config, enabled: entry.enabled, configKey: entry.configKey ?? undefined };
|
||||
}
|
||||
|
||||
export async function getConfiguredGooseExtensions(): Promise<GooseExtensionEntry[]> {
|
||||
@@ -75,13 +73,85 @@ export async function getConfiguredGooseExtensions(): Promise<GooseExtensionEntr
|
||||
return response.extensions;
|
||||
}
|
||||
|
||||
export async function getConfiguredExtensions(): Promise<ExtensionResponse> {
|
||||
export async function getConfiguredExtensions(): Promise<ConfiguredExtensionsResponse> {
|
||||
const client = await getAcpClient();
|
||||
const response = await client.goose.configExtensionsList_unstable({});
|
||||
return {
|
||||
extensions: response.extensions
|
||||
.map(gooseExtensionEntryToExtensionEntry)
|
||||
.filter((entry): entry is ExtensionEntry => entry !== null),
|
||||
.filter((entry): entry is ConfiguredExtensionEntry => entry !== null),
|
||||
warnings: response.warnings ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function extensionConfigToGooseExtension(config: ExtensionConfig): GooseExtension | null {
|
||||
switch (config.type) {
|
||||
case 'builtin':
|
||||
return {
|
||||
type: 'builtin',
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
display_name: config.display_name,
|
||||
timeout: config.timeout,
|
||||
bundled: config.bundled,
|
||||
};
|
||||
case 'platform':
|
||||
return {
|
||||
type: 'platform',
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
display_name: config.display_name,
|
||||
bundled: config.bundled,
|
||||
};
|
||||
case 'stdio':
|
||||
return {
|
||||
type: 'mcp',
|
||||
server: { name: config.name, command: config.cmd, args: config.args, env: [] },
|
||||
envKeys: config.env_keys ?? [],
|
||||
description: config.description,
|
||||
timeout: config.timeout,
|
||||
bundled: config.bundled,
|
||||
};
|
||||
case 'streamable_http':
|
||||
return {
|
||||
type: 'mcp',
|
||||
server: {
|
||||
type: 'http',
|
||||
name: config.name,
|
||||
url: config.uri,
|
||||
headers: Object.entries(config.headers ?? {}).map(([name, value]) => ({ name, value })),
|
||||
},
|
||||
envKeys: config.env_keys ?? [],
|
||||
description: config.description,
|
||||
timeout: config.timeout,
|
||||
socket: config.socket,
|
||||
bundled: config.bundled,
|
||||
};
|
||||
case 'sse':
|
||||
case 'frontend':
|
||||
case 'inline_python':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addConfigExtension(config: ExtensionConfig, enabled: boolean): Promise<void> {
|
||||
const extension = extensionConfigToGooseExtension(config);
|
||||
if (!extension) {
|
||||
throw new Error(`Unsupported extension type for ACP: ${config.type}`);
|
||||
}
|
||||
const client = await getAcpClient();
|
||||
await client.goose.configExtensionsAdd_unstable({ extension, enabled });
|
||||
}
|
||||
|
||||
export async function removeConfigExtension(configKey: string): Promise<void> {
|
||||
const client = await getAcpClient();
|
||||
await client.goose.configExtensionsRemove_unstable({ configKey });
|
||||
}
|
||||
|
||||
export async function setConfigExtensionEnabled(
|
||||
configKey: string,
|
||||
enabled: boolean
|
||||
): Promise<void> {
|
||||
const client = await getAcpClient();
|
||||
await client.goose.configExtensionsSetEnabled_unstable({ configKey, enabled });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ExtensionConfig } from '../api';
|
||||
import { getAcpClient } from './acpConnection';
|
||||
import { extensionConfigToGooseExtension, gooseExtensionToExtensionConfig } from './extensions';
|
||||
|
||||
export async function getSessionExtensions(sessionId: string): Promise<ExtensionConfig[]> {
|
||||
const client = await getAcpClient();
|
||||
const response = await client.goose.sessionExtensionsList_unstable({ sessionId });
|
||||
return response.extensions
|
||||
.map(gooseExtensionToExtensionConfig)
|
||||
.filter((config): config is ExtensionConfig => config !== null);
|
||||
}
|
||||
|
||||
export async function addSessionExtension(
|
||||
sessionId: string,
|
||||
config: ExtensionConfig
|
||||
): Promise<void> {
|
||||
const extension = extensionConfigToGooseExtension(config);
|
||||
if (!extension) {
|
||||
throw new Error(`Unsupported extension type for ACP: ${config.type}`);
|
||||
}
|
||||
const client = await getAcpClient();
|
||||
await client.goose.sessionExtensionsAdd_unstable({ sessionId, extension });
|
||||
}
|
||||
|
||||
export async function removeSessionExtension(sessionId: string, name: string): Promise<void> {
|
||||
const client = await getAcpClient();
|
||||
await client.goose.sessionExtensionsRemove_unstable({ sessionId, name });
|
||||
}
|
||||
@@ -1,21 +1,18 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { readAllConfig, readConfig, removeConfig, upsertConfig, providers } from '../api';
|
||||
import {
|
||||
readAllConfig,
|
||||
readConfig,
|
||||
removeConfig,
|
||||
upsertConfig,
|
||||
addExtension as apiAddExtension,
|
||||
removeExtension as apiRemoveExtension,
|
||||
providers,
|
||||
} from '../api';
|
||||
import { getConfiguredExtensions } from '../acp/extensions';
|
||||
getConfiguredExtensions,
|
||||
addConfigExtension,
|
||||
removeConfigExtension,
|
||||
setConfigExtensionEnabled,
|
||||
} from '../acp/extensions';
|
||||
import { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './settings/extensions';
|
||||
import { nameToKey } from './settings/extensions/utils';
|
||||
import type {
|
||||
ConfigResponse,
|
||||
UpsertConfigQuery,
|
||||
ConfigKeyQuery,
|
||||
ProviderDetails,
|
||||
ExtensionQuery,
|
||||
ExtensionConfig,
|
||||
} from '../api';
|
||||
|
||||
@@ -24,6 +21,7 @@ export type { ExtensionConfig } from '../api/types.gen';
|
||||
// Define a local version that matches the structure of the imported one
|
||||
export type FixedExtensionEntry = ExtensionConfig & {
|
||||
enabled: boolean;
|
||||
configKey?: string;
|
||||
};
|
||||
|
||||
interface ConfigContextType {
|
||||
@@ -35,12 +33,10 @@ interface ConfigContextType {
|
||||
read: (key: string, is_secret: boolean, options?: { throwOnError?: boolean }) => Promise<unknown>;
|
||||
remove: (key: string, is_secret: boolean) => Promise<void>;
|
||||
addExtension: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>;
|
||||
toggleExtension: (name: string) => Promise<void>;
|
||||
setExtensionEnabled: (configKey: string, enabled: boolean) => Promise<void>;
|
||||
removeExtension: (name: string) => Promise<void>;
|
||||
getProviders: (b: boolean) => Promise<ProviderDetails[]>;
|
||||
getExtensions: (b: boolean) => Promise<FixedExtensionEntry[]>;
|
||||
disableAllExtensions: () => Promise<void>;
|
||||
enableBotExtensions: (extensions: ExtensionConfig[]) => Promise<void>;
|
||||
}
|
||||
|
||||
interface ConfigProviderProps {
|
||||
@@ -112,11 +108,8 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
}, []);
|
||||
|
||||
const addExtension = useCallback(
|
||||
async (name: string, config: ExtensionConfig, enabled: boolean) => {
|
||||
const query: ExtensionQuery = { name, config, enabled };
|
||||
await apiAddExtension({
|
||||
body: query,
|
||||
});
|
||||
async (_name: string, config: ExtensionConfig, enabled: boolean) => {
|
||||
await addConfigExtension(config, enabled);
|
||||
await reloadConfig();
|
||||
// Refresh extensions list after successful addition
|
||||
await refreshExtensions();
|
||||
@@ -126,12 +119,13 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
|
||||
const removeExtension = useCallback(
|
||||
async (name: string) => {
|
||||
await apiRemoveExtension({ path: { name: name } });
|
||||
const entry = extensionsList.find((ext) => ext.name === name);
|
||||
await removeConfigExtension(entry?.configKey ?? nameToKey(name));
|
||||
await reloadConfig();
|
||||
// Refresh extensions list after successful removal
|
||||
await refreshExtensions();
|
||||
},
|
||||
[reloadConfig, refreshExtensions]
|
||||
[extensionsList, reloadConfig, refreshExtensions]
|
||||
);
|
||||
|
||||
const getExtensions = useCallback(
|
||||
@@ -144,16 +138,13 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
[extensionsList, refreshExtensions]
|
||||
);
|
||||
|
||||
const toggleExtension = useCallback(
|
||||
async (name: string) => {
|
||||
const exts = await getExtensions(true);
|
||||
const extension = exts.find((ext) => ext.name === name);
|
||||
|
||||
if (extension) {
|
||||
await addExtension(name, extension, !extension.enabled);
|
||||
}
|
||||
const setExtensionEnabled = useCallback(
|
||||
async (configKey: string, enabled: boolean) => {
|
||||
await setConfigExtensionEnabled(configKey, enabled);
|
||||
await reloadConfig();
|
||||
await refreshExtensions();
|
||||
},
|
||||
[addExtension, getExtensions]
|
||||
[reloadConfig, refreshExtensions]
|
||||
);
|
||||
|
||||
const getProviders = useCallback(async (forceRefresh = false): Promise<ProviderDetails[]> => {
|
||||
@@ -202,15 +193,14 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
// The syncBundledExtensions function skips extensions that already exist and are marked as bundled
|
||||
// Platform extensions (code_execution, todo, etc.) are handled by the backend
|
||||
const addExtensionForSync = async (
|
||||
name: string,
|
||||
_name: string,
|
||||
config: ExtensionConfig,
|
||||
enabled: boolean
|
||||
) => {
|
||||
const query: ExtensionQuery = { name, config, enabled };
|
||||
await apiAddExtension({ body: query });
|
||||
await addConfigExtension(config, enabled);
|
||||
};
|
||||
const removeExtensionForSync = async (name: string) => {
|
||||
await apiRemoveExtension({ path: { name } });
|
||||
const removeExtensionForSync = async (configKey: string) => {
|
||||
await removeConfigExtension(configKey);
|
||||
};
|
||||
extensions = await pruneDeprecatedBundledExtensions(extensions, removeExtensionForSync);
|
||||
await syncBundledExtensions(extensions, addExtensionForSync);
|
||||
@@ -227,23 +217,6 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(() => {
|
||||
const disableAllExtensions = async () => {
|
||||
const currentExtensions = await getExtensions(true);
|
||||
for (const ext of currentExtensions) {
|
||||
if (ext.enabled) {
|
||||
await addExtension(ext.name, ext, false);
|
||||
}
|
||||
}
|
||||
await reloadConfig();
|
||||
};
|
||||
|
||||
const enableBotExtensions = async (extensions: ExtensionConfig[]) => {
|
||||
for (const ext of extensions) {
|
||||
await addExtension(ext.name, ext, true);
|
||||
}
|
||||
await reloadConfig();
|
||||
};
|
||||
|
||||
return {
|
||||
config,
|
||||
providersList,
|
||||
@@ -254,11 +227,9 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
remove,
|
||||
addExtension,
|
||||
removeExtension,
|
||||
toggleExtension,
|
||||
setExtensionEnabled,
|
||||
getProviders,
|
||||
getExtensions,
|
||||
disableAllExtensions,
|
||||
enableBotExtensions,
|
||||
};
|
||||
}, [
|
||||
config,
|
||||
@@ -270,10 +241,9 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
remove,
|
||||
addExtension,
|
||||
removeExtension,
|
||||
toggleExtension,
|
||||
setExtensionEnabled,
|
||||
getProviders,
|
||||
getExtensions,
|
||||
reloadConfig,
|
||||
]);
|
||||
|
||||
return <ConfigContext.Provider value={contextValue}>{children}</ConfigContext.Provider>;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { toastService } from '../../toasts';
|
||||
import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList';
|
||||
import { nameToKey } from '../settings/extensions/utils';
|
||||
import { ExtensionConfig, getSessionExtensions } from '../../api';
|
||||
import { getSessionExtensions as getAcpSessionExtensions } from '../../acp/session-extensions';
|
||||
import { USE_ACP_CHAT } from '../../acpChatFeatureFlag';
|
||||
import { addToAgent, removeFromAgent } from '../settings/extensions/agent-api';
|
||||
import { defineMessages, useIntl } from '../../i18n';
|
||||
import { AppEvents } from '../../constants/events';
|
||||
@@ -260,17 +262,21 @@ function SessionExtensionsMenu({ sessionId }: { sessionId: string }) {
|
||||
|
||||
const loadSessionExtensions = useCallback(
|
||||
async (targetSessionId: string, signal?: GetSessionExtensionsSignal) => {
|
||||
const response = await getSessionExtensions({
|
||||
path: { session_id: targetSessionId },
|
||||
signal,
|
||||
throwOnError: true,
|
||||
});
|
||||
const extensions = USE_ACP_CHAT
|
||||
? await getAcpSessionExtensions(targetSessionId)
|
||||
: ((
|
||||
await getSessionExtensions({
|
||||
path: { session_id: targetSessionId },
|
||||
signal,
|
||||
throwOnError: true,
|
||||
})
|
||||
).data?.extensions ?? []);
|
||||
|
||||
if (signal?.aborted || latestSessionIdRef.current !== targetSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionExtensions(response.data?.extensions ?? []);
|
||||
setSessionExtensions(extensions);
|
||||
setIsSessionExtensionsLoaded(true);
|
||||
},
|
||||
[]
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ExtensionFormData,
|
||||
extensionToFormData,
|
||||
getDefaultFormData,
|
||||
nameToKey,
|
||||
} from './utils';
|
||||
|
||||
import { activateExtensionDefault, deleteExtension, toggleExtensionDefault } from './index';
|
||||
@@ -61,7 +62,8 @@ export default function ExtensionsSection({
|
||||
searchTerm = '',
|
||||
}: ExtensionSectionProps) {
|
||||
const intl = useIntl();
|
||||
const { getExtensions, addExtension, removeExtension, extensionsList } = useConfig();
|
||||
const { getExtensions, addExtension, removeExtension, setExtensionEnabled, extensionsList } =
|
||||
useConfig();
|
||||
const [selectedExtension, setSelectedExtension] = useState<FixedExtensionEntry | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
@@ -113,11 +115,12 @@ export default function ExtensionsSection({
|
||||
}
|
||||
|
||||
const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn';
|
||||
const configKey = extensionConfig.configKey ?? nameToKey(extensionConfig.name);
|
||||
|
||||
await toggleExtensionDefault({
|
||||
toggle: toggleDirection,
|
||||
extensionConfig: extensionConfig,
|
||||
addToConfig: addExtension,
|
||||
setEnabled: (enabled) => setExtensionEnabled(configKey, enabled),
|
||||
});
|
||||
|
||||
await fetchExtensions();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { toastService } from '../../../toasts';
|
||||
import { agentAddExtension, ExtensionConfig, agentRemoveExtension } from '../../../api';
|
||||
import { USE_ACP_CHAT } from '../../../acpChatFeatureFlag';
|
||||
import { addSessionExtension, removeSessionExtension } from '../../../acp/session-extensions';
|
||||
import { errorMessage } from '../../../utils/conversionUtils';
|
||||
import {
|
||||
createExtensionRecoverHints,
|
||||
@@ -20,10 +22,14 @@ export async function addToAgent(
|
||||
: 0;
|
||||
|
||||
try {
|
||||
await agentAddExtension({
|
||||
body: { session_id: sessionId, config: extensionConfig },
|
||||
throwOnError: true,
|
||||
});
|
||||
if (USE_ACP_CHAT) {
|
||||
await addSessionExtension(sessionId, extensionConfig);
|
||||
} else {
|
||||
await agentAddExtension({
|
||||
body: { session_id: sessionId, config: extensionConfig },
|
||||
throwOnError: true,
|
||||
});
|
||||
}
|
||||
if (showToast) {
|
||||
toastService.dismiss(toastId);
|
||||
toastService.success({
|
||||
@@ -61,10 +67,14 @@ export async function removeFromAgent(
|
||||
: 0;
|
||||
|
||||
try {
|
||||
await agentRemoveExtension({
|
||||
body: { session_id: sessionId, name: extensionName },
|
||||
throwOnError: true,
|
||||
});
|
||||
if (USE_ACP_CHAT) {
|
||||
await removeSessionExtension(sessionId, extensionName);
|
||||
} else {
|
||||
await agentRemoveExtension({
|
||||
body: { session_id: sessionId, name: extensionName },
|
||||
throwOnError: true,
|
||||
});
|
||||
}
|
||||
if (showToast) {
|
||||
toastService.dismiss(toastId);
|
||||
toastService.success({
|
||||
|
||||
@@ -41,19 +41,19 @@ export async function deleteExtension({
|
||||
interface ToggleExtensionDefaultProps {
|
||||
toggle: 'toggleOn' | 'toggleOff';
|
||||
extensionConfig: ExtensionConfig;
|
||||
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
|
||||
setEnabled: (enabled: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function toggleExtensionDefault({
|
||||
toggle,
|
||||
extensionConfig,
|
||||
addToConfig,
|
||||
setEnabled,
|
||||
}: ToggleExtensionDefaultProps) {
|
||||
const isBuiltin = isBuiltinExtension(extensionConfig);
|
||||
const enabled = toggle === 'toggleOn';
|
||||
|
||||
try {
|
||||
await addToConfig(extensionConfig.name, extensionConfig, enabled);
|
||||
await setEnabled(enabled);
|
||||
if (enabled) {
|
||||
trackExtensionEnabled(extensionConfig.name, true, undefined, isBuiltin);
|
||||
} else {
|
||||
|
||||
@@ -45,7 +45,6 @@ export default function ExtensionTimeoutField({
|
||||
<Input
|
||||
value={timeout}
|
||||
onChange={(e) => onChange('timeout', e.target.value)}
|
||||
defaultValue={300}
|
||||
className={`${!submitAttempted || isTimeoutValid() ? 'border-border-primary' : 'border-red-500'} text-text-primary focus:border-border-primary`}
|
||||
/>
|
||||
{submitAttempted && !isTimeoutValid() && (
|
||||
|
||||
Reference in New Issue
Block a user