From ec20cffb35d8b08be84d35aa73be63814e20bd15 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sun, 1 Nov 2026 07:04:47 -0800
Subject: [PATCH 1/4] feat(connectors): one named Disconnect per hosted
connection
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A hosted entry with two or more connections offered no Disconnect, since
nothing told the connections apart. The catalogue now gives each hosted
row its connectedAt and, for an operator key, its agent, so each gets its
own Disconnect, labelled "Signed in · Oct 30" or "Key · Scout · Oct 31".
A connection the server does not describe still offers none.
Dropping one of several connections no longer marks the entry's grants
revoked in place: a grant does not name its connection, so the re-read
decides.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3
---
frontend/src/i18n/locales/en.json | 6 +-
frontend/src/i18n/locales/zh-CN.json | 6 +-
.../__tests__/ConnectorsPage.tools.test.tsx | 52 ++++++++++++++-
frontend/src/v2/connectors/ToolPanels.tsx | 63 ++++++++++++++++---
frontend/src/v2/connectors/toolModel.ts | 13 +++-
.../src/v2/connectors/useToolConnectors.ts | 7 ++-
6 files changed, 132 insertions(+), 15 deletions(-)
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index f6617a62a..5db86b548 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -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": {
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index da78fef33..c238dfeda 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -2731,7 +2731,11 @@
"trailError": "无法读取调用记录。",
"viewGrant": "在 {{pod}} 中查看 {{tool}}",
"listSeparator": "、",
- "sentenceSeparator": ""
+ "sentenceSeparator": "",
+ "connectionSeparator": " · ",
+ "connectionSignedIn": "已登录",
+ "connectionKey": "密钥",
+ "disconnectConnectionLabel": "断开 {{label}}:{{connection}}"
},
"agentManage": {
"runtime": {
diff --git a/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx
index d5fb519d7..490d28560 100644
--- a/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx
+++ b/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx
@@ -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();
@@ -689,6 +689,56 @@ 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.
+ 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 · Oct 30')).toBeInTheDocument();
+ expect(within(popover).getByText('Key · Scout · Oct 31')).toBeInTheDocument();
+ fireEvent.click(within(popover).getByRole('button', { name: 'Disconnect Atlassian: Key · Scout · Oct 31' }));
+ expect(within(popover).getAllByRole('button', { name: 'Yes, disconnect' })).toHaveLength(1);
+ expect(within(popover).getByRole('button', { name: 'Disconnect Atlassian: Signed in · Oct 30' })).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 · Oct 30' }));
+ 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 · Oct 31')).toBeInTheDocument();
+ });
+
it('GitHub (the instance App connection) never offers Disconnect', async () => {
renderPage();
await openTool('GitHub');
diff --git a/frontend/src/v2/connectors/ToolPanels.tsx b/frontend/src/v2/connectors/ToolPanels.tsx
index b3bbb6aec..0dfb37681 100644
--- a/frontend/src/v2/connectors/ToolPanels.tsx
+++ b/frontend/src/v2/connectors/ToolPanels.tsx
@@ -19,6 +19,7 @@ import type {
ToolOutcome,
ToolCatalogEntry,
HiddenToolConnection,
+ ToolConnection,
} from './toolModel';
import type { ToolConnectorsModel } from './useToolConnectors';
@@ -128,12 +129,53 @@ export const useToolPanels = (m: ToolConnectorsModel) => {
);
};
+ 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' ?
{button}
: button;
+ }
+ // Several connections: each is named, so each gets its own Disconnect. A
+ // connection the server did not describe cannot be told apart, so none is
+ // offered rather than one that might drop the wrong connection.
+ const labelled = hosted.map((connection) => ({ connection, label: connectionLabel(connection) }));
+ if (labelled.some((row) => !row.label)) return null;
+ return (
+ <>
+ {labelled.map(({ connection, label }) => (variant === 'aside' ? (
+
}
diff --git a/frontend/src/v2/connectors/toolModel.ts b/frontend/src/v2/connectors/toolModel.ts
index 65c4969ca..b7968c79f 100644
--- a/frontend/src/v2/connectors/toolModel.ts
+++ b/frontend/src/v2/connectors/toolModel.ts
@@ -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;
}
export interface HiddenToolConnection {
diff --git a/frontend/src/v2/connectors/useToolConnectors.ts b/frontend/src/v2/connectors/useToolConnectors.ts
index 55d7cfa1b..e51164820 100644
--- a/frontend/src/v2/connectors/useToolConnectors.ts
+++ b/frontend/src/v2/connectors/useToolConnectors.ts
@@ -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,
@@ -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]);
From 666da9f960d30ef3052df20c2d3da39d2d567011 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sun, 1 Nov 2026 07:11:27 -0800
Subject: [PATCH 2/4] fix(connectors): two connections that read the same get
no Disconnect
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two keys bound the same day for agents not on a loaded roster both read
"Key · Oct 31", so their buttons could not be told apart. Treat a repeated
label like a missing one and offer none. Also pin the partial-502 branch
with two connections: the grant stays live until the re-read.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3
---
.../__tests__/ConnectorsPage.tools.test.tsx | 30 +++++++++++++++++++
frontend/src/v2/connectors/ToolPanels.tsx | 8 +++--
2 files changed, 35 insertions(+), 3 deletions(-)
diff --git a/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx
index 490d28560..27507383d 100644
--- a/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx
+++ b/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx
@@ -739,6 +739,36 @@ describe('Connectors page: tools', () => {
expect(within(popover).getByText('Key · Oct 31')).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-31T09:00:00Z' },
+ { connectionId: 'conn-atl-k2', owner: '', repo: '', agentId: 'off-roster-2', connectedAt: '2026-10-31T15: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 · Oct 30' }));
+ 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');
diff --git a/frontend/src/v2/connectors/ToolPanels.tsx b/frontend/src/v2/connectors/ToolPanels.tsx
index 0dfb37681..f4d937c36 100644
--- a/frontend/src/v2/connectors/ToolPanels.tsx
+++ b/frontend/src/v2/connectors/ToolPanels.tsx
@@ -155,10 +155,12 @@ export const useToolPanels = (m: ToolConnectorsModel) => {
return variant === 'aside' ?
{button}
: button;
}
// Several connections: each is named, so each gets its own Disconnect. A
- // connection the server did not describe cannot be told apart, so none is
- // offered rather than one that might drop the wrong connection.
+ // 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) }));
- if (labelled.some((row) => !row.label)) return null;
+ 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' ? (
From 72ce77c01bb3c9b61872804be47d4cf52959ed6f Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sun, 1 Nov 2026 07:15:38 -0800
Subject: [PATCH 3/4] fix(connectors): centre a hosted connection's name on its
Disconnect
In the grant aside the label reused the note class, whose
margin-top: 12px !important dropped it about 6px below the button's
centre in the flex row. Give it its own class with the note's type and
no margin; guarded by a layout invariant.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3
---
frontend/src/v2/__tests__/v2-layout-invariants.test.ts | 5 +++++
frontend/src/v2/connectors/ToolPanels.tsx | 2 +-
frontend/src/v2/v2.css | 2 ++
3 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index 084c4edce..8b8e4180e 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -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');
diff --git a/frontend/src/v2/connectors/ToolPanels.tsx b/frontend/src/v2/connectors/ToolPanels.tsx
index f4d937c36..457cbbb74 100644
--- a/frontend/src/v2/connectors/ToolPanels.tsx
+++ b/frontend/src/v2/connectors/ToolPanels.tsx
@@ -165,7 +165,7 @@ export const useToolPanels = (m: ToolConnectorsModel) => {
<>
{labelled.map(({ connection, label }) => (variant === 'aside' ? (