diff --git a/apps/desktop/src/components/ui/connector-card.test.tsx b/apps/desktop/src/components/ui/connector-card.test.tsx new file mode 100644 index 0000000000..fe877c73fb --- /dev/null +++ b/apps/desktop/src/components/ui/connector-card.test.tsx @@ -0,0 +1,258 @@ +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 { 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 = {}) { + const onConnect = vi.fn() + const onDismiss = vi.fn() + + render( + + ) + + return { onConnect, onDismiss } +} + +describe('the resting offer', () => { + it('leads with what the thing is, not with a question about it', () => { + renderCard() + + // Scoped to a span: the brand glyph is an carrying its own + // Linear, which is the mark's accessible name rather than + // the card's copy. + 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() + + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onConnect).toHaveBeenCalledOnce() + + fireEvent.click(screen.getByRole('button', { name: 'Not now' })) + expect(onDismiss).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({ phase: 'Installing…' }) + + expect(screen.getByRole('button', { name: 'Not now' }).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 }) + + expect(screen.getByRole('button', { name: 'Connect' }).hasAttribute('disabled')).toBe(true) + expect(screen.getByRole('button', { name: 'Not now' }).hasAttribute('disabled')).toBe(false) + }) +}) + +describe('once it is answered', () => { + it('collapses to a single line reporting what came of it', () => { + renderCard({ outcome: { status: 'connected', tools: ['a', 'b', 'c'] } }) + + 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('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', () => { + const onEnvChange = vi.fn() + + renderCard({ connector: withEnv, envOpen: true, onEnvChange }) + + fireEvent.change(screen.getByLabelText('API key *'), { 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() + }) +}) + +describe('where a mark is read from', () => { + it('prefers the product site over the endpoint it talks to', () => { + expect( + connectorLogoSource({ homepage: 'https://linear.app', name: 'linear', url: 'https://mcp.linear.app/sse' }) + ).toBe('https://linear.app') + }) + + it('falls back to the endpoint, then to the docs', () => { + expect(connectorLogoSource({ name: 'linear', url: 'https://mcp.linear.app/sse' })).toBe('https://mcp.linear.app') + expect(connectorLogoSource({ docs: 'https://docs.stripe.com/x', name: 'stripe' })).toBe('https://docs.stripe.com') + }) + + it('reads the origin, never the path, so an endpoint is not fetched just to draw a logo', () => { + expect(connectorLogoSource({ name: 'acme', url: 'https://acme.test/deep/mcp?token=1' })).toBe('https://acme.test') + }) + + it('refuses a code host, because a bridge published on GitHub is not GitHub', () => { + expect(connectorLogoSource({ name: 'n8n-bridge', url: 'https://github.com/someone/n8n-mcp' })).toBe('') + }) + + it('refuses a private host, which has no logo to find and should not be named aloud', () => { + expect(connectorLogoSource({ name: 'unreal-engine', url: 'http://127.0.0.1:8000/mcp' })).toBe('') + }) + + it('shrugs at something that is not a URL at all', () => { + expect(connectorLogoSource({ docs: 'see the README', name: 'local' })).toBe('') + }) +}) diff --git a/apps/desktop/src/components/ui/connector-card.tsx b/apps/desktop/src/components/ui/connector-card.tsx new file mode 100644 index 0000000000..a7c8cab59f --- /dev/null +++ b/apps/desktop/src/components/ui/connector-card.tsx @@ -0,0 +1,348 @@ +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 { Loader2 } from '@/lib/icons' +import { cn } from '@/lib/utils' + +/** + * The consent card for connecting a thing to Hermes, as pure presentation. + * + * Deliberately knows nothing about MCP. It renders a subject, a state, and an + * outcome, and it calls back — what a connector IS, how one connects, and + * where the strings come from all belong to the caller. That boundary is the + * point: the transcript's inline setup card and any other surface that has to + * ask "connect this?" should look identical without sharing a data layer. + * + * Consent vocabulary follows the tool approval bar: primary-tinted action, + * quiet ghost decline, the same `mt-2` stand-off. + */ + +/** Where the subject stands before anything is attempted. */ +export type ConnectorCardState = 'connected' | 'disabled' | 'needs_auth' | 'not_configured' + +/** How much the source vouches for the subject. */ +export type ConnectorCardTrust = 'catalog' | 'community' | 'verified' + +/** One credential the subject declares it needs. */ +export interface ConnectorCardField { + name: string + prompt?: string + required?: boolean +} + +/** What the card renders. Structural, so a caller's richer type satisfies it + * without this file importing that type. */ +export interface ConnectorCardSubject extends ConnectorLogoSubject { + description?: string + /** Named by the publisher and checked against the serving domain. */ + publisher?: string + requiredEnv?: ConnectorCardField[] + /** Ordered work only the user can do, elsewhere, before this can connect. */ + setup?: string[] + title: string + trust?: ConnectorCardTrust +} + +/** How an attempt ended. */ +export interface ConnectorCardOutcome { + detail?: string + /** The failure was a refusal, not a fault: wrong key, expired grant, or a + * grant that never covered the tools. Asking again is the fix, so the card + * offers access rather than a retry. */ + needsAuth?: boolean + status: 'connected' | 'declined' | 'error' + tools?: unknown[] +} + +/** Every string the card shows. Passed in rather than read from i18n so the + * card stays a leaf that anything can render, tests included. */ +export interface ConnectorCardCopy { + connectAction: 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 +} + +const SHELL_CLASS = `${WIDGET_SHELL_CLASS} text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)` + +const hostOf = (url: null | string | undefined): string => { + if (!url) { + return '' + } + + try { + return new URL(url).hostname + } catch { + return url + } +} + +/** What trails a settled subject's name: how it ended, and the useful number + * or reason behind that. */ +export function outcomeMeta(outcome: ConnectorCardOutcome, copy: ConnectorCardCopy): string { + if (outcome.status === 'connected') { + const toolCount = Array.isArray(outcome.tools) ? outcome.tools.length : 0 + + return toolCount > 0 ? `${copy.stateConnected} · ${copy.toolCount(toolCount)}` : copy.stateConnected + } + + if (outcome.status === 'error') { + return outcome.detail || copy.stateFailed + } + + return copy.stateDeclined +} + +/** + * A subject that no longer needs anything: connected, skipped, failed. + * + * Not a card. An answered offer is transcript scaffolding — the same kind of + * line as a settled tool run — so it renders through `ScaffoldRow` and reads + * like everything else already done. The name keeps full contrast because + * that's the content; the verdict trails it as meta. + */ +export function ConnectorSummary({ + connector, + meta, + tone +}: { + connector: ConnectorLogoSubject + meta?: string + tone?: 'error' +}) { + // The scaffold mark goes on the row, never on a container holding several: + // opacity opens a stacking context and would pin every sibling to one level. + return ( +
+ + + + {connector.title || connector.name} + + {meta ? {meta} : null} + +
+ ) +} + +/** + * How much the source vouches for this subject. + * + * Only the exceptions get a badge. Something we shipped in a reviewed catalog + * is the ordinary case — badging it "reviewed" spends a word on every card to + * say "normal", and a label whose meaning nobody can guess teaches the user to + * ignore the one that matters. + * + * So: nothing for vetted sources. A publisher that proved it owns the serving + * domain says "verified · notion.com" — checkable identity, not an + * endorsement, which is why it names the domain instead of claiming trust. + * Everything else gets the amber "unreviewed" with the host in its tooltip: + * the card doesn't spend a line on an endpoint nobody reads, but for a + * publisher nobody has vouched for, the host is the whole question. + */ +function TrustBadge({ connector, copy }: { connector: ConnectorCardSubject; copy: ConnectorCardCopy }) { + if (!connector.trust || connector.trust === 'catalog') { + return null + } + + if (connector.trust === 'verified') { + // No publisher domain means a curated directory of vendor remotes, which + // is the ordinary case again — nothing to say. + if (!connector.publisher) { + return null + } + + return ( + + {copy.trustVerified(connector.publisher)} + + ) + } + + return ( + + + + {copy.trustCommunity} + + + ) +} + +export interface ConnectorCardProps { + connector: ConnectorCardSubject + copy: ConnectorCardCopy + /** Waved off by the user. Collapses to the same settled line as success. */ + dismissed?: boolean + envDraft?: Record + /** Whether the credential fields are revealed. The caller owns this because + * a refused credential should reveal them without a second click. */ + envOpen?: boolean + onConnect: () => void + onDismiss: () => void + onEnvChange?: (key: string, value: string) => void + /** A sibling card is mid-flight. Two sign-in tabs racing for focus is + * hostile, so the action waits — but the decline never does. */ + otherBusy?: boolean + outcome?: ConnectorCardOutcome + /** Present only while working; replaces the resting state label. */ + phase?: string + state: ConnectorCardState +} + +/** + * One subject's consent card. + * + * Owns everything about that subject and nothing about its siblings: its own + * trust badge, credential fields, failure reason, and its own action. A + * connected card collapses to a single confirmed line, because its offer is + * spent and the space belongs to the ones still asking. + */ +export function ConnectorCard({ + connector, + copy, + dismissed = false, + envDraft = {}, + envOpen = false, + onConnect, + onDismiss, + onEnvChange, + otherBusy = false, + outcome, + phase, + state +}: ConnectorCardProps) { + const working = phase !== undefined + const connected = outcome?.status === 'connected' + const failed = outcome?.status === 'error' + + // Answered: the offer is spent, so the card collapses to a scaffold line and + // gives the space back to whatever is still asking. + if (connected || dismissed) { + return + } + + const stateLabel = state === 'disabled' ? copy.stateDisabled : state === 'needs_auth' ? copy.stateNeedsAuth : null + + // Before the first connect, and again after a failed one. Something that + // connected doesn't need its console instructions repeated — but a failure + // usually happened BECAUSE of that console: an API left un-enabled, the + // wrong client type, a secret copied one character short. Withdrawing the + // steps and the fields at the moment they're finally needed is backwards, + // and it leaves a wrong credential with nowhere to be corrected. + const fixable = state === 'not_configured' || failed + const envFields = fixable ? (connector.requiredEnv ?? []) : [] + const steps = fixable ? (connector.setup ?? []) : [] + + // Logo owns the left rail; everything the card says and every control it + // offers shares the one text column, so the buttons sit on the copy's grid + // line instead of hanging off the card's edge under the mark. + return ( +
+ + +
+
+ {connector.title} + {/* While the card is working its phase replaces the resting state — + "Signing in…" is the one the user needs, because the browser tab + that just took focus is otherwise unexplained. */} + {working ? ( + {phase} + ) : ( + stateLabel && {stateLabel} + )} + +
+ + {connector.description ?

{connector.description}

: null} + + {failed && outcome.detail ?

{outcome.detail}

: null} + + {/* The part we cannot do. Numbered because order matters, linked + because the whole cost of these steps is finding the page. */} + {steps.length > 0 && ( +
    + {steps.map((step, index) => ( +
  1. + {index + 1}. + +
  2. + ))} +
+ )} + + {envOpen && envFields.length > 0 && ( +
+

{copy.envRequired}

+ {envFields.map(env => ( + + ))} +
+ )} + + {/* Same strip as the tool approval bar (tool/approval.tsx), down to its + `mt-2` stand-off: a bordered primary-tinted action plus a quiet + ghost decline. One consent vocabulary across the transcript. */} +
+
+ +
+ {/* Never disabled: while a connect is in flight this is the way out + of a stuck sign-in tab or a hung install. */} + +
+
+
+ ) +} diff --git a/apps/desktop/src/components/ui/connector-logo.tsx b/apps/desktop/src/components/ui/connector-logo.tsx index 50d82c8206..f571f806c0 100644 --- a/apps/desktop/src/components/ui/connector-logo.tsx +++ b/apps/desktop/src/components/ui/connector-logo.tsx @@ -1,40 +1,77 @@ import { brandFor } from '@/lib/mcp-brands' -import { type Connector, connectorLogoSource } from '@/lib/mcp-connectors' import { cn } from '@/lib/utils' import { AvatarChip, monogramFor } from './avatar-chip' import { Favicon } from './favicon' +/** The least a mark needs: something to name it, and somewhere a logo might + * live. Structural on purpose — a caller's richer connector type satisfies + * this without the visual layer knowing that type exists. */ +export interface ConnectorLogoSubject { + docs?: string + homepage?: string + name: string + title?: string + url?: null | string +} + +/** Hosts whose favicon would name the wrong thing: a bridge published on + * GitHub is not GitHub, and a package page is not the product. */ +const NOT_A_LOGO = + /(^|\.)(github\.com|githubusercontent\.com|gitlab\.com|bitbucket\.org|npmjs\.com|pypi\.org|readthedocs\.io)$/ + +const isLoopback = (host: string) => + host === 'localhost' || host === '::1' || /^127\./.test(host) || /^(10|192\.168)\./.test(host) + +/** + * Where to read this subject's mark from. + * + * The product's own site first (the only source that is certainly the right + * logo), then the endpoint it talks to — `mcp.linear.app` is Linear — then + * vendor docs, which for most entries is `docs.stripe.com` and friends. + * Nothing for a private or loopback host: there is no logo out there to find, + * and asking would announce an internal hostname. + */ +export function connectorLogoSource(subject: ConnectorLogoSubject): string { + for (const candidate of [subject.homepage, subject.url, subject.docs]) { + if (!candidate) { + continue + } + + try { + const { hostname, origin } = new URL(candidate) + + if (!NOT_A_LOGO.test(hostname) && !isLoopback(hostname)) { + // The origin, never the path: the icon lives on the site, and this + // way a not-yet-connected endpoint is never fetched just to draw a + // logo. + return origin + } + } catch { + // Not a URL (a bare repo path, a note) — nothing to read a mark from. + } + } + + return '' +} + /** * A connector's mark, resolved as far as it goes. * * Curated brand glyph → the product's own favicon → the monogram every other * unknown name in the app falls back to. The middle rung is what keeps the - * long tail from all looking alike: the catalog is a couple dozen names and - * the registry is thousands, so a connector we ship no icon for still arrives - * wearing its own logo. Which site to read that favicon from is - * `connectorLogoSource`'s call, not this component's. + * long tail from all looking alike: a curated icon set is a couple dozen names + * and a public registry is thousands, so something we ship no icon for still + * arrives wearing its own logo. */ -export function ConnectorLogo({ - className, - connector -}: { - className?: string - // Name and title are the identity; the URLs are what a favicon lookup needs - // and a settled transcript row no longer has. - connector: Partial> & Pick -}) { +export function ConnectorLogo({ className, connector }: { className?: string; connector: ConnectorLogoSubject }) { + const label = connector.title || connector.name const brand = brandFor(connector.name) const site = brand ? '' : connectorLogoSource(connector) return ( - - {site ? : undefined} + + {site ? : undefined} ) }