feat(desktop): a connector consent card that knows nothing about connectors

The visual half of asking someone to connect a thing: the mark, the card,
the settled scaffold line it collapses to, and the trust badge.

It renders a subject, a state, and an outcome, and calls back. What a
connector IS, how one connects, and where the strings come from all stay
with the caller — the prop types are structural, so a richer type satisfies
them without this layer importing it. That boundary is the point: any
surface that has to ask "connect this?" should look identical without
sharing a data layer.

MarkdownLinkText comes along because setup steps need inline links.
LinkifiedText can't serve them — it finds bare URLs and guesses a label,
and here the label carries the meaning while the URL is a console page
whose own title is useless or, behind a login wall, actively wrong.
This commit is contained in:
Brooklyn Nicholson
2026-08-20 14:01:53 -05:00
committed by brooklyn!
parent 73d1041000
commit 4a5b6dd451
3 changed files with 664 additions and 21 deletions
@@ -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<ConnectorCardProps> = {}) {
const onConnect = vi.fn()
const onDismiss = vi.fn()
render(
<ConnectorCard
connector={LINEAR}
copy={COPY}
onConnect={onConnect}
onDismiss={onDismiss}
state="not_configured"
{...overrides}
/>
)
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 <svg> carrying its own
// <title>Linear</title>, 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('')
})
})
@@ -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 (
<div data-conversation-scaffold="" data-slot="connector-card">
<ScaffoldRow>
<ConnectorLogo className="size-4 rounded-[0.25rem]" connector={connector} />
<span className="truncate text-[length:var(--conversation-tool-font-size)] text-(--ui-text-primary)">
{connector.title || connector.name}
</span>
{meta ? <span className={cn(SCAFFOLD_META_CLASS, tone === 'error' && 'text-destructive')}>{meta}</span> : null}
</ScaffoldRow>
</div>
)
}
/**
* 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 (
<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 {
connector: ConnectorCardSubject
copy: ConnectorCardCopy
/** Waved off by the user. Collapses to the same settled line as success. */
dismissed?: boolean
envDraft?: Record<string, string>
/** 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 <ConnectorSummary connector={connector} meta={outcomeMeta(outcome ?? { status: 'declined' }, copy)} />
}
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 (
<div className={cn(SHELL_CLASS, 'flex items-start gap-3')} data-slot="connector-card">
<ConnectorLogo connector={connector} />
<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">{connector.title}</span>
{/* 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 ? (
<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}
{failed && outcome.detail ? <p className="text-[0.6875rem] text-destructive">{outcome.detail}</p> : 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 && (
<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>
)}
{/* 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. */}
<div className="mt-2 flex items-center gap-2.5">
<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={working || otherBusy}
onClick={onConnect}
size="xs"
variant="ghost"
>
{working ? (
<Loader2 className="size-3 animate-spin" />
) : outcome?.needsAuth ? (
copy.grantAction
) : failed ? (
copy.retryAction
) : (
copy.connectAction
)}
</Button>
</div>
{/* Never disabled: while a connect is in flight this is the way out
of a stuck sign-in tab or a 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}
</Button>
</div>
</div>
</div>
)
}
@@ -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<Connector, 'docs' | 'homepage' | 'url'>> & Pick<Connector, 'name' | 'title'>
}) {
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 (
<AvatarChip
brand={brand}
className={cn(site && 'overflow-hidden', className)}
name={connector.title || connector.name}
title={connector.title}
>
{site ? <Favicon fallback={monogramFor(connector.title || connector.name)} url={site} /> : undefined}
<AvatarChip brand={brand} className={cn(site && 'overflow-hidden', className)} name={label} title={connector.title}>
{site ? <Favicon fallback={monogramFor(label)} url={site} /> : undefined}
</AvatarChip>
)
}