// 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 (

{t('connectors.hostedMcpManualRevoke', { defaultValue: 'Your previous {{provider}} account may still have Commonly connected. Switch to it on {{provider}}, then remove Commonly there.', context, provider: manualRevokeNotice.provider, })}{' '} {t('connectors.hostedMcpReviewAuthorization', { defaultValue: "Open {{provider}}'s connected apps", context, provider: manualRevokeNotice.provider, })}

); }; // 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) => (
{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)}

)} {canConnectHosted && entry.connectNotice &&

{entry.connectNotice}

} {isHosted && entry.available && entry.accessPage &&

{renderAccessLink(entry)}

} {renderManualRevokeNotice(entry)} ); }; const renderGithubAppSetup = () => { if (!githubAppSetup) return null; return ( ); }; const renderDraft = () => { if (!draft || !draftEntry) return null; const podSeats = seats[draft.podId] || []; const tools = draftTools(draftEntry, draft.writeMode); const irreversible = irreversibleTools(draftEntry, tools); const asks = draft.writeMode === 'read' ? t('tools.asksNothing', { defaultValue: 'nothing asks first' }) : draft.writeMode === 'write-with-confirm' ? t('tools.asksEveryWrite', { defaultValue: 'every write asks first' }) : (irreversible.length ? t('tools.asksList', { defaultValue: '{{tools}} ask first', tools: joinList(irreversible) }) : t('tools.asksNothing', { defaultValue: 'nothing asks first' })); return ( ); }; const renderAside = (grant: ToolGrant) => { const dead = isDead(grant, now); const podId = grantPodId(grant); const granter = memberName(grant.grantedBy); const revokedBy = memberName(grant.revokedBy); const entry = entryFor(grant); const counts = trail?.counts; return ( ); }; return { renderManualRevokeNotice, renderDisconnect, renderHiddenConnection, renderRow, renderAccessLink, renderEntryActions, renderEntryNote, renderGithubAppSetup, renderDraft, renderAside }; };