feat(connectors): the connector card offers one verb per row; Continue is the only way out (#110843)

The card offers; the transcript explains. One shell, one heading, one row per app:
a 16 px state mark (hollow circle, spinning arc, check), the brand chip, the name, one
cue on the row whose link is open ("Waiting for your browser…"), and one verb in a
fixed lane (Connect / Try again / Install). A resolved row shows no control.
Continue sits below the shell and is the one exit; the per-row Not now is gone, so
every open row settles as "Not connected".

No reason or error text lives in the card. A sign-in failure is a background event:
the row's verb becomes Try again and the model's reply after Continue explains. A
click that changed nothing (a refused re-mint) is the one toast: "Could not start
authorization for <app>." A successful Try again opens the fresh link at once.

The MCP setup card is the same component with Install / Enable / Authorize as the
verb: every target gets a row (a two-server call no longer strands the second one),
Continue below, no catalog source line. The settled card keeps its scaffold rows and
says three words: Connected, Skipped, Not connected.

Design of record: Paper 01M29RZY5NDXT7CN7MBWQT0HRW, page B-0, artboard ZV6-0
("Variation 2b"). Presentation only; no wire change.

Tests are behaviour contracts, run red first: one verb per row and no reason; the
mark carries the state; Try again mints on the open operation and opens the link; a
refused Try again toasts and keeps the verb; Continue settles; settled rows carry no
detail text; the MCP card lists every target live and settled.
This commit is contained in:
Siddharth Balyan
2026-09-15 00:41:15 +05:30
committed by GitHub
parent ee2f5629b8
commit 1ab32b212b
11 changed files with 663 additions and 933 deletions

View File

@@ -6,12 +6,30 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'
import { connectionRequestOwnsPart, ConnectorOffer, ConnectorTool } from '@/components/assistant-ui/connector-tool'
import { I18nProvider } from '@/i18n'
import { $connectionRequests, type ConnectionRequest, setConnectionRequest } from '@/store/connection-request'
import { $gateway } from '@/store/gateway'
import {
$connectionRequests,
type ConnectionRequest,
type ConnectionTarget,
setConnectionRequest
} from '@/store/connection-request'
import { $gateway, setPrimaryGateway } from '@/store/gateway'
import { $notifications } from '@/store/notifications'
import { _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session'
const SESSION_ID = 'session-1'
const OWNER = { connectionId: 'connection-1', profile: 'default' }
// A null connection id routes the card's own RPCs through the primary gateway socket.
const PRIMARY_OWNER = { connectionId: null, profile: 'default' }
const GMAIL: ConnectionTarget = {
action: 'connect',
connectUrl: 'https://connect.example/gmail',
detail: '',
kind: 'connector',
name: 'gmail',
state: 'pending',
tools: []
}
const REQUEST: ConnectionRequest = {
deadlineAt: 1_800_000_000,
@@ -20,17 +38,7 @@ const REQUEST: ConnectionRequest = {
sessionId: SESSION_ID,
settled: false,
settledBy: null,
targets: [
{
action: 'connect',
connectUrl: 'https://connect.example/gmail',
detail: '',
kind: 'connector',
name: 'gmail',
state: 'pending',
tools: []
}
]
targets: [GMAIL]
}
function props(): ToolCallMessagePartProps {
@@ -73,7 +81,7 @@ function view(sessionId: string): SessionView {
function renderOffer(request = REQUEST) {
return render(
<I18nProvider configClient={null} initialLocale="en">
<ConnectorOffer owner={OWNER} request={request} />
<ConnectorOffer owner={PRIMARY_OWNER} request={request} />
</I18nProvider>
)
}
@@ -95,6 +103,8 @@ afterEach(() => {
cleanup()
$connectionRequests.set({})
$gateway.set(null)
setPrimaryGateway(null)
$notifications.set([])
_resetSessionOwnerHintsForTests({ storage: true })
vi.useRealTimers()
vi.clearAllMocks()
@@ -113,6 +123,20 @@ describe('ConnectorTool operation card', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('offers one verb per row and Continue below; nothing per row says no', () => {
renderOffer({
...REQUEST,
targets: [
{ ...GMAIL, state: 'initiated' },
{ ...GMAIL, connectUrl: null, name: 'notion', state: 'failed' },
{ ...GMAIL, name: 'linear', state: 'connected' }
]
})
expect(screen.getAllByRole('button').map(button => button.textContent)).toEqual(['Connect', 'Try again', 'Continue'])
expect(screen.queryByRole('button', { name: 'Not now' })).toBeNull()
})
it('opens the stored link from Connect on a waiting row, without an RPC', async () => {
const request = vi.fn()
// SAFETY: the store calls only `request`; the rest of the client is never touched in these tests.
@@ -121,7 +145,7 @@ describe('ConnectorTool operation card', () => {
// SAFETY: the card reads only `openExternal` from the preload bridge.
window.hermesDesktop = { openExternal } as never
renderConnector({ ...REQUEST, targets: [{ ...REQUEST.targets[0], state: 'initiated' }] })
renderConnector({ ...REQUEST, targets: [{ ...GMAIL, state: 'initiated' }] })
const connect = await waitFor(() => screen.getByRole('button', { name: 'Connect' }))
expect(connect.hasAttribute('disabled')).toBe(false)
@@ -131,22 +155,62 @@ describe('ConnectorTool operation card', () => {
expect(request).not.toHaveBeenCalledWith('connectors.connect', expect.anything())
})
it('sends Not now as a per-target skipped response', async () => {
it('Try again mints a fresh link on the open operation and opens it at once', async () => {
const openExternal = vi.fn()
// SAFETY: the card reads only `openExternal` from the preload bridge.
window.hermesDesktop = { openExternal } as never
const request = vi.fn().mockResolvedValue({
targets: [{ connect_url: 'https://connect.example/gmail-2', name: 'gmail', state: 'initiated' }]
})
// SAFETY: the card calls only `request` on the primary socket; nothing else on the client is touched.
setPrimaryGateway({ request } as never)
renderOffer({ ...REQUEST, targets: [{ ...GMAIL, connectUrl: null, state: 'failed' }] })
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
await waitFor(() => {
expect(openExternal).toHaveBeenCalledWith('https://connect.example/gmail-2')
})
expect(request).toHaveBeenCalledWith(
'connectors.connect',
{ connectors: ['gmail'], reconnect: true, session_id: SESSION_ID },
expect.any(Number),
undefined
)
})
it('a refused Try again says so in a toast and leaves the row as it was', async () => {
const request = vi.fn().mockRejectedValue(new Error('LINK_STILL_VALID'))
// SAFETY: the card calls only `request` on the primary socket; nothing else on the client is touched.
setPrimaryGateway({ request } as never)
renderOffer({ ...REQUEST, targets: [{ ...GMAIL, connectUrl: null, state: 'failed' }] })
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
await waitFor(() => {
expect($notifications.get().map(({ kind, title }) => ({ kind, title }))).toEqual([
{ kind: 'error', title: 'Could not start authorization for Gmail.' }
])
})
expect(screen.getByRole('button', { name: 'Try again' }).hasAttribute('disabled')).toBe(false)
expect(screen.queryByText('LINK_STILL_VALID')).toBeNull()
})
it('Continue settles the whole operation', async () => {
const request = vi.fn().mockResolvedValue({ status: 'ok' })
// SAFETY: the store calls only `request`; the rest of the client is never touched in these tests.
$gateway.set({ request } as never)
renderConnector()
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Not now' })).toBeTruthy()
})
fireEvent.click(screen.getByRole('button', { name: 'Not now' }))
fireEvent.click(await waitFor(() => screen.getByRole('button', { name: 'Continue' })))
await waitFor(() => {
expect(request).toHaveBeenCalledWith('connection.respond', {
op_id: 'operation-1',
result: { targets: [{ name: 'gmail', status: 'skipped' }] },
result: { settled_by: 'continue' },
session_id: SESSION_ID
})
})
@@ -159,15 +223,27 @@ describe('ConnectorTool operation card', () => {
expect(connectionRequestOwnsPart(props(), REQUEST)).toBe(true)
})
it('renders settled operations with no live controls', () => {
renderOffer({ ...REQUEST, settled: true, settledBy: 'all_resolved' })
it('renders a settled operation as three words and no live controls', () => {
renderOffer({
...REQUEST,
settled: true,
settledBy: 'deadline',
targets: [
{ ...GMAIL, state: 'connected' },
{ ...GMAIL, name: 'notion', state: 'skipped' },
{ ...GMAIL, detail: 'Access denied by user', name: 'linear', state: 'not_connected' }
]
})
// ScaffoldRow paints every settled tool row as a disabled disclosure button; the contract is
// that nothing is actionable: no enabled button, no Connect / Not now / Continue.
// that nothing is actionable and nothing explains.
const buttons = [...window.document.querySelectorAll('[data-connector-offer] button')]
expect(buttons.every(button => button.hasAttribute('disabled'))).toBe(true)
expect(screen.queryByRole('button', { name: 'Not now' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull()
expect(screen.getByText('Connected')).toBeTruthy()
expect(screen.getByText('Skipped')).toBeTruthy()
expect(screen.getByText('Not connected')).toBeTruthy()
expect(screen.queryByText('Access denied by user')).toBeNull()
})
})

View File

@@ -7,24 +7,24 @@ import { useSessionView } from '@/app/chat/session-view'
import { resolveSessionOwner } from '@/app/session/hooks/use-session-actions/utils'
import { ToolFallback } from '@/components/assistant-ui/tool/fallback'
import { Button } from '@/components/ui/button'
import {
ConnectorCard,
type ConnectorCardCopy,
type ConnectorCardOutcome,
type ConnectorCardState,
ConnectorSummary,
outcomeMeta
} from '@/components/ui/connector-card'
import { ConnectorCard, ConnectorRow, type ConnectorRowMark, ConnectorSummary } from '@/components/ui/connector-card'
import { useI18n } from '@/i18n'
import { connectorCalls, connectorText, connectorTitle, connectorToolName, recordOf } from '@/lib/connector-tools'
import {
connectorAuthorizationUrl,
connectorCalls,
connectorText,
connectorTitle,
connectorToolName,
recordOf
} from '@/lib/connector-tools'
import {
type ConnectionRequest,
type ConnectionTarget,
continueConnectionRequest,
sessionConnectionRequest,
skipConnectionTarget
sessionConnectionRequest
} from '@/store/connection-request'
import { requestGatewayForAgent } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $activeGatewayProfile } from '@/store/profile'
import { assertSessionOwnerResolved } from '@/store/session-owner-resolution'
import { isSessionOwnerRoute } from '@/store/session-request-router'
@@ -122,158 +122,85 @@ export function ConnectorTool(props: ToolCallMessagePartProps) {
}
type ConnectorCopy = ReturnType<typeof useI18n>['t']['connectors']
type ConnectorAction = 'none' | 'open' | 'reissue'
type ConnectorVerb = 'none' | 'open' | 'reissue'
interface ConnectorCardPhase {
action: ConnectorAction
cardState: ConnectorCardState
dismissed: boolean
outcome: (target: ConnectionTarget, copy: ConnectorCopy) => ConnectorCardOutcome | undefined
phase: (copy: ConnectorCopy) => string | undefined
requiresUrl: boolean
unresolved: boolean
/** The settled row's word; the card never says why. */
interface SettledWord {
meta: string
tone?: 'ok'
}
const noOutcome = (): undefined => undefined
const noPhase = (): undefined => undefined
interface ConnectorCardPhase {
mark: ConnectorRowMark
resolved: boolean
settled: (copy: ConnectorCopy) => SettledWord
verb: ConnectorVerb
}
const errorOutcome = (target: ConnectionTarget, copy: ConnectorCopy): ConnectorCardOutcome => ({
detail: target.detail || copy.failed,
status: 'error'
})
const connectedOutcome = (target: ConnectionTarget): ConnectorCardOutcome => ({
status: 'connected',
tools: target.tools
})
const connected = (copy: ConnectorCopy): SettledWord => ({ meta: copy.connected, tone: 'ok' })
const notConnected = (copy: ConnectorCopy): SettledWord => ({ meta: copy.notConnected })
const skipped = (copy: ConnectorCopy): SettledWord => ({ meta: copy.skipped })
const CONNECTOR_CARD_PHASES = {
connected: {
action: 'none',
cardState: 'connected',
dismissed: false,
outcome: connectedOutcome,
phase: noPhase,
requiresUrl: false,
unresolved: false
},
expired: {
action: 'reissue',
cardState: 'needs_auth',
dismissed: false,
outcome: errorOutcome,
phase: noPhase,
requiresUrl: false,
unresolved: true
},
failed: {
action: 'reissue',
cardState: 'not_configured',
dismissed: false,
outcome: errorOutcome,
phase: noPhase,
requiresUrl: false,
unresolved: true
},
initiated: {
action: 'open',
cardState: 'not_configured',
dismissed: false,
outcome: noOutcome,
phase: copy => copy.waiting,
requiresUrl: true,
unresolved: true
},
not_connected: {
action: 'none',
cardState: 'not_configured',
dismissed: false,
outcome: (target, copy) => ({ detail: target.detail || copy.notConnected, status: 'error' }),
phase: noPhase,
requiresUrl: false,
unresolved: true
},
pending: {
action: 'open',
cardState: 'not_configured',
dismissed: false,
outcome: noOutcome,
phase: noPhase,
requiresUrl: true,
unresolved: true
},
skipped: {
action: 'none',
cardState: 'not_configured',
dismissed: true,
outcome: noOutcome,
phase: noPhase,
requiresUrl: false,
unresolved: false
},
unavailable: {
action: 'none',
cardState: 'disabled',
dismissed: false,
outcome: errorOutcome,
phase: noPhase,
requiresUrl: false,
unresolved: true
}
connected: { mark: 'connected', resolved: true, settled: connected, verb: 'none' },
expired: { mark: 'idle', resolved: false, settled: notConnected, verb: 'reissue' },
failed: { mark: 'idle', resolved: false, settled: notConnected, verb: 'reissue' },
initiated: { mark: 'waiting', resolved: false, settled: notConnected, verb: 'open' },
not_connected: { mark: 'idle', resolved: false, settled: notConnected, verb: 'none' },
pending: { mark: 'idle', resolved: false, settled: notConnected, verb: 'open' },
skipped: { mark: 'idle', resolved: true, settled: skipped, verb: 'none' },
unavailable: { mark: 'idle', resolved: true, settled: notConnected, verb: 'none' }
} satisfies Record<ConnectionTargetState, ConnectorCardPhase>
const MARK_LABEL = {
connected: (copy: ConnectorCopy) => copy.connected,
idle: (copy: ConnectorCopy) => copy.notConnected,
waiting: (copy: ConnectorCopy) => copy.waiting
} satisfies Record<ConnectorRowMark, (copy: ConnectorCopy) => string>
interface ConnectorOfferProps {
owner: ConnectorOwner
request: ConnectionRequest
}
function connectorCardCopy(copy: ConnectorCopy): ConnectorCardCopy {
return {
connectAction: copy.connect,
connectTitle: copy.connectTitle,
decline: copy.skip,
envRequired: '',
grantAction: copy.grant,
retryAction: copy.retry,
stateConnected: copy.connected,
stateDeclined: copy.skipped,
stateDisabled: copy.disabled,
stateFailed: copy.failed,
stateNeedsAuth: copy.needsAuth,
toolCount: count => String(count),
trustCommunity: '',
trustCommunityTip: () => '',
trustVerified: () => '',
trustVerifiedTip: () => ''
}
}
export function ConnectorOffer({ owner, request }: ConnectorOfferProps) {
const { t } = useI18n()
const copy = t.connectors
const cardCopy = connectorCardCopy(copy)
const [reissuing, setReissuing] = useState<ReadonlySet<string>>(new Set())
const unresolved = request.targets.some(target => CONNECTOR_CARD_PHASES[target.state].unresolved)
const unresolved = request.targets.some(target => !CONNECTOR_CARD_PHASES[target.state].resolved)
const reissue = async (name: string): Promise<void> => {
setReissuing(current => new Set(current).add(name))
// Try again is one RPC on the open operation; the backend re-mints only a dead link. The fresh link
// opens at once, and the update frame then paints the row as waiting. A refused re-mint is a click
// that changed nothing, so it gets a toast; the row stays as it was.
const reissue = async (target: ConnectionTarget): Promise<void> => {
setReissuing(current => new Set(current).add(target.name))
try {
await requestGatewayForAgent(
const reply = await requestGatewayForAgent<ToolCallMessagePartProps['result']>(
owner.connectionId,
owner.profile,
'connectors.connect',
{
connectors: [name],
connectors: [target.name],
reconnect: true,
session_id: request.sessionId
},
45000
)
const rows = recordOf(reply).targets
const minted = Array.isArray(rows) ? rows.map(recordOf).find(row => connectorText(row.name) === target.name) : undefined
const url = connectorAuthorizationUrl(minted?.connect_url)
if (url) {
void window.hermesDesktop?.openExternal?.(url)
}
} catch (error) {
notifyError(error, copy.connectErrorFor(connectorTitle(target.name)))
} finally {
setReissuing(current => {
const next = new Set(current)
next.delete(name)
next.delete(target.name)
return next
})
@@ -285,15 +212,14 @@ export function ConnectorOffer({ owner, request }: ConnectorOfferProps) {
return (
<div className="my-2 grid min-w-0 max-w-lg gap-1" data-connector-offer>
{request.targets.map(target => {
const phase = CONNECTOR_CARD_PHASES[target.state]
const outcome = phase.outcome(target, copy) ?? { status: 'declined' as const }
const { meta, tone } = CONNECTOR_CARD_PHASES[target.state].settled(copy)
return (
<ConnectorSummary
connector={{ name: target.name, title: connectorTitle(target.name) }}
key={target.name}
meta={outcomeMeta(outcome, cardCopy)}
tone={outcome.status === 'connected' ? 'ok' : outcome.status === 'error' ? 'error' : undefined}
meta={meta}
tone={tone}
/>
)
})}
@@ -303,42 +229,42 @@ export function ConnectorOffer({ owner, request }: ConnectorOfferProps) {
return (
<div className="my-2 grid min-w-0 max-w-lg gap-1" data-connector-offer>
{request.targets.map(target => {
const phase = CONNECTOR_CARD_PHASES[target.state]
const title = connectorTitle(target.name)
const waitingForReissue = reissuing.has(target.name)
<ConnectorCard title={copy.title}>
{request.targets.map(target => {
const phase = CONNECTOR_CARD_PHASES[target.state]
const busy = reissuing.has(target.name)
return (
<ConnectorCard
actionDisabled={phase.action === 'none' || waitingForReissue || (phase.requiresUrl && target.connectUrl === null)}
busy={waitingForReissue}
collapseWhenSettled={false}
connector={{
description: copy.describe(title),
name: target.name,
title
}}
copy={cardCopy}
dismissed={phase.dismissed}
key={target.name}
onConnect={() => {
if (phase.action === 'open' && target.connectUrl && window.hermesDesktop?.openExternal) {
void window.hermesDesktop.openExternal(target.connectUrl)
}
const action =
phase.verb === 'none'
? undefined
: {
busy,
// Prevent concurrent sign-in tabs; a waiting row without a link has nothing to open yet.
disabled: (reissuing.size > 0 && !busy) || (phase.verb === 'open' && target.connectUrl === null),
label: phase.verb === 'open' ? copy.connect : copy.retry,
onClick: () => {
if (phase.verb === 'open' && target.connectUrl && window.hermesDesktop?.openExternal) {
void window.hermesDesktop.openExternal(target.connectUrl)
}
if (phase.action === 'reissue') {
void reissue(target.name)
}
}}
onDismiss={() => void skipConnectionTarget(request, target.name)}
otherBusy={reissuing.size > 0 && !waitingForReissue}
outcome={phase.outcome(target, copy)}
phase={phase.phase(copy)}
state={phase.cardState}
variant="avatar"
/>
)
})}
if (phase.verb === 'reissue') {
void reissue(target)
}
}
}
return (
<ConnectorRow
action={action}
connector={{ name: target.name, title: connectorTitle(target.name) }}
cue={phase.mark === 'waiting' ? copy.waiting : undefined}
key={target.name}
mark={phase.mark}
markLabel={MARK_LABEL[phase.mark](copy)}
/>
)
})}
</ConnectorCard>
{unresolved ? (
<div className="px-3.5">
<Button onClick={() => void continueConnectionRequest(request)} size="xs" variant="textStrong">

View File

@@ -0,0 +1,117 @@
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
import { cleanup, render, screen } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'
import { McpSetupPending, McpSetupTool } from '@/components/assistant-ui/mcp-setup-tool'
import { I18nProvider } from '@/i18n'
import { $connectionRequests, type ConnectionRequest, setConnectionRequest } from '@/store/connection-request'
const SESSION_ID = 'session-1'
const REQUEST: ConnectionRequest = {
deadlineAt: 1_800_000_000,
opId: 'operation-1',
toolCallId: 'mcp-call-1',
sessionId: SESSION_ID,
settled: false,
settledBy: null,
targets: [
{ action: 'install', connectUrl: null, detail: '', kind: 'mcp', name: 'linear', state: 'pending', tools: [] },
{ action: 'install', connectUrl: null, detail: '', kind: 'mcp', name: 'postgres', state: 'pending', tools: [] }
]
}
const ARGS = {
action: 'install',
connectors: [
{ mcp: true, name: 'linear' },
{ mcp: true, name: 'postgres' }
]
}
function props(result?: ToolCallMessagePartProps['result']): ToolCallMessagePartProps {
return {
addResult: vi.fn(),
args: ARGS,
argsText: JSON.stringify(ARGS),
isError: false,
respondToApproval: vi.fn(),
result,
resume: vi.fn(),
status: result === undefined ? { type: 'running' } : { type: 'complete' },
toolCallId: 'mcp-call-1',
toolName: 'manage_connections',
type: 'tool-call'
}
}
function view(sessionId: string): SessionView {
return {
$awaitingResponse: atom(false),
$busy: atom(false),
$cwd: atom(''),
$fast: atom(false),
$lastVisibleIsUser: atom(false),
$messages: atom([]),
$messagesEmpty: atom(false),
$model: atom(''),
$provider: atom(''),
$reasoningEffort: atom(''),
$runtimeId: atom(sessionId),
$storedId: atom(sessionId),
$turnStartedAt: atom(null),
kind: 'primary'
}
}
// The live gate (message still running) is assistant-ui state; the pending card renders below it.
function renderTool(result?: ToolCallMessagePartProps['result']) {
const Card = result === undefined ? McpSetupPending : McpSetupTool
return render(
<I18nProvider configClient={null} initialLocale="en">
<SessionViewProvider value={view(SESSION_ID)}>
<Card {...props(result)} />
</SessionViewProvider>
</I18nProvider>
)
}
afterEach(() => {
cleanup()
$connectionRequests.set({})
vi.clearAllMocks()
})
describe('the MCP setup card', () => {
it('shows one row per target with its verb, and Continue below', () => {
setConnectionRequest(REQUEST)
renderTool()
expect(screen.getByText('Add MCP servers')).toBeTruthy()
expect(screen.getByText('Linear', { selector: 'span' })).toBeTruthy()
expect(screen.getByText('Postgres', { selector: 'span' })).toBeTruthy()
expect(screen.getAllByRole('button', { name: 'Install' })).toHaveLength(2)
expect(screen.getByRole('button', { name: 'Continue' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Not now' })).toBeNull()
})
it('lists every target once settled, in the same three words as the connector card', () => {
renderTool({
settled_by: 'continue',
status: 'settled',
targets: [
{ action: 'install', kind: 'mcp', name: 'linear', state: 'connected', tools: ['a', 'b'] },
{ action: 'install', detail: 'catalog write failed', kind: 'mcp', name: 'postgres', state: 'not_connected' }
]
})
expect(screen.getByText('Installed Linear · 2 tools')).toBeTruthy()
expect(screen.getByText('Not connected')).toBeTruthy()
expect(screen.queryByText(/catalog write failed/)).toBeNull()
expect(screen.queryAllByRole('button').filter(button => !button.hasAttribute('disabled'))).toHaveLength(0)
})
})

View File

@@ -2,16 +2,17 @@
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { capabilityScoped } from '@/api/client'
import { useSessionView } from '@/app/chat/session-view'
import { ToolFallback } from '@/components/assistant-ui/tool/fallback'
import { WIDGET_SHELL_CLASS } from '@/components/chat/widget-shell'
import { ConnectorCard, type ConnectorCardCopy, ConnectorSummary } from '@/components/ui/connector-card'
import { Button } from '@/components/ui/button'
import { ConnectorCard, ConnectorRow, type ConnectorRowMark, ConnectorSummary } from '@/components/ui/connector-card'
import { getActionStatus, getMcpCatalog, installMcpCatalogEntry, type McpCatalogEntry, setMcpServerEnabled } from '@/hermes'
import { useI18n } from '@/i18n'
import { connectorText, mcpTargets } from '@/lib/connector-tools'
import { connectorText, type McpTarget, mcpTargets } from '@/lib/connector-tools'
import { triggerHaptic } from '@/lib/haptics'
import { Loader2 } from '@/lib/icons'
import { isSubmitEnter } from '@/lib/ime'
@@ -19,7 +20,10 @@ import { completeMcpDesktopOAuth, McpOAuthCancelled } from '@/lib/mcp-dashboard-
import { prettyName } from '@/lib/text'
import { cn } from '@/lib/utils'
import {
type ConnectionRequest,
type ConnectionTarget,
type ConnectionTargetOutcome,
continueConnectionRequest,
respondToConnectionRequest,
sessionConnectionRequest
} from '@/store/connection-request'
@@ -30,79 +34,58 @@ import { invalidateMcpSuggestionIndex } from '@/store/suggestion-providers/mcp'
import { selectMessageRunning } from './tool/fallback-model'
import { parseMaybeObject } from './tool/fallback-model/format'
type SetupAction = 'authorize' | 'enable' | 'install'
interface SetupArgs {
server: string
action: SetupAction
}
type SetupAction = McpTarget['action']
type SetupCopy = ReturnType<typeof useI18n>['t']['assistant']['mcpSetup']
const CATALOG_INSTALL_POLL_MS = 1500
// The declined response is already sent before this sentinel reaches the catch path.
const CANCELLED = Symbol('mcp-setup-cancelled')
function readSetupArgs(args: unknown): SetupArgs {
const row = parseMaybeObject(args)
const [target] = mcpTargets('manage_connections', row)
return {
action: target?.action ?? 'install',
server: target?.name ?? ''
}
}
interface SettledResult {
status?: 'connected' | 'not_connected' | 'skipped' | 'unavailable'
detail?: string
server?: string
tools?: string[]
}
function readSetupResult(result: unknown): SettledResult {
const row = parseMaybeObject(result)
const [target] = Array.isArray(row.targets) ? row.targets.map(parseMaybeObject) : []
if (!target) {
return {}
}
const STATES: readonly NonNullable<SettledResult['status']>[] = ['connected', 'not_connected', 'skipped', 'unavailable']
return {
detail: connectorText(target.detail),
server: connectorText(target.name),
status: STATES.find(state => state === target.state),
tools: Array.isArray(target.tools) ? target.tools.map(connectorText).filter((t): t is string => t !== undefined) : undefined
}
}
const SHELL_CLASS = `${WIDGET_SHELL_CLASS} text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)`
function cardCopy(
copy: ReturnType<typeof useI18n>['t']['assistant']['mcpSetup'],
action: SetupAction
): ConnectorCardCopy {
return {
connectAction:
action === 'enable' ? copy.enableAction : action === 'authorize' ? copy.authorizeAction : copy.installAction,
connectTitle:
action === 'enable' ? copy.enableTitle : action === 'authorize' ? copy.authorizeTitle : copy.installTitle,
decline: copy.decline,
envRequired: copy.envRequired,
grantAction: copy.authorizeAction,
retryAction: copy.installAction,
stateConnected: '',
stateDeclined: copy.declined,
stateDisabled: '',
stateFailed: '',
stateNeedsAuth: '',
toolCount: copy.toolCount,
trustCommunity: '',
trustCommunityTip: () => '',
trustVerified: () => '',
trustVerifiedTip: () => ''
}
const TITLE = {
authorize: (copy: SetupCopy) => copy.authorizeTitle,
enable: (copy: SetupCopy) => copy.enableTitle,
install: (copy: SetupCopy) => copy.installTitle
} satisfies Record<SetupAction, (copy: SetupCopy) => string>
const VERB = {
authorize: (copy: SetupCopy) => copy.authorizeAction,
enable: (copy: SetupCopy) => copy.enableAction,
install: (copy: SetupCopy) => copy.installAction
} satisfies Record<SetupAction, (copy: SetupCopy) => string>
const DONE = {
authorize: (copy: SetupCopy, server: string) => copy.authorized(server),
enable: (copy: SetupCopy, server: string) => copy.enabled(server),
install: (copy: SetupCopy, server: string) => copy.installed(server)
} satisfies Record<SetupAction, (copy: SetupCopy, server: string) => string>
// Mirrors `RESOLVED_STATES` in tools/connectors/contract.py.
const resolved = (target: ConnectionTarget): boolean =>
target.state === 'connected' || target.state === 'skipped' || target.state === 'unavailable'
function readSetupAction(args: unknown): SetupAction {
const [target] = mcpTargets('manage_connections', parseMaybeObject(args))
return target?.action ?? 'install'
}
interface SettledTarget {
name: string
state: string
tools: number
}
function readSetupResult(result: unknown): SettledTarget[] {
const row = parseMaybeObject(result)
const targets = Array.isArray(row.targets) ? row.targets.map(parseMaybeObject) : []
return targets.flatMap(target => {
const name = connectorText(target.name)
return name
? [{ name, state: connectorText(target.state) ?? '', tools: Array.isArray(target.tools) ? target.tools.length : 0 }]
: []
})
}
export const McpSetupTool = (props: ToolCallMessagePartProps) => {
@@ -126,119 +109,123 @@ const McpSetupLive = (props: ToolCallMessagePartProps) => {
function McpSetupSettled({ args, result }: ToolCallMessagePartProps) {
const { t } = useI18n()
const copy = t.assistant.mcpSetup
const fromArgs = useMemo(() => readSetupArgs(args), [args])
const fromResult = useMemo(() => readSetupResult(result), [result])
const server = fromResult.server || fromArgs.server
const status = fromResult.status ?? 'not_connected'
const displayName = prettyName(server)
const connectedLine =
fromArgs.action === 'enable'
? copy.enabled(displayName)
: fromArgs.action === 'authorize'
? copy.authorized(displayName)
: copy.installed(displayName)
const line =
status === 'connected'
? connectedLine
: status === 'skipped'
? copy.declined
: status === 'not_connected' && fromResult.detail === 'deadline'
? copy.unanswered
: copy.failed(displayName)
const ok = status === 'connected'
const neutral = status === 'skipped' || (status === 'not_connected' && fromResult.detail === 'deadline')
const toolCount = Array.isArray(fromResult.tools) ? fromResult.tools.length : 0
const action = useMemo(() => readSetupAction(args), [args])
const targets = useMemo(() => readSetupResult(result), [result])
return (
<ConnectorSummary
connector={{ name: server, title: displayName }}
meta={
ok && toolCount > 0
? `${line} · ${copy.toolCount(toolCount)}`
: !ok && !neutral && fromResult.detail
? `${line} — ${fromResult.detail}`
: line
}
tone={ok ? 'ok' : neutral ? undefined : 'error'}
/>
<div className="my-2 grid min-w-0 max-w-lg gap-1">
{targets.map(target => {
const title = prettyName(target.name)
const connected = target.state === 'connected'
const line = connected
? DONE[action](copy, title)
: target.state === 'skipped'
? t.connectors.skipped
: t.connectors.notConnected
return (
<ConnectorSummary
connector={{ name: target.name, title }}
key={target.name}
meta={connected && target.tools > 0 ? `${line} · ${copy.toolCount(target.tools)}` : line}
tone={connected ? 'ok' : undefined}
/>
)
})}
</div>
)
}
function McpSetupPending({ args }: ToolCallMessagePartProps) {
export function McpSetupPending({ args }: ToolCallMessagePartProps) {
const { t } = useI18n()
const copy = t.assistant.mcpSetup
// Use the rendering transcript's session, not the globally active one.
const sessionId = useStore(useSessionView().$runtimeId)
const $request = useMemo(() => sessionConnectionRequest(sessionId), [sessionId])
const request = useStore($request)
const action = useMemo(() => readSetupAction(args), [args])
const title = TITLE[action](copy)
// `tool.start` arrives before `connection.request`.
if (!request) {
return (
<div className={cn(SHELL_CLASS, 'my-1.5 flex items-center gap-2')} data-slot="connector-card">
<Loader2 aria-hidden className="size-4 animate-spin text-(--ui-text-tertiary)" />
<span className="text-(--ui-text-tertiary)">{title}</span>
</div>
)
}
const open = request.targets.filter(target => !resolved(target))
return (
<div className="my-2 grid min-w-0 max-w-lg gap-1" data-connector-offer>
<ConnectorCard title={title}>
{request.targets.map(target => (
<McpSetupRow
action={action}
copy={copy}
key={target.name}
request={request}
single={open.length === 1 && open[0] === target}
target={target}
/>
))}
</ConnectorCard>
{open.length > 0 ? (
<div className="px-3.5">
<Button onClick={() => void continueConnectionRequest(request)} size="xs" variant="textStrong">
{t.common.continue}
</Button>
</div>
) : null}
</div>
)
}
interface McpSetupRowProps {
action: SetupAction
copy: SetupCopy
request: ConnectionRequest
/** The only open row owns ⌘⏎; with several rows the buttons are the path. */
single: boolean
target: ConnectionTarget
}
function McpSetupRow({ action, copy, request, single, target }: McpSetupRowProps) {
const { t } = useI18n()
const gateway = useStore($gateway)
const fromArgs = useMemo(() => readSetupArgs(args), [args])
const [requestTarget] = request?.targets ?? []
const server = fromArgs.server || requestTarget?.name || ''
const action: SetupAction = fromArgs.action ?? requestTarget?.action ?? 'install'
const [working, setWorking] = useState(false)
const [envDraft, setEnvDraft] = useState<Record<string, string>>({})
const [entry, setEntry] = useState<McpCatalogEntry | null | undefined>(undefined)
const [envOpen, setEnvOpen] = useState(false)
const cancelRef = useRef(false)
const server = target.name
const displayName = prettyName(server)
const done = resolved(target)
// `tool.start` arrives before `connection.request`.
const ready = Boolean(request)
const respond = async (outcome: ConnectionTargetOutcome) => {
if (!gateway) {
notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed)
const respond = useCallback(
async (outcome: ConnectionTargetOutcome) => {
if (!request) {
return
}
return
}
if (!gateway) {
notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed)
if (outcome.status === 'connected') {
invalidateMcpSuggestionIndex()
}
return
}
try {
await respondToConnectionRequest(request, { targets: [outcome] })
} catch (error) {
notifyError(error, copy.sendFailed)
}
}
const success = outcome.status === 'connected'
if (success) {
invalidateMcpSuggestionIndex()
}
try {
await respondToConnectionRequest(request, { targets: [outcome] })
} catch (error) {
notifyError(error, copy.sendFailed)
}
},
[copy.gatewayDisconnected, copy.sendFailed, gateway, request]
)
const decline = useCallback(() => {
// Respond first; cancelRef stops abandoned work at its next poll.
cancelRef.current = true
triggerHaptic('cancel')
void respond({ name: server, status: 'skipped' })
}, [respond, server])
const approve = useCallback(async () => {
cancelRef.current = false
const approve = async () => {
const oauthScope = capabilityScoped()
setWorking(true)
// OAuth owns its cancellation; polling needs an explicit boundary check.
const throwIfCancelled = <T,>(value: T): T => {
if (cancelRef.current) {
throw CANCELLED
}
return value
}
try {
if (action === 'enable') {
await setMcpServerEnabled(server, true)
@@ -249,11 +236,7 @@ function McpSetupPending({ args }: ToolCallMessagePartProps) {
}
if (action === 'authorize') {
const flow = await completeMcpDesktopOAuth({
serverName: server,
profile: oauthScope,
cancelled: () => cancelRef.current
})
const flow = await completeMcpDesktopOAuth({ serverName: server, profile: oauthScope })
triggerHaptic('submit')
await respond({ name: server, status: 'connected', tools: (flow.tools ?? []).map(tool => tool.name) })
@@ -261,21 +244,21 @@ function McpSetupPending({ args }: ToolCallMessagePartProps) {
return
}
let resolved = entry
let catalogEntry = entry
if (resolved === undefined) {
if (catalogEntry === undefined) {
const catalog = await getMcpCatalog()
resolved = catalog.entries.find(candidate => candidate.name === server) ?? null
setEntry(resolved)
catalogEntry = catalog.entries.find(candidate => candidate.name === server) ?? null
setEntry(catalogEntry)
}
if (!resolved) {
if (!catalogEntry) {
await respond({ detail: copy.notInCatalog(server), name: server, status: 'failed' })
return
}
const required = resolved.required_env.filter(env => env.required)
const required = catalogEntry.required_env.filter(env => env.required)
if (required.some(env => !envDraft[env.name]?.trim())) {
setEnvOpen(true)
@@ -288,7 +271,7 @@ function McpSetupPending({ args }: ToolCallMessagePartProps) {
// Poll background installs so non-zero exits cannot report false success.
if (res.background && res.action) {
for (;;) {
const status = throwIfCancelled(await getActionStatus(res.action, 1))
const status = await getActionStatus(res.action, 1)
if (!status.running) {
if (status.exit_code !== 0) {
@@ -305,12 +288,12 @@ function McpSetupPending({ args }: ToolCallMessagePartProps) {
triggerHaptic('submit')
await respond({ name: server, status: 'connected' })
} catch (error) {
// The declined response is already sent; do not report cancellation as failure.
if (error === CANCELLED || error instanceof McpOAuthCancelled) {
// The user closed the sign-in window; the row simply offers again.
if (error instanceof McpOAuthCancelled) {
return
}
notifyError(error, copy.failed(server))
notifyError(error, copy.failed(displayName))
await respond({
detail: error instanceof Error ? error.message : String(error),
name: server,
@@ -319,21 +302,16 @@ function McpSetupPending({ args }: ToolCallMessagePartProps) {
} finally {
setWorking(false)
}
}, [action, copy, entry, envDraft, respond, server])
}
const displayName = prettyName(server)
const card = cardCopy(copy, action)
const sourceLine = action === 'install' ? (entry?.url ?? copy.catalogSource) : null
// Do not capture shortcuts while a focusable control owns typed input.
// Do not capture the shortcut while a focusable control owns typed input.
useEffect(() => {
if (!ready) {
if (!single || done) {
return
}
const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.defaultPrevented) {
if (event.defaultPrevented || !isSubmitEnter(event) || !(event.metaKey || event.ctrlKey)) {
return
}
@@ -346,50 +324,32 @@ function McpSetupPending({ args }: ToolCallMessagePartProps) {
return
}
if (isSubmitEnter(event) && (event.metaKey || event.ctrlKey)) {
if (!working) {
event.preventDefault()
void approve()
}
} else if (event.key === 'Escape') {
if (!working) {
event.preventDefault()
decline()
void approve()
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [approve, decline, ready, working])
})
if (!ready) {
return (
<div className={cn(SHELL_CLASS, 'my-1.5 flex items-center gap-2')} data-slot="connector-card">
<Loader2 aria-hidden className="size-4 animate-spin text-(--ui-text-tertiary)" />
<span className="text-(--ui-text-tertiary)">{card.connectTitle?.(displayName)}</span>
</div>
)
}
const waiting = working && action === 'authorize'
const mark: ConnectorRowMark = done ? 'connected' : waiting ? 'waiting' : 'idle'
return (
<ConnectorCard
accelerators
busy={working}
connector={{
name: server,
requiredEnv: entry?.required_env,
title: displayName
}}
copy={{ ...card, decline: working ? t.common.cancel : card.decline }}
<ConnectorRow
action={done ? undefined : { busy: working, label: VERB[action](copy), onClick: () => void approve() }}
connector={{ name: server, title: displayName }}
cue={waiting ? t.connectors.waiting : undefined}
envDraft={envDraft}
envFields={entry?.required_env}
envOpen={envOpen && !!entry && entry.required_env.length > 0}
onConnect={() => void approve()}
onDismiss={decline}
envRequired={copy.envRequired}
mark={mark}
markLabel={mark === 'connected' ? t.connectors.connected : waiting ? t.connectors.waiting : t.connectors.notConnected}
onEnvChange={(key, value) => setEnvDraft(prev => ({ ...prev, [key]: value }))}
phase={working ? '' : undefined}
source={sourceLine ? { text: sourceLine } : undefined}
state="not_configured"
variant="avatar"
/>
)
}

View File

@@ -107,7 +107,7 @@ describe('manage_connections routing outside guided onboarding', () => {
)
await waitFor(() => {
expect(screen.getAllByRole('button', { name: 'Not now' })).toHaveLength(2)
expect(screen.getAllByRole('button', { name: 'Connect' })).toHaveLength(2)
})
expect(screen.queryByText(/running manage connections/i)).toBeNull()
})

View File

@@ -1,238 +1,84 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ConnectorCard,
type ConnectorCardCopy,
type ConnectorCardProps,
type ConnectorCardSubject
} from './connector-card'
import { ConnectorCard, ConnectorRow, type ConnectorRowProps } from './connector-card'
import { connectorLogoSource } from './connector-logo'
afterEach(cleanup)
const COPY: ConnectorCardCopy = {
connectAction: 'Connect',
decline: 'Not now',
envRequired: 'Fill in the required credentials first',
grantAction: 'Grant access',
retryAction: 'Retry',
stateConnected: 'Connected',
stateDeclined: 'Skipped',
stateDisabled: 'currently off',
stateFailed: 'Failed',
stateNeedsAuth: 'signed out',
toolCount: count => (count === 1 ? '1 tool' : `${count} tools`),
trustCommunity: 'unreviewed',
trustCommunityTip: host => `Nobody has verified who runs ${host}`,
trustVerified: publisher => `verified · ${publisher}`,
trustVerifiedTip: publisher => `The publisher proved it owns ${publisher}`
}
const LINEAR: ConnectorCardSubject = {
description: 'Issues and projects.',
homepage: 'https://linear.app',
name: 'linear',
title: 'Linear',
trust: 'catalog'
}
function renderCard(overrides: Partial<ConnectorCardProps> = {}) {
const onConnect = vi.fn()
const onDismiss = vi.fn()
const LINEAR = { homepage: 'https://linear.app', name: 'linear', title: 'Linear' }
function renderRow(overrides: Partial<ConnectorRowProps> = {}) {
render(
<ConnectorCard
connector={LINEAR}
copy={COPY}
onConnect={onConnect}
onDismiss={onDismiss}
state="not_configured"
{...overrides}
/>
<ConnectorCard title="Connect your apps">
<ConnectorRow connector={LINEAR} mark="idle" markLabel="Not connected" {...overrides} />
</ConnectorCard>
)
return { onConnect, onDismiss }
}
describe('the resting offer', () => {
it('leads with what the thing is, not with a question about it', () => {
renderCard()
describe('a row in the card', () => {
it('offers exactly one verb and gives no reason', () => {
const onClick = vi.fn()
// Scoped to a span: the brand glyph is an <svg> carrying its own
// <title>Linear</title>, which is the mark's accessible name rather than
// the card's copy.
renderRow({ action: { label: 'Connect', onClick } })
expect(screen.getByText('Connect your apps')).toBeTruthy()
// Scoped to a span: the brand glyph is an <svg> carrying its own <title>.
expect(screen.getByText('Linear', { selector: 'span' })).toBeTruthy()
expect(screen.getByText('Issues and projects.')).toBeTruthy()
})
it('offers a connect and a way out', () => {
const { onConnect, onDismiss } = renderCard()
expect(screen.getAllByRole('button')).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onConnect).toHaveBeenCalledOnce()
fireEvent.click(screen.getByRole('button', { name: 'Not now' }))
expect(onDismiss).toHaveBeenCalledOnce()
expect(onClick).toHaveBeenCalledOnce()
})
it('says how a configured connector currently stands', () => {
renderCard({ state: 'needs_auth' })
expect(screen.getByText('signed out')).toBeTruthy()
})
})
describe('while it is working', () => {
it('shows the phase instead of the resting state, because the browser tab that just took focus is otherwise unexplained', () => {
renderCard({ phase: 'Signing in…', state: 'needs_auth' })
expect(screen.getByText('Signing in…')).toBeTruthy()
expect(screen.queryByText('signed out')).toBeNull()
})
it('holds its own action but never the way out', () => {
// While a connect is in flight, decline is the escape from a stuck
// sign-in tab or a hung install.
renderCard({ busy: true, phase: 'Installing…' })
const [action] = screen.getAllByRole('button')
expect(action.hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: 'Not now' }).hasAttribute('disabled')).toBe(false)
})
it('keeps Connect clickable under a phase label alone, so a waiting row can reopen its link', () => {
renderCard({ phase: 'Finish connecting in your browser…' })
it('says how it stands through the mark, so the verb never has to', () => {
renderRow({
action: { label: 'Connect', onClick: vi.fn() },
cue: 'Waiting for your browser…',
mark: 'waiting',
markLabel: 'Waiting for your browser…'
})
expect(screen.getByRole('img', { name: 'Waiting for your browser…' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Connect' }).hasAttribute('disabled')).toBe(false)
})
it('holds its action while a sibling is mid-flight, so two sign-in tabs never race for focus', () => {
renderCard({ otherBusy: true })
it('holds its verb while it runs', () => {
renderRow({ action: { busy: true, label: 'Try again', onClick: vi.fn() } })
expect(screen.getByRole('button', { name: 'Connect' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: 'Not now' }).hasAttribute('disabled')).toBe(false)
const button = screen.getByRole('button')
expect(button.getAttribute('aria-busy')).toBe('true')
expect(button.hasAttribute('disabled')).toBe(true)
})
it('has nothing to press once it is done', () => {
renderRow({ mark: 'connected', markLabel: 'Connected' })
expect(screen.queryAllByRole('button')).toHaveLength(0)
expect(screen.getByRole('img', { name: 'Connected' })).toBeTruthy()
})
})
describe('once it is answered', () => {
it('collapses to a single line reporting what came of it', () => {
renderCard({ outcome: { status: 'connected', tools: ['a', 'b', 'c'] } })
describe('credentials under a row', () => {
const envFields = [{ name: 'LINEAR_API_KEY', prompt: 'API key', required: true }]
expect(screen.getByText('Connected · 3 tools')).toBeTruthy()
// The offer is spent; the space belongs to whatever is still asking.
expect(screen.queryByRole('button', { name: 'Connect' })).toBeNull()
it('stay out of the way until the row asks for them', () => {
renderRow({ envFields })
expect(screen.queryByLabelText('API key *')).toBeNull()
})
it('collapses the same way when waved off, still naming the thing', () => {
renderCard({ dismissed: true })
expect(screen.getByText('Linear', { selector: 'span' })).toBeTruthy()
expect(screen.getByText('Skipped')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Not now' })).toBeNull()
})
it('does not claim a tool count it does not have', () => {
renderCard({ outcome: { status: 'connected' } })
expect(screen.getByText('Connected')).toBeTruthy()
})
})
describe('after a failure', () => {
it('stays live with the reason and a retry', () => {
renderCard({ outcome: { detail: 'Connection refused', status: 'error' } })
expect(screen.getByText('Connection refused')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy()
})
it('asks for access rather than a retry when the credential was refused', () => {
// Repeating a refused grant just gets refused again; the fix is to ask.
renderCard({ outcome: { detail: 'insufficient scope', needsAuth: true, status: 'error' } })
expect(screen.getByRole('button', { name: 'Grant access' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
})
it('brings the setup steps back, because the failure was usually caused by one of them', () => {
const withSetup: ConnectorCardSubject = { ...LINEAR, setup: ['Enable the API'] }
renderCard({ connector: withSetup, outcome: { status: 'error' }, state: 'connected' })
expect(screen.getByText('Enable the API')).toBeTruthy()
})
})
describe('the work only the user can do', () => {
it('numbers the steps, because their order matters', () => {
renderCard({ connector: { ...LINEAR, setup: ['Create a project', 'Enable the API'] } })
expect(screen.getByText('1.')).toBeTruthy()
expect(screen.getByText('2.')).toBeTruthy()
})
it('turns a markdown link into a real link, because the whole cost of a step is finding the page', () => {
renderCard({ connector: { ...LINEAR, setup: ['Create a token at [Settings](https://linear.app/settings/api)'] } })
const link = screen.getByRole('link', { name: 'Settings' })
expect(link.getAttribute('href')).toBe('https://linear.app/settings/api')
})
it('does not repeat console instructions at a connector that already works', () => {
renderCard({ connector: { ...LINEAR, setup: ['Enable the API'] }, state: 'disabled' })
expect(screen.queryByText('Enable the API')).toBeNull()
})
})
describe('credentials', () => {
const withEnv: ConnectorCardSubject = {
...LINEAR,
requiredEnv: [{ name: 'LINEAR_API_KEY', prompt: 'API key', required: true }]
}
it('stays out of the way until the card asks for it', () => {
renderCard({ connector: withEnv })
expect(screen.queryByText('API key *')).toBeNull()
})
it('reports each keystroke so the caller owns the draft', () => {
it('report each keystroke so the caller owns the draft, and mask the value', () => {
const onEnvChange = vi.fn()
renderCard({ connector: withEnv, envOpen: true, onEnvChange })
renderRow({ envFields, envOpen: true, envRequired: 'Fill in the required credentials first', onEnvChange })
fireEvent.change(screen.getByLabelText('API key *'), { target: { value: 'lin_abc' } })
const input = screen.getByLabelText('API key *')
fireEvent.change(input, { target: { value: 'lin_abc' } })
expect(onEnvChange).toHaveBeenCalledWith('LINEAR_API_KEY', 'lin_abc')
})
it('masks the value, since these are secrets', () => {
renderCard({ connector: withEnv, envOpen: true })
expect(screen.getByLabelText('API key *').getAttribute('type')).toBe('password')
})
})
describe('how much the source vouches for it', () => {
it('says nothing for a reviewed source, so the badge that matters is not trained away', () => {
renderCard()
expect(screen.queryByText('unreviewed')).toBeNull()
expect(screen.queryByText(/verified/)).toBeNull()
})
it('names the domain a publisher proved it owns, rather than claiming trust', () => {
renderCard({ connector: { ...LINEAR, publisher: 'notion.com', trust: 'verified' } })
expect(screen.getByText('verified · notion.com')).toBeTruthy()
})
it('flags a publisher nobody has vouched for', () => {
renderCard({ connector: { ...LINEAR, trust: 'community', url: 'https://random.test/mcp' } })
expect(screen.getByText('unreviewed')).toBeTruthy()
expect(input.getAttribute('type')).toBe('password')
})
})

View File

@@ -1,18 +1,28 @@
import type { ReactNode } from 'react'
import { SCAFFOLD_META_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row'
import { WIDGET_SHELL_CLASS } from '@/components/chat/widget-shell'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ConnectorLogo, type ConnectorLogoSubject } from '@/components/ui/connector-logo'
import { Input } from '@/components/ui/input'
import { Tip } from '@/components/ui/tooltip'
import { MarkdownLinkText } from '@/lib/external-link'
import { CheckCircle2 } from '@/lib/icons'
import { Check, CircleIcon, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
/** Presentation leaf: callers own connector semantics and localized copy. */
export type ConnectorCardState = 'connected' | 'disabled' | 'needs_auth' | 'not_configured'
/**
* Presentation leaf: callers own connector semantics and localized copy.
*
* The card offers; the transcript explains. One shell, one heading, one row per app. A row carries a
* mark, a name, at most one cue and at most one verb; it never says why. The verb lives in a fixed
* lane so every row's control is the same box on the same edge.
*/
export type ConnectorRowMark = 'connected' | 'idle' | 'waiting'
export type ConnectorCardTrust = 'catalog' | 'community' | 'verified'
export interface ConnectorRowAction {
busy?: boolean
disabled?: boolean
label: string
onClick: () => void
}
export interface ConnectorCardField {
name: string
@@ -20,76 +30,103 @@ export interface ConnectorCardField {
required?: boolean
}
/** Structural to accept callers' richer subject types without importing them. */
export interface ConnectorCardSubject extends ConnectorLogoSubject {
description?: string
publisher?: string
requiredEnv?: ConnectorCardField[]
setup?: string[]
title: string
trust?: ConnectorCardTrust
}
export interface ConnectorCardOutcome {
detail?: string
/** An access refusal; offer authorization instead of retry. */
needsAuth?: boolean
status: 'connected' | 'declined' | 'error'
tools?: unknown[]
}
/** Passed in so this presentation leaf has no i18n dependency. */
export interface ConnectorCardCopy {
connectAction: string
connectTitle?: (title: string) => string
decline: string
envRequired: string
grantAction: string
retryAction: string
stateConnected: string
stateDeclined: string
stateDisabled: string
stateFailed: string
stateNeedsAuth: string
toolCount: (count: number) => string
trustCommunity: string
trustCommunityTip: (host: string) => string
trustVerified: (publisher: string) => string
trustVerifiedTip: (publisher: string) => string
}
export interface ConnectorCardSource {
text: string
export interface ConnectorRowProps {
/** Absent once the row is resolved: there is nothing left to offer. */
action?: ConnectorRowAction
connector: ConnectorLogoSubject
/** The one quiet line after the name; the waiting row's "in your browser" cue. */
cue?: string
envDraft?: Record<string, string>
envFields?: ConnectorCardField[]
/** The caller reveals fields after a refused credential. */
envOpen?: boolean
envRequired?: string
mark: ConnectorRowMark
/** What a screen reader gets for the mark. */
markLabel: string
onEnvChange?: (key: string, value: string) => void
}
const SHELL_CLASS = `${WIDGET_SHELL_CLASS} text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)`
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
const MARKS = {
connected: { Icon: Check, className: 'text-emerald-600 dark:text-emerald-400' },
idle: { Icon: CircleIcon, className: 'text-(--ui-text-quaternary)' },
waiting: { Icon: Loader2, className: 'animate-spin text-primary' }
} satisfies Record<ConnectorRowMark, { Icon: typeof Check; className: string }>
const hostOf = (url: null | string | undefined): string => {
if (!url) {
return ''
}
try {
return new URL(url).hostname
} catch {
return url
}
export function ConnectorCard({ children, title }: { children: ReactNode; title: string }) {
return (
<div className={cn(SHELL_CLASS, 'my-1.5 grid gap-0.5')} data-slot="connector-card">
<p className="pb-1.5 font-medium leading-(--conversation-line-height)">{title}</p>
{children}
</div>
)
}
export function outcomeMeta(outcome: ConnectorCardOutcome, copy: ConnectorCardCopy): string {
if (outcome.status === 'connected') {
const toolCount = Array.isArray(outcome.tools) ? outcome.tools.length : 0
export function ConnectorRow({
action,
connector,
cue,
envDraft = {},
envFields = [],
envOpen = false,
envRequired,
mark,
markLabel,
onEnvChange
}: ConnectorRowProps) {
const { Icon, className } = MARKS[mark]
const fields = envOpen ? envFields : []
return toolCount > 0 ? `${copy.stateConnected} · ${copy.toolCount(toolCount)}` : copy.stateConnected
}
return (
<div className="grid gap-1" data-slot="connector-row">
<div className="flex h-8 items-center gap-2.5">
<span aria-label={markLabel} className="grid size-4 shrink-0 place-items-center" role="img">
<Icon aria-hidden className={cn('size-3.5', className)} />
</span>
<ConnectorLogo className="size-6 rounded-md text-[0.6875rem]" connector={connector} />
<span className="truncate leading-(--conversation-line-height)">{connector.title || connector.name}</span>
<span className="min-w-0 flex-1 truncate text-[0.6875rem] text-(--ui-text-tertiary)">{cue}</span>
<span className="flex w-22 shrink-0 justify-end">
{action ? (
<span className="inline-flex h-6 w-full items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
<Button
className="h-full w-full rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
disabled={action.disabled}
loading={action.busy}
onClick={action.onClick}
size="xs"
variant="ghost"
>
{action.label}
</Button>
</span>
) : null}
</span>
</div>
if (outcome.status === 'error') {
return outcome.detail || copy.stateFailed
}
return copy.stateDeclined
{fields.length > 0 && (
<div className="grid gap-2 pb-1.5 pl-13" data-slot="connector-row-env">
{envRequired ? <p className="text-[0.6875rem] text-(--ui-text-tertiary)">{envRequired}</p> : null}
{fields.map(env => (
<label className="grid gap-1" key={env.name}>
<span className="text-[0.6875rem] text-(--ui-text-secondary)">
{env.prompt || env.name}
{env.required ? ' *' : ''}
</span>
<Input
className="h-7 text-xs"
onChange={event => onEnvChange?.(env.name, event.currentTarget.value)}
type="password"
value={envDraft[env.name] ?? ''}
/>
</label>
))}
</div>
)}
</div>
)
}
export function ConnectorSummary({
@@ -99,7 +136,7 @@ export function ConnectorSummary({
}: {
connector: ConnectorLogoSubject
meta?: string
tone?: 'error' | 'ok'
tone?: 'ok'
}) {
// Apply opacity to rows, not a shared container: it would create a stacking context for every sibling.
return (
@@ -110,13 +147,7 @@ export function ConnectorSummary({
{connector.title || connector.name}
</span>
{meta ? (
<span
className={cn(
SCAFFOLD_META_CLASS,
tone === 'error' && 'text-destructive',
tone === 'ok' && 'text-emerald-600/85 dark:text-emerald-400/85'
)}
>
<span className={cn(SCAFFOLD_META_CLASS, tone === 'ok' && 'text-emerald-600/85 dark:text-emerald-400/85')}>
{meta}
</span>
) : null}
@@ -124,211 +155,3 @@ export function ConnectorSummary({
</div>
)
}
// Catalog entries are ordinary; reserve badges for verified and community exceptions.
function TrustBadge({ connector, copy }: { connector: ConnectorCardSubject; copy: ConnectorCardCopy }) {
if (!connector.trust || connector.trust === 'catalog') {
return null
}
if (connector.trust === 'verified') {
if (!connector.publisher) {
return null
}
return (
<Tip label={copy.trustVerifiedTip(connector.publisher)}>
<span className="text-[0.6875rem] text-(--ui-text-tertiary)">{copy.trustVerified(connector.publisher)}</span>
</Tip>
)
}
return (
<Tip label={copy.trustCommunityTip(hostOf(connector.url))}>
<span className="inline-flex items-center gap-1 text-[0.6875rem] text-amber-500">
<Codicon name="warning" size="0.6875rem" />
{copy.trustCommunity}
</span>
</Tip>
)
}
export interface ConnectorCardProps {
/** Only callers that handle these keys may show the accelerator hint. */
accelerators?: boolean
collapseWhenSettled?: boolean
connector: ConnectorCardSubject
copy: ConnectorCardCopy
dismissed?: boolean
envDraft?: Record<string, string>
/** The caller reveals fields after a refused credential. */
envOpen?: boolean
onConnect: () => void
onDismiss: () => void
onEnvChange?: (key: string, value: string) => void
/** Prevent concurrent sign-in tabs; decline remains enabled. */
otherBusy?: boolean
actionDisabled?: boolean
outcome?: ConnectorCardOutcome
/** The action itself is running (spinner, button held). Independent of `phase`: a row can show
* "Finish connecting in your browser" while Connect stays clickable to reopen the link. */
busy?: boolean
phase?: string
source?: ConnectorCardSource
state: ConnectorCardState
variant?: 'avatar' | 'compact'
}
export function ConnectorCard({
accelerators = false,
collapseWhenSettled = true,
connector,
copy,
dismissed = false,
envDraft = {},
envOpen = false,
onConnect,
onDismiss,
onEnvChange,
otherBusy = false,
actionDisabled = false,
busy = false,
outcome,
phase,
source,
state,
variant = 'compact'
}: ConnectorCardProps) {
const working = phase !== undefined
const connected = outcome?.status === 'connected'
const failed = outcome?.status === 'error'
if ((connected || dismissed) && collapseWhenSettled) {
return (
<ConnectorSummary
connector={connector}
meta={outcomeMeta(outcome ?? { status: 'declined' }, copy)}
tone={connected ? 'ok' : undefined}
/>
)
}
const settled = connected || dismissed
const stateLabel = state === 'disabled' ? copy.stateDisabled : state === 'needs_auth' ? copy.stateNeedsAuth : null
// Keep setup and credentials visible after failure so they can be corrected.
const fixable = state === 'not_configured' || failed
const envFields = fixable ? (connector.requiredEnv ?? []) : []
const steps = fixable ? (connector.setup ?? []) : []
const avatar = variant === 'avatar'
return (
<div className={cn(SHELL_CLASS, 'my-1.5 grid gap-1.5')} data-slot="connector-card">
<div className={cn('flex items-start', avatar ? 'gap-3' : 'gap-2')}>
{avatar ? <ConnectorLogo className="size-10 rounded-xl text-base" connector={connector} /> : null}
<div className="grid min-w-0 flex-1 gap-0.5">
<div className="flex flex-wrap items-baseline gap-x-1.5">
<span className="font-medium leading-(--conversation-line-height)">
{copy.connectTitle ? copy.connectTitle(connector.title) : connector.title}
</span>
{working ? (
<span className="text-[0.6875rem] text-(--ui-text-tertiary)">{phase}</span>
) : (
stateLabel && <span className="text-[0.6875rem] text-(--ui-text-tertiary)">{stateLabel}</span>
)}
<TrustBadge connector={connector} copy={copy} />
</div>
{connector.description ? <p className="text-(--ui-text-secondary)">{connector.description}</p> : null}
{source ? <p className="truncate text-[0.6875rem] text-(--ui-text-tertiary)">{source.text}</p> : null}
{failed && outcome.detail ? <p className="text-[0.6875rem] text-destructive">{outcome.detail}</p> : null}
{steps.length > 0 && (
<ol className="mt-1.5 grid gap-1" data-slot="connector-card-steps">
{steps.map((step, index) => (
<li className="flex gap-1.5 text-[0.6875rem] text-(--ui-text-secondary)" key={step}>
<span className="tabular-nums text-(--ui-text-tertiary)">{index + 1}.</span>
<MarkdownLinkText text={step} />
</li>
))}
</ol>
)}
{envOpen && envFields.length > 0 && (
<div className="mt-1 grid gap-2" data-slot="connector-card-env">
<p className="text-[0.6875rem] text-(--ui-text-tertiary)">{copy.envRequired}</p>
{envFields.map(env => (
<label className="grid gap-1" key={env.name}>
<span className="text-[0.6875rem] text-(--ui-text-secondary)">
{env.prompt || env.name}
{env.required ? ' *' : ''}
</span>
<Input
className="h-7 text-xs"
onChange={event => onEnvChange?.(env.name, event.currentTarget.value)}
type="password"
value={envDraft[env.name] ?? ''}
/>
</label>
))}
</div>
)}
</div>
{avatar ? null : (
<ConnectorLogo className="mt-px size-5 rounded-[0.3rem] text-[0.6875rem]" connector={connector} />
)}
</div>
<div className={cn('flex items-center gap-2.5', avatar && 'pl-13')}>
{settled ? (
<div
className={cn(
'inline-flex h-6 items-stretch overflow-hidden rounded-md border',
connected
? 'border-emerald-600/25 bg-emerald-600/10 text-emerald-700 dark:border-emerald-400/25 dark:bg-emerald-400/10 dark:text-emerald-300'
: 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quaternary) text-(--ui-text-tertiary)'
)}
data-slot="connector-card-verdict"
>
<span className="inline-flex h-full items-center gap-1 px-2 text-xs font-medium">
{connected ? <CheckCircle2 aria-hidden className="size-3" /> : null}
{outcomeMeta(outcome ?? { status: 'declined' }, copy)}
</span>
</div>
) : (
<>
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
<Button
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
disabled={otherBusy || actionDisabled}
loading={busy}
onClick={onConnect}
size="xs"
variant="ghost"
>
{outcome?.needsAuth ? copy.grantAction : failed ? copy.retryAction : copy.connectAction}
{accelerators ? (
<span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>
) : null}
</Button>
</div>
{/* Never disable: this exits a stuck sign-in tab or hung install. */}
<Button
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
onClick={onDismiss}
size="xs"
variant="ghost"
>
{copy.decline}
{accelerators ? <span className="text-[0.625rem] opacity-55">Esc</span> : null}
</Button>
</>
)}
</div>
</div>
)
}

View File

@@ -13,7 +13,7 @@ export const en: Translations = {
connected: 'Connected',
checking: 'Checking your apps…',
waitingSignIn: 'Waiting for you to finish signing in…',
notConnected: "Didn't connect",
notConnected: 'Not connected',
notAvailable: 'Not available',
startWith: count => `Start the task with ${count} ${count === 1 ? 'app' : 'apps'} connected`,
startWithout: 'Start without connections',
@@ -22,18 +22,17 @@ export const en: Translations = {
failed: 'Could not connect',
needsAuth: 'Access expired',
opening: 'Opening sign-in…',
waiting: 'Finish connecting in your browser…',
waiting: 'Waiting for your browser…',
timeout: 'Still waiting for authorization.',
refresh: 'Refresh status',
statusError: 'Could not check connections. Try refreshing.',
connectError: 'Could not start authorization. Try again.',
connectErrorFor: (app: string) => `Could not start authorization for ${app}.`,
unavailable: 'Connectors are unavailable for this session.',
ownerMissing: 'Reopen this conversation to manage its connections.',
search: 'Find an app',
empty: 'No matching apps',
disclaimer: 'Connecting is optional. Only authorize the apps you want Hermes to use.',
connectTitle: app => `Connect ${app}?`,
describe: app => `Hermes signs in to ${app} in your browser and asks before reading anything there.`,
execution: 'Connector tools'
},
@@ -3931,22 +3930,18 @@ export const en: Translations = {
lateAnswerHint: 'This prompt is no longer waiting. Pick an option to draft it as a follow-up message.'
},
mcpSetup: {
installTitle: server => `Add the ${server} MCP server?`,
enableTitle: server => `Enable the ${server} MCP server?`,
authorizeTitle: server => `Authorize the ${server} MCP server?`,
installTitle: 'Add MCP servers',
enableTitle: 'Enable MCP servers',
authorizeTitle: 'Authorize MCP servers',
installAction: 'Install',
enableAction: 'Enable',
authorizeAction: 'Authorize',
decline: 'Not now',
declined: 'Declined',
installed: server => `Installed ${server}`,
enabled: server => `Enabled ${server}`,
authorized: server => `Authorized ${server}`,
failed: server => `Setup failed for ${server}`,
unanswered: 'No response',
toolCount: count => (count === 1 ? '1 tool' : `${count} tools`),
notInCatalog: server => `“${server}” is not in the MCP catalog`,
catalogSource: 'From the Nous-approved catalog',
envRequired: 'Fill in the required credentials first',
sendFailed: 'Could not send MCP setup response',
reloadFailed: 'Server saved, but reloading MCP tools failed — they load next session',

View File

@@ -3629,22 +3629,18 @@ export const ru = defineLocale({
lateAnswerHint: 'Этот промпт больше не ждёт. Выберите вариант, чтобы составить его как сообщение-продолжение.'
},
mcpSetup: {
installTitle: server => `Добавить MCP-сервер ${server}?`,
enableTitle: server => `Включить MCP-сервер ${server}?`,
authorizeTitle: server => `Авторизовать MCP-сервер ${server}?`,
installTitle: 'Добавить MCP-серверы',
enableTitle: 'Включить MCP-серверы',
authorizeTitle: 'Авторизовать MCP-серверы',
installAction: 'Установить',
enableAction: 'Включить',
authorizeAction: 'Авторизовать',
decline: 'Не сейчас',
declined: 'Отклонено',
installed: server => `${server} установлен`,
enabled: server => `${server} включён`,
authorized: server => `${server} авторизован`,
failed: server => `Настройка не удалась для ${server}`,
unanswered: 'Нет ответа',
toolCount: count => `${count} ${RU_NOUN(count, 'инструмент', 'инструмента', 'инструментов')}`,
notInCatalog: server => `«${server}» нет в MCP-каталоге`,
catalogSource: 'Из каталога, одобренного Nous',
envRequired: 'Сначала заполните обязательные учётные данные',
sendFailed: 'Не удалось отправить ответ на настройку MCP',
reloadFailed: 'Сервер сохранён, но перезагрузка MCP-инструментов не удалась — они загрузятся в следующем сеансе',

View File

@@ -75,13 +75,12 @@ export interface Translations {
refresh: string
statusError: string
connectError: string
connectErrorFor: (app: string) => string
unavailable: string
ownerMissing: string
search: string
empty: string
disclaimer: string
connectTitle: (app: string) => string
describe: (app: string) => string
execution: string
}
sessionImport: {
@@ -3457,22 +3456,18 @@ export interface Translations {
lateAnswerHint: string
}
mcpSetup: {
installTitle: (server: string) => string
enableTitle: (server: string) => string
authorizeTitle: (server: string) => string
installTitle: string
enableTitle: string
authorizeTitle: string
installAction: string
enableAction: string
authorizeAction: string
decline: string
declined: string
installed: (server: string) => string
enabled: (server: string) => string
authorized: (server: string) => string
failed: (server: string) => string
unanswered: string
toolCount: (count: number) => string
notInCatalog: (server: string) => string
catalogSource: string
envRequired: string
sendFailed: string
reloadFailed: string

View File

@@ -16,18 +16,18 @@ export const zh = defineLocale({
failed: '连接失败',
needsAuth: '授权已过期',
opening: '正在打开登录…',
waiting: '请在浏览器中完成连接…',
waiting: '正在等待浏览器…',
notConnected: '未连接',
timeout: '仍在等待授权。',
refresh: '刷新状态',
statusError: '无法检查连接,请刷新重试。',
connectError: '无法开始授权,请重试。',
connectErrorFor: app => `无法为 ${app} 开始授权。`,
unavailable: '此会话暂时无法使用连接器。',
ownerMissing: '请重新打开此对话以管理连接。',
search: '查找应用',
empty: '没有匹配的应用',
disclaimer: '连接为可选操作。请仅授权你希望 Hermes 使用的应用。',
connectTitle: app => `连接 ${app}?`,
describe: app => `Hermes 会在浏览器中登录 ${app},读取任何内容前都会先询问。`,
execution: '连接器工具'
},
@@ -4085,22 +4085,18 @@ export const zh = defineLocale({
lateAnswerHint: '此问题已不再等待回答。选择一个选项会将其起草为后续消息。'
},
mcpSetup: {
installTitle: server => `添加 ${server} MCP 服务器?`,
enableTitle: server => `启用 ${server} MCP 服务器?`,
authorizeTitle: server => `授权 ${server} MCP 服务器?`,
installTitle: '添加 MCP 服务器',
enableTitle: '启用 MCP 服务器',
authorizeTitle: '授权 MCP 服务器',
installAction: '安装',
enableAction: '启用',
authorizeAction: '授权',
decline: '暂不',
declined: '已拒绝',
installed: server => `已安装 ${server}`,
enabled: server => `已启用 ${server}`,
authorized: server => `已授权 ${server}`,
failed: server => `${server} 设置失败`,
unanswered: '未响应',
toolCount: count => `${count} 个工具`,
notInCatalog: server => `“${server}”不在 MCP 目录中`,
catalogSource: '来自 Nous 认证目录',
envRequired: '请先填写所需凭据',
sendFailed: '无法发送 MCP 设置响应',
reloadFailed: '服务器已保存,但重新加载 MCP 工具失败 — 将在下个会话加载',