Make MCP reconnect progress visible in the detail view

The reconnect action could succeed, but the detail and edit views gave almost no page-level feedback beyond the button spinner. Mirror the reconnect lifecycle into the visible status surfaces so users can tell immediately that the request is in flight and whether it settled.

Constraint: Reconnect feedback must be visible without waiting for the list row to refresh
Rejected: Rely on toast only | too late and too easy to miss while staying on the detail page
Rejected: Add a separate transient banner | duplicates the existing status surfaces instead of updating them
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep reconnect state reflected in the page-level status badge and status field, not just the action button
Tested: cd desktop && bun run test -- mcpSettings.test.tsx; cd desktop && bun x tsc --noEmit --ignoreDeprecations 5.0
Not-tested: Manual desktop click-through against a live MCP reconnect
This commit is contained in:
程序员阿江(Relakkes) 2026-04-22 15:53:27 +08:00
parent 49e1367659
commit c4772a097e
2 changed files with 90 additions and 3 deletions

View File

@ -224,4 +224,54 @@ describe('McpSettings', () => {
expect(toggleServer).toHaveBeenCalledWith(server, '/workspace/project')
})
it('shows reconnecting status immediately in the detail view', async () => {
let resolveReconnect: ((value: typeof server) => void) | null = null
const server = {
name: 'plugin:telegram:telegram',
scope: 'dynamic',
transport: 'stdio',
enabled: true,
status: 'failed' as 'connected' | 'needs-auth' | 'failed' | 'disabled' | 'checking',
statusLabel: 'Unavailable',
statusDetail: 'Timed out' as string | undefined,
configLocation: '/tmp/config',
summary: 'bun run start',
canEdit: false,
canRemove: false,
canReconnect: true,
canToggle: true,
config: { type: 'stdio' as const, command: 'bun', args: ['run', 'start'], env: {} },
}
const reconnectServer = vi.fn().mockImplementation(() => new Promise<typeof server>((resolve) => {
resolveReconnect = resolve
}))
useMcpStore.setState({
servers: [server],
reconnectServer,
})
render(<McpSettings />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Open plugin:telegram:telegram' }))
})
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /reconnect/i }))
})
expect(screen.getAllByText('Reconnecting...').length).toBeGreaterThan(0)
expect(reconnectServer).toHaveBeenCalledWith(server, '/workspace/project')
await act(async () => {
resolveReconnect?.({
...server,
status: 'connected',
statusLabel: 'Connected',
statusDetail: undefined,
})
})
})
})

View File

@ -229,6 +229,14 @@ function scopeLabel(server: McpServerRecord, t: ReturnType<typeof useTranslation
return t(`settings.mcp.scope.${group}`)
}
function StatusBadge({ server }: { server: McpServerRecord }) {
return (
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold ${STATUS_TONE[server.status]}`}>
{server.statusLabel}
</span>
)
}
function getServerIdentityKey(server: Pick<McpServerRecord, 'name' | 'scope' | 'projectPath'>) {
if (server.scope === 'local' || server.scope === 'project') {
return `${server.scope}:${server.projectPath ?? ''}:${server.name}`
@ -358,9 +366,7 @@ function ServerRow({
<div className="min-w-0">
<div className="flex items-center gap-3 mb-2 min-w-0">
<div className="text-[1.05rem] font-semibold text-[var(--color-text-primary)] truncate">{server.name}</div>
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold ${STATUS_TONE[server.status]}`}>
{server.statusLabel}
</span>
<StatusBadge server={server} />
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<span className="rounded-full bg-[var(--color-surface-hover)] px-2 py-1 font-medium text-[var(--color-text-secondary)]">
@ -516,7 +522,19 @@ export function McpSettings() {
}
const handleReconnect = async (server: McpServerRecord) => {
const optimistic = {
...server,
status: 'checking' as const,
statusLabel: t('status.reconnecting'),
statusDetail: undefined,
}
setBusyServerName(server.name)
setView((current) => {
if (current.type !== 'details' && current.type !== 'edit') return current
if (getServerIdentityKey(current.server) !== getServerIdentityKey(server)) return current
return { ...current, server: optimistic }
})
try {
const updated = await reconnectServer(server, resolveOperationCwd(server))
addToast({
@ -528,6 +546,11 @@ export function McpSettings() {
if (view.type === 'edit') setView({ type: 'edit', server: updated })
if (view.type === 'details') setView({ type: 'details', server: updated })
} catch (error) {
setView((current) => {
if (current.type !== 'details' && current.type !== 'edit') return current
if (getServerIdentityKey(current.server) !== getServerIdentityKey(server)) return current
return { ...current, server }
})
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('settings.mcp.toast.reconnectFailed'),
@ -669,6 +692,12 @@ export function McpSettings() {
<div>
<h2 className="text-[2.2rem] font-semibold tracking-[-0.03em] text-[var(--color-text-primary)]">{server.name}</h2>
<p className="mt-3 text-base text-[var(--color-text-secondary)]">{server.summary}</p>
<div className="mt-4 flex flex-wrap items-center gap-3">
<StatusBadge server={server} />
{server.statusDetail && (
<span className="text-sm text-[var(--color-text-tertiary)]">{server.statusDetail}</span>
)}
</div>
</div>
{server.canReconnect && (
<Button variant="secondary" onClick={() => handleReconnect(server)} loading={busyServerName === server.name}>
@ -724,6 +753,14 @@ export function McpSettings() {
<p className="mt-3 text-base text-[var(--color-text-secondary)]">
{editing ? t('settings.mcp.form.editHint') : t('settings.mcp.form.createHint')}
</p>
{editing && targetServer && (
<div className="mt-4 flex flex-wrap items-center gap-3">
<StatusBadge server={targetServer} />
{targetServer.statusDetail && (
<span className="text-sm text-[var(--color-text-tertiary)]">{targetServer.statusDetail}</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-3">