feat(chat): work-status panel, and MCP auth and settings fixes (#2776)
Adds a work-status panel beside the transcript. Context fill, model and cost, todos, running subagents and the permission requests blocking them, branch and working-tree state, MCP servers, pinned messages and context sources were scattered across the header, the composer and the context panel — a blocked subagent was reported nowhere at all. The panel reads them from live channels rather than persisted history, and becomes an overlay where the chat is too narrow to seat a column. It is on by default, including for existing installs. Because it now carries these readouts, the desktop header and composer drop the ones it duplicates: todo and changed-files chips, usage and MCP tabs. VS Code and mobile keep theirs — neither hosts the panel. Fixes MCP authorization, which was broken from the panel, invalidated by a directory switch through a redirect URI that encoded the working directory, and left the desktop app in the background because browsers will not follow a custom-protocol link without a user gesture. The settings page no longer asks the user to understand the MCP spec before adding a server: one field takes the command or the link, with the kind inferred and a visible override, and client-registration fields appear only when a server actually asks for its own credentials. Also: skills load from the panel instead of only when the composer's slash autocomplete opens; the header button names the current instance rather than falling through to the word "Instance" for relay hosts. Three new optional UI settings keys, all migrated. No change to stored MCP server configuration.
This commit is contained in:
@@ -20,21 +20,20 @@ import {
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsFieldRow,
|
||||
SettingsCheckboxRow,
|
||||
SettingsStackedField,
|
||||
SettingsChipGroup,
|
||||
SettingsGroupTitle,
|
||||
SettingsStackedField,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
SETTINGS_FIELD_LABEL_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
import { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
|
||||
import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
|
||||
import { buildMcpAuthorizationRedirectUri, startMcpAuthorization } from '@/components/sections/mcp/startMcpAuthorization';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -51,6 +50,7 @@ import {
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -59,11 +59,9 @@ import { useI18n } from '@/lib/i18n';
|
||||
interface CommandTextareaProps {
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
pasteCommandTitle: string;
|
||||
pasteCommandLabel: string;
|
||||
pasteSuccess: (count: number) => string;
|
||||
clipboardReadFailed: string;
|
||||
preview: (count: number) => string;
|
||||
/** Called when the text is plainly a link rather than a command. */
|
||||
onDetectUrl?: (url: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,11 +122,8 @@ function extractAuthorizationResponse(raw: string): {
|
||||
const CommandTextarea: React.FC<CommandTextareaProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
pasteCommandTitle,
|
||||
pasteCommandLabel,
|
||||
pasteSuccess,
|
||||
clipboardReadFailed,
|
||||
preview,
|
||||
onDetectUrl,
|
||||
}) => {
|
||||
// Internal: one arg per line
|
||||
const [text, setText] = React.useState(() => value.join('\n'));
|
||||
@@ -144,42 +139,38 @@ const CommandTextarea: React.FC<CommandTextareaProps> = ({
|
||||
|
||||
const commit = (raw: string) => {
|
||||
const lines = raw.split('\n').filter((l) => l.trim().length > 0);
|
||||
// A single line that is nothing but a URL is a hosted server, not a
|
||||
// command to run — the page switches kind rather than making the user say.
|
||||
if (onDetectUrl && lines.length === 1 && /^https?:\/\/\S+$/i.test(lines[0].trim())) {
|
||||
onDetectUrl(lines[0].trim());
|
||||
return;
|
||||
}
|
||||
onChange(lines);
|
||||
};
|
||||
|
||||
const handlePasteFromClipboard = async () => {
|
||||
try {
|
||||
const raw = await navigator.clipboard.readText();
|
||||
const trimmed = raw.trim();
|
||||
// If it looks like a multi-line list, keep as-is; otherwise parse as shell command
|
||||
const lines = trimmed.includes('\n')
|
||||
? trimmed.split('\n').filter((l) => l.trim())
|
||||
: parseShellCommand(trimmed);
|
||||
setText(lines.join('\n'));
|
||||
onChange(lines);
|
||||
toast.success(pasteSuccess(lines.length));
|
||||
} catch {
|
||||
toast.error(clipboardReadFailed);
|
||||
}
|
||||
/**
|
||||
* Pasting a whole command line splits it into arguments here, in the field
|
||||
* the user pasted into. The old approach — a button that read the clipboard
|
||||
* itself — fails outright wherever the runtime denies clipboard reads.
|
||||
*/
|
||||
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const raw = event.clipboardData.getData('text');
|
||||
const trimmed = raw.trim();
|
||||
// Only take over a paste that replaces the whole field with one command
|
||||
// line; anything else is ordinary editing and belongs to the browser.
|
||||
if (!trimmed || trimmed.includes('\n') || !/\s/.test(trimmed)) return;
|
||||
const target = event.currentTarget;
|
||||
if (target.selectionStart !== 0 || target.selectionEnd !== target.value.length) return;
|
||||
event.preventDefault();
|
||||
const lines = parseShellCommand(trimmed);
|
||||
setText(lines.join('\n'));
|
||||
onChange(lines);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-bwignore="true" data-1p-ignore="true" data-lpignore="true">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal gap-1 text-muted-foreground"
|
||||
onClick={handlePasteFromClipboard}
|
||||
type="button"
|
||||
title={pasteCommandTitle}
|
||||
>
|
||||
<Icon name="clipboard" className="h-3 w-3" />
|
||||
{pasteCommandLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
onPaste={handlePaste}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
@@ -198,7 +189,7 @@ const CommandTextarea: React.FC<CommandTextareaProps> = ({
|
||||
'npx\n-y\n@modelcontextprotocol/server-postgres\npostgresql://user:pass@host/db'
|
||||
}
|
||||
rows={Math.max(4, value.length + 1)}
|
||||
className="font-mono typography-meta resize-y min-h-[80px]"
|
||||
className="font-mono typography-meta min-h-[80px]"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
@@ -508,42 +499,6 @@ const shouldShowFullStatusCard = (status: string | undefined, authUrl: string |
|
||||
return false;
|
||||
};
|
||||
|
||||
const buildMcpOAuthRedirectUri = (name?: string | null, directory?: string | null): string | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(MCP_OAUTH_CALLBACK_PATH, getRuntimeApiBaseUrl() || window.location.origin);
|
||||
if (typeof name === 'string' && name.trim()) {
|
||||
url.searchParams.set('server', name.trim());
|
||||
}
|
||||
if (typeof directory === 'string' && directory.trim()) {
|
||||
url.searchParams.set('directory', directory.trim());
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const queuePendingMcpAuthContext = async (input: {
|
||||
state: string;
|
||||
name: string;
|
||||
directory?: string | null;
|
||||
}): Promise<void> => {
|
||||
const response = await runtimeFetch('/api/mcp/auth/pending', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
state: input.state,
|
||||
name: input.name,
|
||||
directory: typeof input.directory === 'string' && input.directory.trim() ? input.directory.trim() : null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(payload?.error || 'Failed to prepare MCP authorization callback');
|
||||
}
|
||||
};
|
||||
|
||||
const getPendingMcpAuthContext = async (stateKey: string): Promise<{ name: string; directory: string | null } | null> => {
|
||||
const response = await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey)}`);
|
||||
if (!response.ok) {
|
||||
@@ -626,7 +581,6 @@ export const McpPage: React.FC = () => {
|
||||
const refreshStatus = useMcpStore((state) => state.refresh);
|
||||
const connectMcp = useMcpStore((state) => state.connect);
|
||||
const disconnectMcp = useMcpStore((state) => state.disconnect);
|
||||
const startAuthMcp = useMcpStore((state) => state.startAuth);
|
||||
const completeAuthMcp = useMcpStore((state) => state.completeAuth);
|
||||
const clearAuthMcp = useMcpStore((state) => state.clearAuth);
|
||||
const testConnectionMcp = useMcpStore((state) => state.testConnection);
|
||||
@@ -887,6 +841,39 @@ export const McpPage: React.FC = () => {
|
||||
);
|
||||
}, [mcpType, command, url, envEntries, headerEntries, oauthEnabled, oauthClientId, oauthClientSecret, oauthScope, oauthRedirectUri, timeout, enabled]);
|
||||
|
||||
// What the user has is either a command they were given or a link. Which of
|
||||
// the two decides the transport, so the page reads it off the text instead of
|
||||
// asking — and lets them correct it when the text alone cannot say.
|
||||
const connectionKindTabs = React.useMemo<SortableTabsStripItem[]>(() => [
|
||||
{
|
||||
id: 'local',
|
||||
label: t('settings.mcp.page.connection.kindCommand'),
|
||||
icon: <Icon name="terminal" className="h-3.5 w-3.5" />,
|
||||
},
|
||||
{
|
||||
id: 'remote',
|
||||
label: t('settings.mcp.page.connection.kindLink'),
|
||||
icon: <Icon name="global" className="h-3.5 w-3.5" />,
|
||||
},
|
||||
], [t]);
|
||||
|
||||
const handleDetectedUrl = React.useCallback((candidate: string) => {
|
||||
setMcpType('remote');
|
||||
setUrl(candidate);
|
||||
setCommand([]);
|
||||
}, []);
|
||||
|
||||
const handleUrlChange = React.useCallback((next: string) => {
|
||||
setUrl(next);
|
||||
// A command pasted into the link field is still a command.
|
||||
const trimmed = next.trim();
|
||||
if (trimmed && !/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && /\s/.test(trimmed)) {
|
||||
setMcpType('local');
|
||||
setCommand(parseShellCommand(trimmed));
|
||||
setUrl('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
const name = isNewServer ? draftName.trim() : selectedMcpName ?? '';
|
||||
if (!name) { toast.error(t('settings.mcp.page.toast.nameRequired')); return; }
|
||||
@@ -1042,48 +1029,20 @@ export const McpPage: React.FC = () => {
|
||||
const currentStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName]?.status;
|
||||
authPollStartsFromNeedsAuthRef.current = currentStatus === 'needs_auth' || currentStatus === 'needs_client_registration';
|
||||
|
||||
const redirectUri = buildMcpOAuthRedirectUri(selectedMcpName, currentDirectory);
|
||||
if (!redirectUri) {
|
||||
throw new Error(t('settings.mcp.page.toast.oauthRedirectUrlBuildFailed'));
|
||||
}
|
||||
|
||||
if (!oauthRedirectUri.trim() && !isVSCodeAuthRuntime) {
|
||||
const saved = await updateMcp(selectedMcpName, {
|
||||
oauthEnabled,
|
||||
oauthClientId,
|
||||
oauthClientSecret,
|
||||
oauthScope,
|
||||
oauthRedirectUri: redirectUri,
|
||||
});
|
||||
|
||||
if (!saved.ok) {
|
||||
throw new Error(t('settings.mcp.page.toast.oauthBrowserCallbackSaveFailed'));
|
||||
}
|
||||
|
||||
if (saved.reloadFailed) {
|
||||
throw new Error(saved.warning || saved.message || t('settings.mcp.page.toast.openCodeReloadFailedAfterCallbackSave'));
|
||||
}
|
||||
|
||||
if (runtimeActionKeyRef.current !== actionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOauthRedirectUri(redirectUri);
|
||||
initialRef.current = initialRef.current
|
||||
? { ...initialRef.current, oauthRedirectUri: redirectUri }
|
||||
: initialRef.current;
|
||||
}
|
||||
|
||||
const nextAuthUrl = await startAuthMcp(selectedMcpName, currentDirectory);
|
||||
// One implementation for every surface that can authorise; the page
|
||||
// used to own this flow while the dropdown and the work-status panel
|
||||
// called plain `connect`, which cannot start OAuth at all.
|
||||
const { authorizationUrl: nextAuthUrl, opened } = await startMcpAuthorization({
|
||||
name: selectedMcpName,
|
||||
directory: currentDirectory,
|
||||
// Only VS Code keeps OpenCode's own redirect. Skipping whenever some
|
||||
// value was stored left a stale one — a dead loopback port from an
|
||||
// earlier launch — unrepairable from this page; the bootstrap already
|
||||
// rewrites nothing when the stored value is right.
|
||||
skipRedirectUriBootstrap: isVSCodeAuthRuntime,
|
||||
});
|
||||
const stateKey = parseMcpOAuthCallbackStateKey(new URL(nextAuthUrl).searchParams);
|
||||
if (stateKey) {
|
||||
queuedStateKey = stateKey;
|
||||
await queuePendingMcpAuthContext({
|
||||
state: stateKey,
|
||||
name: selectedMcpName,
|
||||
directory: currentDirectory,
|
||||
});
|
||||
}
|
||||
queuedStateKey = stateKey;
|
||||
|
||||
if (runtimeActionKeyRef.current !== actionKey) {
|
||||
return;
|
||||
@@ -1094,7 +1053,6 @@ export const McpPage: React.FC = () => {
|
||||
setIsAuthPolling(true);
|
||||
authPollAttemptsRef.current = 0;
|
||||
|
||||
const opened = await openExternalUrl(nextAuthUrl);
|
||||
if (runtimeActionKeyRef.current !== actionKey) {
|
||||
return;
|
||||
}
|
||||
@@ -1118,7 +1076,7 @@ export const McpPage: React.FC = () => {
|
||||
setIsAuthorizing(false);
|
||||
}
|
||||
}
|
||||
}, [currentDirectory, isVSCodeAuthRuntime, mcpType, oauthClientId, oauthClientSecret, oauthEnabled, oauthRedirectUri, oauthScope, requireSavedConfig, runtimeActionKey, selectedMcpName, startAuthMcp, t, tUnsafe, updateMcp]);
|
||||
}, [currentDirectory, isVSCodeAuthRuntime, mcpType, requireSavedConfig, runtimeActionKey, selectedMcpName, t, tUnsafe]);
|
||||
|
||||
const handleClearAuthorization = React.useCallback(async () => {
|
||||
if (!selectedMcpName || !requireSavedConfig()) return;
|
||||
@@ -1313,7 +1271,25 @@ export const McpPage: React.FC = () => {
|
||||
const effectiveRuntimeStatus = runtimeStatus ?? runtimeDiagnostic;
|
||||
const isConnected = runtimeStatus?.status === 'connected';
|
||||
const needsAuthorization = runtimeStatus?.status === 'needs_auth' || runtimeStatus?.status === 'needs_client_registration';
|
||||
const suggestedRedirectUri = isVSCodeAuthRuntime ? null : buildMcpOAuthRedirectUri(selectedMcpName, currentDirectory);
|
||||
// Must be the very URI `startMcpAuthorization` writes into the config, not a
|
||||
// second construction of it. The page used to suggest a directory-bearing
|
||||
// address while the flow sent a directory-less one, so a provider enforcing
|
||||
// exact redirect matching rejected a registration copied from right here.
|
||||
const suggestedRedirectUri = isVSCodeAuthRuntime || !selectedMcpName
|
||||
? null
|
||||
: buildMcpAuthorizationRedirectUri(selectedMcpName);
|
||||
|
||||
const handleCopyRedirectUri = async () => {
|
||||
if (!suggestedRedirectUri) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(suggestedRedirectUri);
|
||||
toast.success(t('settings.mcp.page.toast.copiedCallbackUrl'));
|
||||
} catch {
|
||||
toast.error(t('settings.mcp.page.toast.clipboardWriteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const runtimeDescription = getStatusDescription(
|
||||
effectiveRuntimeStatus?.status,
|
||||
tUnsafe,
|
||||
@@ -1431,6 +1407,69 @@ export const McpPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Client credentials appear only when the server has said it
|
||||
needs them. Kept as a permanent four-field form, they made
|
||||
the rarest case the most prominent thing on the page and
|
||||
told nobody what to put there. */}
|
||||
{effectiveRuntimeStatus?.status === 'needs_client_registration' && (
|
||||
<div className="space-y-3 rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-3">
|
||||
<div>
|
||||
<SettingsGroupTitle as="div">{t('settings.mcp.page.registration.title')}</SettingsGroupTitle>
|
||||
<p className="mt-1 typography-micro text-muted-foreground">
|
||||
{t('settings.mcp.page.registration.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{suggestedRedirectUri && (
|
||||
<div>
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
{t('settings.mcp.page.registration.callbackLabel')}
|
||||
</div>
|
||||
<div className="mt-1 flex items-start gap-2">
|
||||
<span className="min-w-0 flex-1 break-all font-mono typography-micro text-foreground/80">
|
||||
{suggestedRedirectUri}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => void handleCopyRedirectUri()}
|
||||
>
|
||||
<Icon name="clipboard" className="h-3.5 w-3.5" />
|
||||
{t('settings.mcp.page.actions.copyLink')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 @xl:grid-cols-2">
|
||||
<SettingsStackedField label={t('settings.mcp.page.registration.clientId')}>
|
||||
<Input
|
||||
value={oauthClientId}
|
||||
onChange={(e) => { setOauthClientId(e.target.value); setOauthEnabled(true); }}
|
||||
className="font-mono typography-meta"
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</SettingsStackedField>
|
||||
<SettingsStackedField label={t('settings.mcp.page.registration.clientSecret')}>
|
||||
<Input
|
||||
type="password"
|
||||
value={oauthClientSecret}
|
||||
onChange={(e) => { setOauthClientSecret(e.target.value); setOauthEnabled(true); }}
|
||||
className="font-mono typography-meta"
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</SettingsStackedField>
|
||||
</div>
|
||||
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
{t('settings.mcp.page.registration.afterSaving')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authUrl && (
|
||||
<div className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-2">
|
||||
<div className="space-y-2">
|
||||
@@ -1450,7 +1489,11 @@ export const McpPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mcpType === 'remote' && (needsAuthorization || isAuthPolling || authUrl) && (
|
||||
{/* VS Code only. Everywhere else the callback returns into the
|
||||
app on its own, so the paste box was a second, confusing way
|
||||
to do what already happened. VS Code cannot receive that
|
||||
redirect, so there it remains the only way to finish. */}
|
||||
{isVSCodeAuthRuntime && mcpType === 'remote' && (needsAuthorization || isAuthPolling || authUrl) && (
|
||||
<div className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-3">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
@@ -1464,7 +1507,7 @@ export const McpPage: React.FC = () => {
|
||||
onChange={(event) => setAuthCallbackInput(event.target.value)}
|
||||
placeholder={t('settings.mcp.page.auth.callbackInputPlaceholder')}
|
||||
rows={3}
|
||||
className="font-mono typography-meta resize-y"
|
||||
className="font-mono typography-meta"
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
spellCheck={false}
|
||||
@@ -1499,32 +1542,60 @@ export const McpPage: React.FC = () => {
|
||||
divider={false}
|
||||
settingsItem="mcp.server"
|
||||
contentClassName="space-y-0"
|
||||
titleAccessory={isNewServer ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal gap-1.5 text-muted-foreground"
|
||||
onClick={handleOpenImportDialog}
|
||||
type="button"
|
||||
title={t('settings.mcp.page.server.importJsonTitle')}
|
||||
>
|
||||
<Icon name="file-code" className="h-3.5 w-3.5" />
|
||||
{t('settings.mcp.page.server.importJson')}
|
||||
</Button>
|
||||
) : null}
|
||||
>
|
||||
|
||||
{isNewServer && (
|
||||
<SettingsFieldRow label={t('settings.mcp.page.server.name')}>
|
||||
<SettingsFieldRow
|
||||
label={t('settings.mcp.page.server.name')}
|
||||
// The scope select carries words now, not a lone icon, so the
|
||||
// control cluster has to be allowed to bound itself and wrap.
|
||||
// Left at its default (fit-width, no shrink) the pair ran past
|
||||
// the edge of the settings pane in a narrow dialog.
|
||||
controlClassName="flex-wrap @xl:w-auto @xl:flex-1"
|
||||
>
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))}
|
||||
placeholder={t('settings.mcp.page.server.namePlaceholder')}
|
||||
className="h-7 w-48 font-mono px-2"
|
||||
className="h-7 w-48 min-w-0 max-w-full shrink font-mono px-2"
|
||||
autoFocus
|
||||
/>
|
||||
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as McpScope)}>
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="!h-7 !w-7 !min-w-0 !px-0 !py-0 justify-center [&>svg:last-child]:hidden" title={draftScope === 'user' ? t('settings.common.scope.global') : t('settings.common.scope.project')}>
|
||||
{draftScope === 'user' ? <Icon name="user-3" className="h-3.5 w-3.5" /> : <Icon name="folder" className="h-3.5 w-3.5" />}
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="!h-7 min-w-0 max-w-full gap-1.5 px-2">
|
||||
<Icon
|
||||
name={draftScope === 'user' ? 'user-3' : 'folder'}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
<span className="truncate">
|
||||
{draftScope === 'user'
|
||||
? t('settings.mcp.page.scope.everywhere')
|
||||
: t('settings.mcp.page.scope.thisProject')}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="user">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="user-3" className="h-3.5 w-3.5" />
|
||||
<span>{t('settings.common.scope.global')}</span>
|
||||
<span>{t('settings.mcp.page.scope.everywhere')}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="project">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="folder" className="h-3.5 w-3.5" />
|
||||
<span>{t('settings.common.scope.project')}</span>
|
||||
<span>{t('settings.mcp.page.scope.thisProject')}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -1532,23 +1603,6 @@ export const McpPage: React.FC = () => {
|
||||
</SettingsFieldRow>
|
||||
)}
|
||||
|
||||
{/* Import JSON - prominent placement for new servers */}
|
||||
{isNewServer && (
|
||||
<div className="py-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal gap-1.5"
|
||||
onClick={handleOpenImportDialog}
|
||||
type="button"
|
||||
title={t('settings.mcp.page.server.importJsonTitle')}
|
||||
>
|
||||
<Icon name="file-code" className="h-3.5 w-3.5" />
|
||||
{t('settings.mcp.page.server.importJson')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsCheckboxRow
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
@@ -1556,42 +1610,50 @@ export const McpPage: React.FC = () => {
|
||||
ariaLabel={t('settings.mcp.page.server.enableAria')}
|
||||
/>
|
||||
|
||||
<SettingsStackedField label={t('settings.mcp.page.server.transportMode')}>
|
||||
<SettingsChipGroup
|
||||
aria-label={t('settings.mcp.page.server.transportMode')}
|
||||
value={mcpType}
|
||||
onChange={setMcpType}
|
||||
options={[
|
||||
{ value: 'local', label: t('settings.mcp.page.transport.local') },
|
||||
{ value: 'remote', label: t('settings.mcp.page.transport.remote') },
|
||||
]}
|
||||
/>
|
||||
</SettingsStackedField>
|
||||
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={mcpType === 'local' ? t('settings.mcp.page.connection.command') : t('settings.mcp.page.connection.serverUrl')}
|
||||
title={t('settings.mcp.page.connection.title')}
|
||||
description={t('settings.mcp.page.connection.description')}
|
||||
settingsItem="mcp.command"
|
||||
// The section's content wrapper carries no spacing of its own, so the
|
||||
// kind tabs, the field and its hint would otherwise sit flush.
|
||||
contentClassName="space-y-2"
|
||||
>
|
||||
{/* Pasting a link or a command still flips this for you, but the
|
||||
choice is a control you can see and press. As one sentence with
|
||||
an inline link it was, in practice, undiscoverable. */}
|
||||
<SortableTabsStrip
|
||||
items={connectionKindTabs}
|
||||
activeId={mcpType}
|
||||
onSelect={(id) => setMcpType(id as 'local' | 'remote')}
|
||||
layoutMode="fit"
|
||||
variant="active-pill"
|
||||
activePillLowercase={false}
|
||||
className="h-10"
|
||||
/>
|
||||
|
||||
{mcpType === 'local' ? (
|
||||
<CommandTextarea
|
||||
value={command}
|
||||
onChange={setCommand}
|
||||
pasteCommandTitle={t('settings.mcp.page.connection.pasteCommandTitle')}
|
||||
pasteCommandLabel={t('settings.mcp.page.connection.pasteCommand')}
|
||||
pasteSuccess={(count) => t('settings.mcp.page.toast.pastedArgumentsCount', { count })}
|
||||
clipboardReadFailed={t('settings.mcp.page.toast.clipboardReadFailed')}
|
||||
preview={(count) => t('settings.mcp.page.connection.previewArgs', { count })}
|
||||
onDetectUrl={handleDetectedUrl}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
placeholder={t('settings.mcp.page.connection.serverUrlPlaceholder')}
|
||||
className="font-mono typography-meta"
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
{mcpType === 'local'
|
||||
? t('settings.mcp.page.connection.hintCommand')
|
||||
: t('settings.mcp.page.connection.hintLink')}
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
{mcpType === 'remote' && (
|
||||
@@ -1607,7 +1669,9 @@ export const McpPage: React.FC = () => {
|
||||
<div className="flex items-center gap-1.5 text-left">
|
||||
<span className="typography-ui-label font-normal text-foreground">{t('settings.mcp.page.advanced.configure')}</span>
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
({oauthEnabled ? t('settings.mcp.page.advanced.autoDetect') : t('settings.mcp.page.advanced.custom')} · {headerEntries.length} {t('settings.mcp.page.advanced.headers')}{timeout ? ` · ${timeout}ms` : ''})
|
||||
{/* OAuth left the form, so the summary stops reporting a
|
||||
setting the user can no longer see. */}
|
||||
({headerEntries.length} {t('settings.mcp.page.advanced.headers')}{timeout ? ` · ${timeout}ms` : ''})
|
||||
</span>
|
||||
</div>
|
||||
{isAdvancedRemoteOptionsOpen ? (
|
||||
@@ -1669,61 +1733,6 @@ export const McpPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<SettingsCheckboxRow
|
||||
checked={oauthEnabled}
|
||||
onChange={setOauthEnabled}
|
||||
label={t('settings.mcp.page.advanced.oauthAutoDetection')}
|
||||
ariaLabel={t('settings.mcp.page.advanced.oauthAutoDetectionAria')}
|
||||
info={t('settings.mcp.page.advanced.oauthHint')}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 @xl:grid-cols-2">
|
||||
<Input
|
||||
value={oauthClientId}
|
||||
onChange={(e) => setOauthClientId(e.target.value)}
|
||||
placeholder={t('settings.mcp.page.advanced.oauthClientIdPlaceholder')}
|
||||
className="font-mono typography-meta"
|
||||
disabled={!oauthEnabled}
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
<Input
|
||||
value={oauthClientSecret}
|
||||
onChange={(e) => setOauthClientSecret(e.target.value)}
|
||||
placeholder={t('settings.mcp.page.advanced.oauthClientSecretPlaceholder')}
|
||||
className="font-mono typography-meta"
|
||||
disabled={!oauthEnabled}
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
<Input
|
||||
value={oauthScope}
|
||||
onChange={(e) => setOauthScope(e.target.value)}
|
||||
placeholder={t('settings.mcp.page.advanced.oauthScopesPlaceholder')}
|
||||
className="font-mono typography-meta"
|
||||
disabled={!oauthEnabled}
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
<Input
|
||||
value={oauthRedirectUri}
|
||||
onChange={(e) => setOauthRedirectUri(e.target.value)}
|
||||
placeholder={t('settings.mcp.page.advanced.oauthRedirectUriPlaceholder')}
|
||||
className="font-mono typography-meta"
|
||||
disabled={!oauthEnabled}
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{suggestedRedirectUri && (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
{t('settings.mcp.page.advanced.oauthCallbackHint')}
|
||||
<span className="mt-1 block break-all font-mono text-foreground/80">{suggestedRedirectUri}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
@@ -1732,6 +1741,7 @@ export const McpPage: React.FC = () => {
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.mcp.page.env.title')}
|
||||
description={t('settings.mcp.page.env.description')}
|
||||
titleAccessory={
|
||||
envEntries.length > 0 ? (
|
||||
<span className="typography-micro text-muted-foreground font-normal">
|
||||
@@ -1823,7 +1833,7 @@ export const McpPage: React.FC = () => {
|
||||
}}
|
||||
placeholder={'{\n "mcpServers": {\n "postgres": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-postgres"]\n }\n }\n}'}
|
||||
rows={8}
|
||||
className="font-mono typography-meta resize-y"
|
||||
className="font-mono typography-meta"
|
||||
spellCheck={false}
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
|
||||
Reference in New Issue
Block a user