Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2728,7 +2728,11 @@
"trailError": "Could not read the trail.",
"viewGrant": "View {{tool}} in {{pod}}",
"listSeparator": ", ",
"sentenceSeparator": " "
"sentenceSeparator": " ",
"connectionSeparator": " · ",
"connectionSignedIn": "Signed in",
"connectionKey": "Key",
"disconnectConnectionLabel": "Disconnect {{label}}: {{connection}}"
},
"agentManage": {
"runtime": {
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2731,7 +2731,11 @@
"trailError": "无法读取调用记录。",
"viewGrant": "在 {{pod}} 中查看 {{tool}}",
"listSeparator": "、",
"sentenceSeparator": ""
"sentenceSeparator": "",
"connectionSeparator": " · ",
"connectionSignedIn": "已登录",
"connectionKey": "密钥",
"disconnectConnectionLabel": "断开 {{label}}:{{connection}}"
},
"agentManage": {
"runtime": {
Expand Down
87 changes: 86 additions & 1 deletion frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,7 @@ describe('Connectors page: tools', () => {
expect(within(aside).queryByText(/Revoked by/)).not.toBeInTheDocument();
});

it('two connections of one entry offer no Disconnect, since a grant does not name its connection', async () => {
it('two connections the server does not describe offer no Disconnect, since neither can be named', async () => {
const two = { ...atlassian, connections: [...atlassian.connections, { connectionId: 'conn-atl-2', owner: 'me', repo: 'atlassian' }] };
mockTools({ entries: [two], grants: [] });
renderPage();
Expand All @@ -689,6 +689,91 @@ describe('Connectors page: tools', () => {
expect(screen.queryByRole('button', { name: /Disconnect/ })).not.toBeInTheDocument();
});

// TASK-330: one sign-in row plus operator-key rows bound per agent. The
// server describes each row, so each gets its own named Disconnect.
// Labels carry the viewer's local date, so expected text is computed the
// same way and holds in any time zone the suite runs in.
const day = (at) => new Date(at).toLocaleDateString('en', { month: 'short', day: 'numeric' });
const SIGNED_IN = `Signed in · ${day('2026-10-30T12:00:00Z')}`;
const KEY_SCOUT = `Key · Scout · ${day('2026-10-31T12:00:00Z')}`;
const twoNamed = {
...atlassian,
connections: [
{ connectionId: 'conn-atl', owner: '', repo: '', connectedAt: '2026-10-30T12:00:00Z' },
{ connectionId: 'conn-atl-key', owner: '', repo: '', agentId: scout.userId, connectedAt: '2026-10-31T12:00:00Z' },
],
};

it('two described connections each offer a named Disconnect that drops only that connection', async () => {
mockTools({ entries: [twoNamed], grants: [] });
axios.delete.mockResolvedValue({ data: { message: 'ok' } });
renderPage();

const popover = await openToolPopover('Atlassian');
expect(within(popover).getByText(SIGNED_IN)).toBeInTheDocument();
expect(within(popover).getByText(KEY_SCOUT)).toBeInTheDocument();
fireEvent.click(within(popover).getByRole('button', { name: `Disconnect Atlassian: ${KEY_SCOUT}` }));
expect(within(popover).getAllByRole('button', { name: 'Yes, disconnect' })).toHaveLength(1);
expect(within(popover).getByRole('button', { name: `Disconnect Atlassian: ${SIGNED_IN}` })).toBeInTheDocument();
fireEvent.click(within(popover).getByRole('button', { name: 'Yes, disconnect' }));
await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/integrations/conn-atl-key', expect.any(Object)));
expect(axios.delete).toHaveBeenCalledTimes(1);
});

it('dropping one of two connections leaves the grant to the re-read, not marked revoked in place', async () => {
mockTools({ entries: [twoNamed], grants: [grantAtl] });
axios.delete.mockResolvedValue({ data: { message: 'ok' } });
renderPage();

const { aside } = await openRow('Atlassian', 'View Atlassian in Launch pod');
fireEvent.click(within(aside).getByRole('button', { name: `Disconnect Atlassian: ${SIGNED_IN}` }));
fireEvent.click(within(aside).getByRole('button', { name: 'Yes, disconnect' }));
await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/integrations/conn-atl', expect.any(Object)));
const card = await screen.findByRole('complementary', { name: 'Grant details' });
// A grant does not name its connection, so only the server can say it ended.
expect(within(card).queryByText(/Revoked by/)).not.toBeInTheDocument();
expect(within(card).getByRole('button', { name: 'Revoke' })).toBeInTheDocument();
});

it('a key connection whose agent is not on any roster is named by key and date', async () => {
const unknownAgent = { ...twoNamed, connections: [twoNamed.connections[0], { ...twoNamed.connections[1], agentId: 'someone-else' }] };
mockTools({ entries: [unknownAgent], grants: [] });
renderPage();

const popover = await openToolPopover('Atlassian');
expect(within(popover).getByText(`Key · ${day('2026-10-31T12:00:00Z')}`)).toBeInTheDocument();
});

it('two connections that read the same offer no Disconnect, since neither can be told apart', async () => {
const lookAlike = {
...twoNamed,
connections: [
{ connectionId: 'conn-atl-k1', owner: '', repo: '', agentId: 'off-roster-1', connectedAt: '2026-10-31T12:00:00Z' },
{ connectionId: 'conn-atl-k2', owner: '', repo: '', agentId: 'off-roster-2', connectedAt: '2026-10-31T12:00:00Z' },
],
};
mockTools({ entries: [lookAlike], grants: [] });
renderPage();

const popover = await openToolPopover('Atlassian');
expect(within(popover).getByRole('button', { name: 'Add' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Disconnect/ })).not.toBeInTheDocument();
});

it('a 502 partial removal of one of two connections leaves the grant to the re-read', async () => {
mockTools({ entries: [twoNamed], grants: [grantAtl] });
axios.delete.mockRejectedValue({ response: { status: 502, data: { code: 'provider_revoke_failed', revokeAt: 'https://id.atlassian.com/manage-profile/apps' } } });
renderPage();

const { aside } = await openRow('Atlassian', 'View Atlassian in Launch pod');
fireEvent.click(within(aside).getByRole('button', { name: `Disconnect Atlassian: ${SIGNED_IN}` }));
fireEvent.click(within(aside).getByRole('button', { name: 'Yes, disconnect' }));
expect(await screen.findByRole('link', { name: 'Open provider settings' })).toBeInTheDocument();
const card = await screen.findByRole('complementary', { name: 'Grant details' });
expect(within(card).queryByText(/Revoked by/)).not.toBeInTheDocument();
expect(within(card).getByRole('button', { name: 'Revoke' })).toBeInTheDocument();
});

it('GitHub (the instance App connection) never offers Disconnect', async () => {
renderPage();
await openTool('GitHub');
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2565,6 +2565,11 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(asideIcon).toContain('height: 32px');
expect(asideIcon).toContain('min-height: 32px');
expect(v2).toContain('.v2-root button.v2-connector-aside__secondary.v2-connector-aside__icon { width: 44px; height: 44px; min-height: 44px; }');
// A hosted connection's name sits in the aside's flex row beside its own
// Disconnect. The note class's `margin-top: 12px !important` dropped it
// about 6px below the button's centre, so this label carries no margin.
expect(ruleBody(v2, '.v2-connector-aside__conn')).toContain('font-size: 12px');
expect(ruleBody(v2, '.v2-connector-aside__conn')).not.toContain('margin');
expect(ruleBody(v2, '.v2-connector-row__details')).toContain('padding-right: 8px');
expect(ruleBody(v2, '.v2-page-head__meta')).toContain('var(--v2-font-mono)');
expect(ruleBody(v2, '.v2-connector-row__glyph')).toContain('width: 20px');
Expand Down
65 changes: 57 additions & 8 deletions frontend/src/v2/connectors/ToolPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
ToolOutcome,
ToolCatalogEntry,
HiddenToolConnection,
ToolConnection,
} from './toolModel';
import type { ToolConnectorsModel } from './useToolConnectors';

Expand Down Expand Up @@ -128,12 +129,55 @@ export const useToolPanels = (m: ToolConnectorsModel) => {
</p>
);
};
const agentNameById = (agentId?: string): string => {
if (!agentId) return '';
const seat = Object.values(seats).flat().find((candidate) => candidate.userId === agentId);
return seat?.displayName || seat?.name || '';
};
// Names one hosted connection: a key row by its agent, the sign-in as such,
// and both by when. Null when the server did not describe the row (no
// connectedAt), so it cannot be told apart.
const connectionLabel = (connection: ToolConnection): string | null => {
if (!connection.connectedAt || Number.isNaN(Date.parse(connection.connectedAt))) return null;
const date = new Date(connection.connectedAt).toLocaleDateString(i18n.language, { month: 'short', day: 'numeric' });
const separator = t('tools.connectionSeparator', { defaultValue: ' · ' });
if (connection.agentId) {
return [t('tools.connectionKey', { defaultValue: 'Key' }), agentNameById(connection.agentId), date].filter(Boolean).join(separator);
}
return [t('tools.connectionSignedIn', { defaultValue: 'Signed in' }), date].join(separator);
};
// 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 hosted = entry && entry.connectionType === 'hosted-mcp' ? entry.connections : [];
if (!entry || hosted.length === 0) return null;
if (hosted.length === 1) {
const button = renderDisconnectOne(entry, hosted[0], variant, null);
return variant === 'aside' ? <div className="v2-connector-aside__actions">{button}</div> : button;
}
// Several connections: each is named, so each gets its own Disconnect. A
// connection that cannot be told apart (the server did not describe it, or
// it reads the same as another) gets none rather than one that might drop
// the wrong connection.
const labelled = hosted.map((connection) => ({ connection, label: connectionLabel(connection) }));
const labels = labelled.map((row) => row.label);
if (labels.some((label) => !label) || new Set(labels).size < labels.length) return null;
return (
<>
{labelled.map(({ connection, label }) => (variant === 'aside' ? (
<div key={connection.connectionId} className="v2-connector-aside__actions">
<span className="v2-connector-aside__conn">{label}</span>
{renderDisconnectOne(entry, connection, variant, label)}
</div>
) : (
<span key={connection.connectionId} className="v2-cx-pop__acts">
<span className="v2-cx-note">{label}</span>
{renderDisconnectOne(entry, connection, variant, label)}
</span>
)))}
</>
);
};
const renderDisconnectOne = (entry: ToolCatalogEntry, connection: ToolConnection, variant: 'row' | 'aside', label: string | null) => {
const cls = variant === 'row'
? 'v2-connector-row__action v2-connector-row__action--secondary'
: 'v2-connector-aside__secondary';
Expand All @@ -150,7 +194,14 @@ export const useToolPanels = (m: ToolConnectorsModel) => {
);
}
return (
<button type="button" className={cls} aria-label={t('tools.disconnectLabel', { defaultValue: 'Disconnect {{label}}', label: entry.label })} onClick={() => setConfirmDisconnect(connection.connectionId)}>
<button
type="button"
className={cls}
aria-label={label
? t('tools.disconnectConnectionLabel', { defaultValue: 'Disconnect {{label}}: {{connection}}', label: entry.label, connection: label })
: t('tools.disconnectLabel', { defaultValue: 'Disconnect {{label}}', label: entry.label })}
onClick={() => setConfirmDisconnect(connection.connectionId)}
>
{t('tools.disconnect', { defaultValue: 'Disconnect' })}
</button>
);
Expand Down Expand Up @@ -583,9 +634,7 @@ export const useToolPanels = (m: ToolConnectorsModel) => {
</button>
</div>
))}
{isGranter(grant) && (
<div className="v2-connector-aside__actions">{renderDisconnect(entry, 'aside')}</div>
)}
{isGranter(grant) && renderDisconnect(entry, 'aside')}
{error && <p className="v2-connector-aside__note" role="alert">{error}</p>}
</section>
<section className="v2-connector-aside__card">
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/v2/connectors/toolModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,18 @@ export interface ToolCatalogEntry {
unavailableReason?: string;
broker?: { id: string };
tools: Array<{ name: string; description?: string; requiredWriteMode: GrantWriteMode; irreversible: boolean }>;
connections: Array<{ connectionId: string; owner: string; repo: string }>;
connections: ToolConnection[];
}

/** A hosted row also says when it was made and, for an operator key, its agent,
* so two of them can be told apart. */
export interface ToolConnection {
connectionId: string;
owner: string;
repo: string;
connectedAt?: string;
/** The bot User a key row is bound to; absent on the OAuth sign-in. */
agentId?: string;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

export interface HiddenToolConnection {
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/v2/connectors/useToolConnectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,8 +505,9 @@ export const useToolConnectors = (pods: V2Pod[], manualRevokeNotice: HostedMcpMa
setConfirmDisconnect(null);
setDisconnectedHere((current) => [...current, connectionId]);
// The removal revokes every grant on the connection, so the page says so
// now rather than waiting on a re-read that may fail.
const ended = (grants || []).filter((grant) => !isDead(grant) && entryCovers(entry, grant));
// now rather than waiting on a re-read that may fail. A grant does not say
// which connection it was minted on, so with several only the re-read can.
const ended = entry.connections.length > 1 ? [] : (grants || []).filter((grant) => !isDead(grant) && entryCovers(entry, grant));
setRevokedHere((current) => ({ ...current, ...Object.fromEntries(ended.map((grant) => [grant.grantId, new Date().toISOString()])) }));
setRemovedHiddenNotice({
label: entry.label,
Expand All @@ -523,7 +524,7 @@ export const useToolConnectors = (pods: V2Pod[], manualRevokeNotice: HostedMcpMa
const partial = response?.status === 502
&& (response.data?.code === 'provider_revoke_failed' || response.data?.code === 'provider_revoked_removal_incomplete');
if (partial) {
const ended = (grants || []).filter((grant) => !isDead(grant) && entryCovers(entry, grant));
const ended = entry.connections.length > 1 ? [] : (grants || []).filter((grant) => !isDead(grant) && entryCovers(entry, grant));
setRevokedHere((current) => ({ ...current, ...Object.fromEntries(ended.map((grant) => [grant.grantId, new Date().toISOString()])) }));
setConfirmDisconnect(null);
setDisconnectedHere((current) => [...current, connectionId]);
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/v2/v2.css
Original file line number Diff line number Diff line change
Expand Up @@ -4238,6 +4238,8 @@ body.modern-ui.v2-canvas {
.v2-connector-aside__refusal a { color: var(--v2-accent-text); text-decoration: underline; }

.v2-connector-aside__note { margin-top: 12px !important; color: var(--v2-text-tertiary) !important; font-size: 12px !important; }
/* A hosted connection's name beside its own Disconnect: the note's type, centred on the button. */
.v2-connector-aside__conn { color: var(--v2-text-tertiary); font-size: 12px; }
/* Row C: the refusal the row's own button earned, beside that button in the
aside (and on the row itself) — same bordered grammar as the page slot. */
.v2-connector-aside__refusal { margin-top: 10px !important; padding: 8px 10px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); background: var(--v2-surface-tint); color: var(--v2-ink); font-size: 13px; line-height: 18px; overflow-wrap: anywhere; }
Expand Down
Loading