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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
117
apps/desktop/src/components/assistant-ui/mcp-setup-tool.test.tsx
Normal file
117
apps/desktop/src/components/assistant-ui/mcp-setup-tool.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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-инструментов не удалась — они загрузятся в следующем сеансе',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 工具失败 — 将在下个会话加载',
|
||||
|
||||
Reference in New Issue
Block a user