// The Tools panels: a grant's details and trail, the Add and Change access form,
// the GitHub App setup, and the connection notices. Moved from the old Tools list
// (TASK-733); verbs and copy are unchanged. The rows that opened them are gone.
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { localizeRelativeTime, localizeWindow } from '../utils/localizeRelativeTime';
import { ActGlyph } from '../icons/glyphs';
import { PlatformGlyph } from '../icons/platforms';
import { catalogDescription } from '../utils/connectorCatalogDescription';
import {
shortAge,
isExpired,
isDead,
PER_ENTRY_REVOKE_COPY,
} from './toolModel';
import type {
GrantWriteMode,
ToolGrant,
ToolOutcome,
ToolCatalogEntry,
HiddenToolConnection,
} from './toolModel';
import type { ToolConnectorsModel } from './useToolConnectors';
const G: React.FC<{ children: React.ReactNode }> = ({ children }) => (
);
/** Outcome marks: a category, so a glyph; the word stays in the log line and the title.
*
* The `superseded` mark is a shield with a SHORT BAR, not the shield-and-check it
* first shipped with: the resolution it stands for may be an
* approval, a decline or an expiry, and the row beside it carries the verdict —
* a check read as "approved" next to a sibling that says `refused`. The bar says
* settled without saying how. */
const OutcomeGlyph: React.FC<{ outcome: ToolOutcome }> = ({ outcome }) => {
if (outcome === 'ok') return ;
if (outcome === 'refused') return ;
if (outcome === 'pending_approval') return ;
if (outcome === 'superseded') return ;
return ;
};
/** Mode marks: eye for read, pen for write, shield for write that asks first. */
const ModeGlyph: React.FC<{ mode: GrantWriteMode }> = ({ mode }) => {
if (mode === 'read') return ;
if (mode === 'write') return ;
return ;
};
export const useToolPanels = (m: ToolConnectorsModel) => {
const { t, i18n } = useTranslation();
const {
manualRevokeNotice,
isGranter,
seats,
trail,
trailError,
selectedId,
setSelectedId,
draft,
setDraft,
githubAppSetup,
setGithubAppSetup,
staleAfterChange,
confirmRevoke,
setConfirmRevoke,
confirmDisconnect,
setConfirmDisconnect,
confirmHiddenRemoval,
setConfirmHiddenRemoval,
busy,
error,
joinPolicyFixPodId,
policyFixedPodId,
grants,
now,
isAdmin,
podIds,
entryFor,
noticeMatchesEntry,
toolLabel,
podName,
memberName,
seatLabel,
grantPodId,
joinList,
audienceLabels,
irreversibleTools,
asksFirst,
outcomeLabel,
modeLabel,
entryGrantModes,
entryModeCopy,
usedRecently,
openDraft,
openGithubAppSetup,
draftEntry,
draftTools,
selectedAgentIds,
submitDraft,
fixJoinPolicy,
connectHostedMcp,
submitGithubAppSetup,
retryStaleRevoke,
revoke,
removeHiddenConnection,
disconnect,
} = m;
const renderManualRevokeNotice = (entry: ToolCatalogEntry | null) => {
if (!manualRevokeNotice || !noticeMatchesEntry(entry)) return null;
// Per-entry copy names only what that vendor's page lists; entries without it fall back to the generic line.
const context = PER_ENTRY_REVOKE_COPY.has(manualRevokeNotice.entryId) ? manualRevokeNotice.entryId : undefined;
return (
);
};
// Hosted connections only: a GitHub App connection is the instance's, not the viewer's to drop.
const renderDisconnect = (entry: ToolCatalogEntry | null, variant: 'row' | 'aside') => {
// One connection only: a grant does not say which connection it was minted on,
// so with several the button could drop the wrong one.
const connection = entry && entry.connectionType === 'hosted-mcp' && entry.connections.length === 1 ? entry.connections[0] : null;
if (!entry || !connection) return null;
const cls = variant === 'row'
? 'v2-connector-row__action v2-connector-row__action--secondary'
: 'v2-connector-aside__secondary';
if (confirmDisconnect === connection.connectionId) {
return (
<>
>
);
}
return (
);
};
const renderHiddenConnection = (connection: HiddenToolConnection) => (
{connection.label}{t('tools.existingConnection', { defaultValue: 'existing connection' })}{t('tools.noLongerOffered', { defaultValue: 'This connector is no longer offered here.' })}
{connection.revokePage && {t('tools.reviewProviderAccess', { defaultValue: 'Review access at {{label}}', label: connection.label })}}
{confirmHiddenRemoval === connection.connectionId ? (
<>
>
) : (
)}
);
const renderRow = (grant: ToolGrant) => {
const dead = isDead(grant, now);
const podId = grantPodId(grant);
const granter = memberName(grant.grantedBy);
const isSelected = selectedId === grant.grantId;
const entry = entryFor(grant);
const label = toolLabel(grant);
const noticeGrantId = noticeMatchesEntry(entry)
? (grants || []).find((candidate) => (
!isDead(candidate, now) && entryFor(candidate)?.installableId === entry?.installableId
))?.grantId
: null;
// What the grant was given TO, which is a seat when the target is a seat. It
// is not the row's location: the kicker and the accessible name both name the
// pod the row lives in, so a seat grant under Ops has a sentence reading
// `granted to Reed` in a row announced as `View GitHub in Ops`. Sharing one
// label between the two put the seat's name where the location belongs
// (TASK-179, ux-lead's gate at 07873c19) — two nouns, so two expressions.
const targetLabel = grant.target.kind === 'pod' ? podName(grant.target.id) : seatLabel(podId, grant.target.id);
// Direction A rule 3: `pod · verb age` — the verb from a key, the age from the timestamp.
const when = t('tools.grantedAge', { defaultValue: 'granted {{age}}', age: shortAge(grant.createdAt, now, t) });
const kicker = `${podId ? podName(podId) : seatLabel(null, grant.target.id)} · ${when}`;
const revokedBy = grant.revokedBy ? memberName(grant.revokedBy) : null;
const line2 = dead
? (grant.revokedAt
? (revokedBy
? t('tools.revokedByLine', { defaultValue: 'revoked by {{member}} {{rel}}', member: revokedBy, rel: localizeRelativeTime(grant.revokedAt, t, { now, pastEvent: true }) })
: t('tools.revokedLine', { defaultValue: 'revoked {{rel}}', rel: localizeRelativeTime(grant.revokedAt, t, { now, pastEvent: true }) }))
: t('tools.expiredLine', { defaultValue: 'expired {{rel}}', rel: localizeRelativeTime(grant.expiresAt, t, { now }) }))
// Direction A rule 1: the write mode is the glyph beside this line; its words ride the 390 kicker.
// One interpolated key, not `{{agents}}` + a separate 'may use it': a language
// that orders the clause differently needs the whole sentence (TASK-164). The
// rendered English is unchanged.
// An empty audience is its own sentence, not the interpolated one with an empty
// subject: zh writes 「没有智能体可以使用」 with no space, which `{{agents}} 可以使用`
// cannot produce (TASK-179).
: (grant.effectiveAudience.length === 0
? t('tools.nobodyMayUse', { defaultValue: 'nobody may use it' })
: t('tools.mayUse', { defaultValue: '{{agents}} may use it', agents: audienceLabels(grant) }));
return (
{dead && entry && isGranter(grant) ? (
// Grant again is the granter's too: the mint 403s anyone but the Connection's owner.
) : (
// Direction A rule 2: Manage is housekeeping beside the row's word, so it is the gear.
)}
{noticeGrantId === grant.grantId && renderManualRevokeNotice(entry)}
);
};
// Where the person who connected a hosted entry picks what it reaches (for
// GitHub, the App's install page). Shown on the offer and on the details of
// their live grant, since a grant takes the entry off the offer list.
const renderAccessLink = (entry: ToolCatalogEntry) => (
{t('tools.chooseAccess', { defaultValue: 'Choose what {{label}} can reach', label: entry.label })}
);
// What a person can do with an entry that has no live grant: the verbs of the
// old "Not yet" row, shared by the tile's popover.
const renderEntryActions = (entry: ToolCatalogEntry) => {
const canAdd = entry.available && entry.connections.length > 0 && podIds.length > 0;
const canInstallGithubApp = isAdmin && entry.installableId === 'github' && entry.available && entry.connections.length === 0;
// A hosted entry is connected by the person who will grant it (§1: member
// mode), so the action belongs on the tile and needs no admin.
const isHosted = entry.connectionType === 'hosted-mcp' && Boolean(entry.entryId);
const canConnectHosted = Boolean(isHosted) && entry.available && entry.connections.length === 0;
return (
<>
{!entry.available && (
{t('tools.ask', { defaultValue: 'Ask' })}
)}
{canConnectHosted && (
)}
{canAdd && (
)}
{renderDisconnect(entry, 'row')}
{canInstallGithubApp && (
)}
>
);
};
// The one line under an entry's name that says what is left to do before it
// works: connect your own account, ask an admin, or the modes it grants.
const renderEntryNote = (entry: ToolCatalogEntry) => {
const isHosted = entry.connectionType === 'hosted-mcp' && Boolean(entry.entryId);
const canConnectHosted = Boolean(isHosted) && entry.available && entry.connections.length === 0;
return (
<>
{entry.available && (
{entry.connections.length === 0
? (isHosted
? t('tools.connectOwn', { defaultValue: 'connect your own {{label}} account', label: entry.label })
: t('tools.noConnection', { defaultValue: 'install the GitHub App first · an admin does this once' }))
: entryModeCopy(entry)}