diff --git a/docs/plans/connectors-page-signal-diff.md b/docs/plans/connectors-page-signal-diff.md
index a0d4895ed8..3ad899fbd8 100644
--- a/docs/plans/connectors-page-signal-diff.md
+++ b/docs/plans/connectors-page-signal-diff.md
@@ -39,7 +39,7 @@ Rows are one line each. `dot` is the 8px mark before the name; between the dot a
Rules under the table:
-- **{lead}** is `config.leadAgentUsername`; when unset the line reads `attention mode · escalations reach the channel`. Nobody invents an agent name.
+- **{lead}** is `config.leadAgentUsername`; when unset the line reads `attention · escalations reach the channel` (meta wraps to a second line and never ellipsises — lily-shen, 2026-09-05). Nobody invents an agent name.
- **when** uses the row's `createdAt` (exists) with the word *started* for pending rows. `connected {rel}` needs `connectedAt`, which no row carries; until Kai stamps it at the Telegram enable and the Slack Confirm (two writes, same PR or a follow-on), connected rows read `added {rel}` from `createdAt`. `updatedAt` is not used — every relay touches it.
- **Selected row** ground `#f2f4f7`, name weight unchanged (display is already heavy). Default selection: the first row whose action is ink (needs the user), else the first connected row. Manage on an already-selected row deselects nothing; it just keeps the aside on that row.
- **iMessage** is on the artboard as a sample provider. It is not in the provider enum; it does not render.
diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
index e47a467bb8..e6df15c7d0 100644
--- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
+++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
@@ -2,9 +2,9 @@
// Signal Connectors page: rows preserve the Phase 1 verbs while the selected
// channel owns code, confirmation, relay controls, and disconnect in its aside.
import React from 'react';
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
-import V2ConnectorsPage, { installableLifecyclePath } from '../components/V2ConnectorsPage';
+import V2ConnectorsPage, { INSTALL_LOCK_TTL_MS, installableLifecyclePath } from '../components/V2ConnectorsPage';
import { AuthContext } from '../../context/AuthContext';
jest.mock('axios', () => {
@@ -150,7 +150,7 @@ describe('V2ConnectorsPage', () => {
));
});
- it('uses the inline provider and pod form, excluding public pods', async () => {
+ it('uses the inline provider and pod form with every pod the user is in', async () => {
mockGets([]);
axios.post.mockResolvedValue({ data: { integration: { _id: 'new' } } });
renderPage();
@@ -159,7 +159,7 @@ describe('V2ConnectorsPage', () => {
const picker = screen.getByLabelText('Pod to bridge');
const options = Array.from(picker.querySelectorAll('option')).map((option) => option.textContent);
expect(options).toContain('Rewire Live Demo');
- expect(options).not.toContain('Town Square');
+ expect(options).toContain('Town Square');
expect(picker.closest('.v2-connectors__aside')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
@@ -244,8 +244,8 @@ describe('V2ConnectorsPage', () => {
axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } });
const firstRender = renderPage();
- fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }));
- fireEvent.click(screen.getByRole('button', { name: /Really disconnect/ }));
+ fireEvent.click(await screen.findByRole('button', { name: 'Remove' }));
+ fireEvent.click(screen.getByRole('button', { name: /Really remove/ }));
await waitFor(() => expect(axios.delete).toHaveBeenCalledWith(
'/api/installables/slack/install',
expect.anything(),
@@ -255,8 +255,8 @@ describe('V2ConnectorsPage', () => {
mockGets([{ ...connectors[1], installationId: undefined }]);
axios.patch.mockResolvedValue({ data: {} });
renderPage();
- fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }));
- fireEvent.click(screen.getByRole('button', { name: /Really disconnect/ }));
+ fireEvent.click(await screen.findByRole('button', { name: 'Remove' }));
+ fireEvent.click(screen.getByRole('button', { name: /Really remove/ }));
await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
'/api/integrations/i-live',
{ isActive: false },
@@ -269,8 +269,8 @@ describe('V2ConnectorsPage', () => {
axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } });
renderPage();
- fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }));
- fireEvent.click(screen.getByRole('button', { name: /Really disconnect/ }));
+ fireEvent.click(await screen.findByRole('button', { name: 'Remove' }));
+ fireEvent.click(screen.getByRole('button', { name: /Really remove/ }));
await waitFor(() => expect(axios.delete).toHaveBeenCalledWith(
'/api/installables/telegram/install',
expect.anything(),
@@ -403,11 +403,281 @@ describe('V2ConnectorsPage', () => {
{},
expect.objectContaining({ withCredentials: true }),
));
- fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }));
- expect(screen.getByRole('button', { name: /Really disconnect/ })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Remove' }));
+ expect(screen.getByRole('button', { name: /Really remove/ })).toBeInTheDocument();
});
it('derives the installable lifecycle target from the connector row type', () => {
expect(installableLifecyclePath('slack')).toBe('/api/installables/slack/install');
});
+ // D8 Phase 2: rows keyed by the capability catalog (D1/D2), the aside's
+ // gate list (D4), and the not-linked row (#1551).
+ describe('catalog rows', () => {
+ const entry = (over = {}) => ({
+ installableId: 'telegram',
+ label: 'Telegram',
+ description: 'One Telegram chat, one pod.',
+ available: true,
+ installation: null,
+ integration: null,
+ ...over,
+ });
+ const liveIntegration = (over = {}) => ({
+ _id: 'i-live',
+ installationId: 'install-slack-u1',
+ type: 'slack',
+ status: 'connected',
+ scope: 'user',
+ isActive: true,
+ createdAt: new Date(Date.now() - 3_600_000).toISOString(),
+ updatedAt: new Date().toISOString(),
+ config: { chatTitle: 'Rewire crew', liveRelay: true, gates: { p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' } } },
+ podId: 'p1',
+ ...over,
+ });
+ const mockCatalog = (installables, list = []) => {
+ axios.get.mockImplementation((url) => {
+ if (url === '/api/integrations/user/all') return Promise.resolve({ data: list });
+ if (url === '/api/installables') return Promise.resolve({ data: { installables } });
+ if (url === '/api/pods') {
+ return Promise.resolve({
+ data: [
+ { _id: 'p1', name: 'Rewire Live Demo', type: 'chat', members: [{ _id: 'b1', username: 'vale', isBot: true }] },
+ { _id: 'p2', name: 'Ops', type: 'team', members: [] },
+ ],
+ });
+ }
+ return Promise.resolve({ data: [] });
+ });
+ };
+
+ it('renders an unavailable provider without a control and an available one with Connect', async () => {
+ mockCatalog([
+ entry(),
+ entry({ installableId: 'slack', label: 'Slack', available: false, unavailableReason: 'not_configured' }),
+ ]);
+ axios.post.mockResolvedValue({ data: { status: 'installing' } });
+ renderPage();
+
+ expect(await screen.findByText('Not enabled on this instance.')).toBeInTheDocument();
+ expect(screen.getByText('ask your operator')).toBeInTheDocument();
+ expect(screen.getByText('One Telegram chat, one pod.')).toBeInTheDocument();
+ expect(screen.getByText('not connected')).toBeInTheDocument();
+ expect(screen.queryByText('not_configured')).toBeNull();
+ const connects = screen.getAllByRole('button', { name: 'Connect' });
+ expect(connects).toHaveLength(1);
+ fireEvent.click(connects[0]);
+ expect(screen.getByRole('button', { name: 'Telegram' })).toHaveClass('v2-connectors__provider--selected');
+ expect(screen.queryByRole('button', { name: 'Slack' })).toBeNull();
+ fireEvent.click(document.querySelector('.v2-connectors__create'));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/installables/telegram/install',
+ { podId: 'p1' },
+ expect.anything(),
+ ));
+ });
+
+ it('shows Setting up… without a control while the claim is fresh, and Cancel once it is stale', async () => {
+ mockCatalog([entry({ installation: { status: 'installing', claimedAt: new Date().toISOString(), components: [] } })]);
+ const fresh = renderPage();
+ expect(await screen.findByText('Setting up…')).toBeInTheDocument();
+ expect(screen.getByText('waiting for the server')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
+ fresh.unmount();
+
+ mockCatalog([entry({
+ installation: { status: 'installing', claimedAt: new Date(Date.now() - INSTALL_LOCK_TTL_MS - 1000).toISOString(), components: [] },
+ })]);
+ axios.delete.mockResolvedValue({ data: { status: 'uninstalling' } });
+ renderPage();
+ expect(await screen.findByText('Setup is taking longer than it should.')).toBeInTheDocument();
+ fireEvent.click(screen.getAllByRole('button', { name: 'Cancel' })[0]);
+ await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/installables/telegram/install', expect.anything()));
+ });
+
+ it('offers Retry on an error parent, posting the bound pod, and Remove in the aside', async () => {
+ mockCatalog([entry({
+ installation: { status: 'error', errorMessage: 'projection missing', boundPodId: 'p2', updatedAt: new Date().toISOString(), components: [] },
+ })]);
+ axios.post.mockResolvedValue({ data: { status: 'installing' } });
+ axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } });
+ renderPage();
+
+ expect((await screen.findAllByText('projection missing')).length).toBeGreaterThan(0);
+ expect(screen.getByText('retry, or remove it')).toBeInTheDocument();
+ fireEvent.click(screen.getAllByRole('button', { name: 'Retry' })[0]);
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/installables/telegram/install',
+ { podId: 'p2' },
+ expect.anything(),
+ ));
+ fireEvent.click(screen.getByRole('button', { name: 'Remove' }));
+ fireEvent.click(screen.getByRole('button', { name: /Really remove/ }));
+ await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/installables/telegram/install', expect.anything()));
+ });
+
+ it('offers Retry remove once an uninstalling claim is stale', async () => {
+ mockCatalog([entry({
+ installation: { status: 'uninstalling', claimedAt: new Date(Date.now() - INSTALL_LOCK_TTL_MS - 1000).toISOString(), components: [] },
+ })]);
+ axios.delete.mockResolvedValue({ data: { status: 'uninstalling' } });
+ renderPage();
+
+ expect(await screen.findByText('Removal is taking longer than it should.')).toBeInTheDocument();
+ fireEvent.click(screen.getAllByRole('button', { name: 'Retry remove' })[0]);
+ await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/installables/telegram/install', expect.anything()));
+ });
+
+ it('shows a paused parent with its reason and no owner control', async () => {
+ mockCatalog([entry({
+ installableId: 'slack',
+ label: 'Slack',
+ installation: { status: 'paused', updatedAt: new Date().toISOString(), components: [] },
+ integration: liveIntegration({ config: { chatTitle: 'Rewire crew', liveRelay: true, adminPause: { reason: 'Spam report under review.', at: new Date(Date.now() - 120_000).toISOString() } } }),
+ })]);
+ renderPage();
+
+ expect((await screen.findAllByText('Paused by an administrator. Spam report under review.')).length).toBeGreaterThan(0);
+ expect(screen.getByText('paused 2m ago')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Remove' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull();
+ expect(screen.queryByRole('checkbox', { name: 'Relay' })).toBeNull();
+ });
+
+ it('treats a missing projection under an active parent as a Retry row', async () => {
+ mockCatalog([entry({ installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] }, integration: null })]);
+ axios.post.mockResolvedValue({ data: { status: 'installing' } });
+ renderPage();
+
+ expect((await screen.findAllByText('The channel record is gone.')).length).toBeGreaterThan(0);
+ expect(screen.getByText('retry rebuilds it')).toBeInTheDocument();
+ expect(screen.getAllByRole('button', { name: 'Retry' }).length).toBeGreaterThan(0);
+ });
+
+ it('renders the not-linked row and expands the pod list with Make active', async () => {
+ mockCatalog([entry({
+ installableId: 'slack',
+ label: 'Slack',
+ installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] },
+ integration: liveIntegration({ podId: null, config: { chatTitle: 'Rewire crew', liveRelay: true, gates: {} } }),
+ })]);
+ axios.patch.mockResolvedValue({ data: {} });
+ renderPage();
+
+ expect(await screen.findByText('Rewire crew · not linked to a pod')).toBeInTheDocument();
+ expect(screen.getByText('your messages have nowhere to go')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Pick a pod' }));
+ expect(screen.getByText('Pick where your messages go')).toBeInTheDocument();
+ expect(screen.queryByText('active')).toBeNull();
+ const makeActive = screen.getAllByRole('button', { name: 'Make active' });
+ expect(makeActive).toHaveLength(2);
+ fireEvent.click(makeActive[1]);
+ await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
+ '/api/integrations/i-live',
+ { podId: 'p2' },
+ expect.anything(),
+ ));
+ });
+
+ it('lists every pod with its gate, marks the active pod, and writes the whole gates map on a switch', async () => {
+ mockCatalog([entry({
+ installableId: 'slack',
+ label: 'Slack',
+ installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] },
+ integration: liveIntegration(),
+ })]);
+ axios.patch.mockResolvedValue({ data: {} });
+ renderPage();
+
+ expect(await screen.findByText('Rewire crew · linked to Rewire Live Demo')).toBeInTheDocument();
+ expect(screen.getByText('Pods that reach this channel')).toBeInTheDocument();
+ expect(screen.getByText('active')).toBeInTheDocument();
+ expect(screen.getByText('off')).toBeInTheDocument();
+ expect(screen.getByText(/^since /)).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('switch', { name: 'Relay Ops' }));
+ await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
+ '/api/integrations/i-live',
+ { config: { gates: {
+ p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' },
+ p2: { enabled: true, since: expect.any(String) },
+ } } },
+ expect.anything(),
+ ));
+ });
+
+ it('drops a gate for a pod the owner has left before writing the map', async () => {
+ mockCatalog([entry({
+ installableId: 'slack',
+ label: 'Slack',
+ installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] },
+ integration: liveIntegration({
+ config: { chatTitle: 'Rewire crew', liveRelay: true, gates: {
+ p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' },
+ pLeft: { enabled: true, since: '2026-08-01T00:00:00.000Z' },
+ } },
+ }),
+ })]);
+ axios.patch.mockResolvedValue({ data: {} });
+ renderPage();
+
+ await screen.findByText('Pods that reach this channel');
+ fireEvent.click(screen.getByRole('switch', { name: 'Relay Ops' }));
+ await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
+ '/api/integrations/i-live',
+ { config: { gates: {
+ p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' },
+ p2: { enabled: true, since: expect.any(String) },
+ } } },
+ expect.anything(),
+ ));
+ });
+
+ it('keeps mode and lead overrides behind the pod name and makes another pod active', async () => {
+ mockCatalog([entry({
+ installableId: 'slack',
+ label: 'Slack',
+ installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] },
+ integration: liveIntegration(),
+ })]);
+ axios.patch.mockResolvedValue({ data: {} });
+ renderPage();
+
+ await screen.findByText('Pods that reach this channel');
+ expect(screen.queryByRole('button', { name: 'Make active' })).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: /Ops/ }));
+ const more = within(document.querySelector('.v2-connector-gate__more'));
+ fireEvent.click(more.getByRole('button', { name: 'Mirror' }));
+ await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
+ '/api/integrations/i-live',
+ { config: { gates: {
+ p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' },
+ p2: { enabled: false, mode: 'mirror', since: expect.any(String) },
+ } } },
+ expect.anything(),
+ ));
+ const makeActive = screen.getByRole('button', { name: 'Make active' });
+ await waitFor(() => expect(makeActive).not.toBeDisabled());
+ fireEvent.click(makeActive);
+ await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
+ '/api/integrations/i-live',
+ { podId: 'p2' },
+ expect.anything(),
+ ));
+ });
+
+ it('keys an installable row by the catalog, not by its legacy listing', async () => {
+ mockCatalog(
+ [entry({
+ installableId: 'slack',
+ label: 'Slack',
+ installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] },
+ integration: liveIntegration(),
+ })],
+ [{ ...connectors[1] }],
+ );
+ renderPage();
+
+ expect(await screen.findAllByRole('button', { name: 'View Slack' })).toHaveLength(1);
+ });
+ });
});
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index 908690ada1..868574a7f2 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -1361,9 +1361,15 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(ruleBody(v2, '.v2-connector-row__glyph')).toContain('width: 20px');
expect(ruleBody(v2, '.v2-connector-row__glyph')).toContain('color: inherit');
expect(ruleBody(v2, '.v2-connector-row--not-yet .v2-connector-row__glyph')).toContain('color: var(--v2-text-placeholder)');
- expect(ruleBody(v2, '.v2-connector-row__detail')).toContain('white-space: nowrap');
+ expect(ruleBody(v2, '.v2-connector-row__detail')).not.toContain('text-overflow');
+ expect(ruleBody(v2, '.v2-connector-row__detail')).not.toContain('nowrap');
expect(ruleBody(v2, '.v2-connector-row__dot--live, .v2-connector-row__dot--pending')).toContain('var(--v2-accent)');
expect(ruleBody(v2, '.v2-connector-row__dot--idle')).toContain('var(--v2-border-soft)');
+ expect(ruleBody(v2, '.v2-connector-gate__mark')).toContain('width: 4px');
+ expect(ruleBody(v2, '.v2-connector-gate__mark')).toContain('var(--v2-ink)');
+ expect(ruleBody(v2, '.v2-connector-gate__switch')).toContain('accent-color: var(--v2-ink)');
+ expect(ruleBody(v2, '.v2-connector-gate__switch')).not.toContain('border-radius');
+ expect(ruleBody(v2, '.v2-connector-gate__tag')).toContain('var(--v2-font-mono)');
const connectorCss = v2.slice(v2.indexOf('/* ── Connectors'), v2.indexOf('/* Activity queue'));
expect(connectorCss).not.toContain('var(--v2-success)');
expect(connectorCss).not.toContain('var(--v2-warning)');
diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx
index af1c40395d..5858674350 100644
--- a/frontend/src/v2/components/V2ConnectorsPage.tsx
+++ b/frontend/src/v2/components/V2ConnectorsPage.tsx
@@ -1,18 +1,32 @@
// Connectors — the Signal Coverage artboard's channel list. It deliberately
-// renders only controls the Phase 1 connector service already enforces.
+// renders only controls the connector service already enforces: rows are keyed
+// by the server's capability catalog (D8 Phase 2 D1/D2), every action is an
+// existing lifecycle verb, and the aside's gate list writes only what the
+// owner-only PATCH accepts (D3/D4).
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useV2Api } from '../hooks/useV2Api';
-import { V2Pod } from '../hooks/useV2Pods';
+import { V2Pod, V2PodMember } from '../hooks/useV2Pods';
import { PlatformGlyph } from '../icons/platforms';
+interface ConnectorGate {
+ enabled?: boolean;
+ mode?: 'attention' | 'mirror';
+ lead?: string;
+ since?: string;
+}
+
interface ConnectorConfig {
chatTitle?: string;
connectCode?: string;
connectCodeExpiresAt?: string;
liveRelay?: boolean;
relayAllAgentMessages?: boolean;
+ leadAgentUsername?: string;
+ gates?: Record;
+ // Projected by the server as { reason, at } only — the admin is never named.
+ adminPause?: { reason?: string; at?: string };
pendingBind?: {
teamName?: string;
slackUserName?: string;
@@ -25,12 +39,37 @@ interface Connector {
installationId?: string;
type: string;
status: string;
+ scope?: 'user' | 'pod';
+ isActive?: boolean;
createdAt?: string;
updatedAt?: string;
config?: ConnectorConfig;
podId?: { _id: string; name?: string } | string | null;
}
+interface CatalogInstallation {
+ status: string;
+ errorMessage?: string;
+ boundPodId?: string;
+ claimedAt?: string;
+ updatedAt?: string;
+ components?: { name?: string; status?: string; errorMessage?: string }[];
+}
+
+interface CatalogEntry {
+ installableId: string;
+ label?: string;
+ description?: string;
+ available: boolean;
+ unavailableReason?: string;
+ installation: CatalogInstallation | null;
+ integration: Connector | null;
+}
+
+interface CatalogResponse {
+ installables?: CatalogEntry[];
+}
+
interface InstallResponse {
status?: 'active' | 'installing' | 'activating' | 'uninstalling';
boundPodId?: string;
@@ -45,18 +84,28 @@ interface SlackAuthorizeResponse {
authorizeUrl?: string;
}
-type ConnectorAction = 'manage' | 'show-code' | 'new-code' | 'authorize' | 'confirm';
+type ConnectorAction =
+ | 'manage' | 'show-code' | 'new-code' | 'authorize' | 'confirm'
+ | 'connect' | 'cancel' | 'retry' | 'retry-remove' | 'pick-pod';
interface ConnectorRow {
action: ConnectorAction | null;
actionLabel?: string;
detail: string;
- dot: 'live' | 'idle' | 'pending' | 'empty';
+ dot: 'live' | 'idle' | 'pending' | 'empty' | 'not-yet';
line: string;
+ muted?: boolean;
pulse: boolean;
+ secondary?: boolean;
when: string;
}
+// One list, two sources: the catalog keys a row per provider the instance
+// knows about; legacy pod-scoped rows (no installation) render as before.
+type ListItem =
+ | { kind: 'catalog'; key: string; entry: CatalogEntry; connector: Connector | null }
+ | { kind: 'legacy'; key: string; connector: Connector };
+
const TYPE_LABELS: Record = {
telegram: 'Telegram',
discord: 'Discord',
@@ -68,8 +117,8 @@ const TYPE_LABELS: Record = {
instagram: 'Instagram',
};
-// The catalog route from TASK-009 will make readiness server-owned. Until it
-// lands, this is intentionally limited to the two providers this page can use.
+// Fallback only: when the catalog cannot be read the page still offers the
+// two providers it can drive, exactly as it did before the catalog existed.
const ADD_PLATFORMS = [
{ type: 'telegram', enabled: true },
{ type: 'slack', enabled: true },
@@ -78,6 +127,10 @@ const UNAVAILABLE_PLATFORM_LABELS = ['Discord', 'WhatsApp'];
const BOT_HANDLE = process.env.REACT_APP_TELEGRAM_BOT_HANDLE || '';
const RECENT_MS = 10 * 60_000;
+// Mirrors INSTALL_LOCK_TTL_MS on the server: Cancel appears exactly when the
+// lifecycle verb would honour it (a stale claim), never before.
+export const INSTALL_LOCK_TTL_MS = 60_000;
+const TRANSIENT_STATUSES = ['installing', 'activating', 'uninstalling'];
export const installableLifecyclePath = (type: string): string => (
`/api/installables/${encodeURIComponent(type)}/install`
@@ -87,17 +140,9 @@ const installErrorResponse = (error: unknown): { status?: number; data?: Install
(error as { response?: { status?: number; data?: InstallErrorResponse } })?.response || {}
);
-const podName = (connector: Connector): string => (
- typeof connector.podId === 'object' && connector.podId ? (connector.podId.name || 'Untitled pod') : 'Untitled pod'
-);
-
-const boundPodName = (boundPodId: string | undefined, connectors: Connector[], pods: V2Pod[]): string => {
- const existing = connectors.find((connector) => {
- const podId = typeof connector.podId === 'object' ? connector.podId?._id : connector.podId;
- return String(podId) === String(boundPodId);
- });
- const boundPod = pods.find((pod) => String(pod._id) === String(boundPodId));
- return existing ? podName(existing) : (boundPod?.name || 'another pod');
+const connectorPodId = (connector: Connector | null | undefined): string | null => {
+ if (!connector || !connector.podId) return null;
+ return typeof connector.podId === 'object' ? String(connector.podId._id || '') || null : String(connector.podId);
};
const groupCode = (code: string): string => (code.match(/.{1,4}/g) || [code]).join(' ');
@@ -119,18 +164,38 @@ const relativeTime = (date?: string): string => {
return `${Math.floor(hours / 24)}d ago`;
};
+const claimIsStale = (installation: CatalogInstallation): boolean => {
+ const claimedAt = installation.claimedAt ? new Date(installation.claimedAt).getTime() : NaN;
+ if (!Number.isFinite(claimedAt)) return true;
+ return Date.now() - claimedAt >= INSTALL_LOCK_TTL_MS;
+};
+
+const projectionMissing = (entry: CatalogEntry): boolean => (
+ !entry.integration
+ || entry.integration.isActive === false
+ || (entry.installation?.components || []).some((component) => component.status === 'stale')
+);
+
+const botMembers = (pod: V2Pod | undefined): V2PodMember[] => (
+ (pod?.members || []).filter((member): member is V2PodMember => (
+ typeof member === 'object' && member !== null && Boolean(member.isBot) && Boolean(member.username)
+ ))
+);
+
const V2ConnectorsPage: React.FC = () => {
const { t } = useTranslation();
const api = useV2Api();
const [connectors, setConnectors] = useState([]);
+ const [catalog, setCatalog] = useState(null);
const [pods, setPods] = useState([]);
const [newPodId, setNewPodId] = useState('');
const [addingType, setAddingType] = useState(null);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState(null);
const [creating, setCreating] = useState(false);
- const [selectedId, setSelectedId] = useState(null);
- const [confirmDisconnect, setConfirmDisconnect] = useState(null);
+ const [selectedKey, setSelectedKey] = useState(null);
+ const [confirmRemove, setConfirmRemove] = useState(null);
+ const [expandedGate, setExpandedGate] = useState(null);
const [copied, setCopied] = useState(null);
const [error, setError] = useState(null);
const [slackCallbackError, setSlackCallbackError] = useState(null);
@@ -139,8 +204,14 @@ const V2ConnectorsPage: React.FC = () => {
const load = useCallback(async () => {
try {
- const data = await api.get('/api/integrations/user/all');
- setConnectors(Array.isArray(data) ? data : []);
+ const [rows, catalogResponse] = await Promise.all([
+ api.get('/api/integrations/user/all'),
+ // The catalog is the page's authority for provider rows; if it cannot
+ // be read the legacy list still renders and the picker falls back.
+ api.get('/api/installables').catch(() => null),
+ ]);
+ setConnectors(Array.isArray(rows) ? rows : []);
+ setCatalog(catalogResponse && Array.isArray(catalogResponse.installables) ? catalogResponse.installables : null);
setError(null);
} catch {
setError(t('connectors.loadError', { defaultValue: 'Could not load connectors.' }));
@@ -177,6 +248,8 @@ const V2ConnectorsPage: React.FC = () => {
const hasPending = connectors.some((connector) => connector.status !== 'connected' && (
(connector.type === 'telegram' && codeIsLive(connector))
|| (connector.type === 'slack' && (codeIsLive(connector) || Boolean(connector.config?.pendingBind)))
+ )) || (catalog || []).some((entry) => (
+ entry.installation ? TRANSIENT_STATUSES.includes(entry.installation.status) : false
));
useEffect(() => {
if (hasPending && !pollRef.current) pollRef.current = setInterval(load, 3000);
@@ -196,67 +269,102 @@ const V2ConnectorsPage: React.FC = () => {
let cancelled = false;
void (async () => {
try {
+ // Membership is the only rule, and it is the server's: every pod that
+ // lists the user is one the install verb and a gate key would accept.
const data = await api.get('/api/pods');
- const eligible = (Array.isArray(data) ? data : []).filter(
- (pod) => !['community', 'showcase'].includes((pod as { type?: string }).type || ''),
- );
+ const eligible = Array.isArray(data) ? data : [];
if (!cancelled) {
setPods(eligible);
setNewPodId((current) => current || eligible[0]?._id || '');
}
} catch {
- // The picker stays empty when no eligible pod can be read.
+ // The picker stays empty when no pod can be read.
}
})();
return () => { cancelled = true; };
}, [api]);
- const createConnector = async () => {
- if (!newPodId || !addingType || creating) return;
- const type = addingType;
+ const podNameById = (podId: string | null | undefined, fallback?: Connector | null): string => {
+ const pod = pods.find((candidate) => String(candidate._id) === String(podId || ''));
+ if (pod) return pod.name || 'Untitled pod';
+ if (fallback && typeof fallback.podId === 'object' && fallback.podId?.name) return fallback.podId.name;
+ return 'Untitled pod';
+ };
+
+ const boundPodName = (boundPodId: string | undefined): string => {
+ const existing = connectors.find((connector) => connectorPodId(connector) === String(boundPodId));
+ const boundPod = pods.find((pod) => String(pod._id) === String(boundPodId));
+ return existing ? podNameById(connectorPodId(existing), existing) : (boundPod?.name || 'another pod');
+ };
+
+ const installInProgressMessage = (boundPodId?: string): string => (
+ boundPodId
+ ? t('connectors.installInProgressForPod', {
+ defaultValue: 'Still setting up for {{pod}} — try again in a moment.',
+ pod: boundPodName(boundPodId),
+ })
+ : t('connectors.installInProgress', { defaultValue: 'Still setting up — try again in a moment.' })
+ );
+
+ const install = async (type: string, podId: string): Promise => {
const typeLabel = TYPE_LABELS[type] || type;
- const installInProgressMessage = (boundPodId?: string): string => (
- boundPodId
- ? t('connectors.installInProgressForPod', {
- defaultValue: 'Still setting up for {{pod}} — try again in a moment.',
- pod: boundPodName(boundPodId, connectors, pods),
- })
- : t('connectors.installInProgress', { defaultValue: 'Still setting up — try again in a moment.' })
- );
- setCreating(true);
- setError(null);
try {
- const result = await api.post(installableLifecyclePath(type), { podId: newPodId });
+ const result = await api.post(installableLifecyclePath(type), { podId });
+ // Reload before reporting: `load` clears the error slot on success, and
+ // the row's new state is what the message refers to.
+ await load();
if (result.status && result.status !== 'active') {
setError(installInProgressMessage(result.boundPodId));
- return;
+ return false;
}
- setAddingType(null);
- await load();
+ return true;
} catch (requestError) {
const response = installErrorResponse(requestError);
+ let message: string;
if (response.status === 409 && response.data?.code === 'install_in_progress') {
- setError(installInProgressMessage(response.data.boundPodId));
+ message = installInProgressMessage(response.data.boundPodId);
} else if (response.status === 409 && response.data?.code === 'already_installed') {
- setError(t('connectors.alreadyBound', {
- defaultValue: 'Your {{connector}} channel is bound to {{pod}}. Disconnect it to bind a different pod.',
+ message = t('connectors.alreadyBound', {
+ defaultValue: 'Your {{connector}} channel is bound to {{pod}}. Remove it to bind a different pod.',
+ connector: typeLabel,
+ pod: boundPodName(response.data.boundPodId),
+ });
+ } else if (response.status === 409 && response.data?.code === 'installation_paused') {
+ message = t('connectors.pausedRefusal', {
+ defaultValue: 'An administrator paused this connector. Ask your operator.',
+ });
+ } else if (response.status === 422 && response.data?.code === 'provider_not_configured') {
+ message = t('connectors.providerNotConfigured', {
+ defaultValue: '{{connector}} is not enabled on this instance. Ask your operator.',
connector: typeLabel,
- pod: boundPodName(response.data.boundPodId, connectors, pods),
- }));
+ });
} else {
- setError(t('connectors.createError', { defaultValue: 'Could not create the connector.' }));
+ message = t('connectors.createError', { defaultValue: 'Could not create the connector.' });
}
+ await load();
+ setError(message);
+ return false;
+ }
+ };
+
+ const createConnector = async () => {
+ if (!newPodId || !addingType || creating) return;
+ setCreating(true);
+ setError(null);
+ try {
+ const installed = await install(addingType, newPodId);
+ if (installed) setAddingType(null);
} finally {
setCreating(false);
}
};
- const patchConfig = async (connector: Connector, config: Record, errorKey: string, errorDefault: string) => {
+ const patchIntegration = async (connector: Connector, body: Record, errorKey: string, errorDefault: string) => {
if (busyId) return;
setBusyId(connector._id);
setError(null);
try {
- await api.patch(`/api/integrations/${connector._id}`, { config });
+ await api.patch(`/api/integrations/${connector._id}`, body);
await load();
} catch {
setError(t(errorKey, { defaultValue: errorDefault }));
@@ -265,6 +373,42 @@ const V2ConnectorsPage: React.FC = () => {
}
};
+ const patchConfig = (connector: Connector, config: Record, errorKey: string, errorDefault: string) => (
+ patchIntegration(connector, { config }, errorKey, errorDefault)
+ );
+
+ // The PATCH merges `config` one level deep, so `gates` is written whole:
+ // every pod's gate travels with the one that changed, `since` included.
+ // The server validates every key against membership and refuses the whole
+ // body on the first miss, so a gate for a pod the owner has since left
+ // (not yet pruned by the reconciler) is dropped here rather than sent —
+ // otherwise every toggle would 403 for up to one sweep (Vera, 63997).
+ const writeGate = (connector: Connector, podId: string, next: ConnectorGate) => {
+ const current = connector.config?.gates || {};
+ const memberOf = new Set(pods.map((pod) => String(pod._id)));
+ const gates: Record = {};
+ Object.entries(current).forEach(([key, gate]) => {
+ if (!memberOf.has(String(key))) return;
+ gates[key] = {
+ enabled: gate.enabled === true,
+ ...(gate.mode ? { mode: gate.mode } : {}),
+ ...(gate.lead ? { lead: gate.lead } : {}),
+ since: gate.since || new Date().toISOString(),
+ };
+ });
+ gates[podId] = {
+ enabled: next.enabled === true,
+ ...(next.mode ? { mode: next.mode } : {}),
+ ...(next.lead ? { lead: next.lead } : {}),
+ since: current[podId]?.since || new Date().toISOString(),
+ };
+ return patchConfig(connector, { gates }, 'connectors.gateError', 'Could not update that pod.');
+ };
+
+ const makeActive = (connector: Connector, podId: string) => (
+ patchIntegration(connector, { podId }, 'connectors.activePodError', 'Could not change the active pod.')
+ );
+
const regenerateCode = async (connector: Connector) => {
if (busyId) return;
setBusyId(connector._id);
@@ -323,7 +467,35 @@ const V2ConnectorsPage: React.FC = () => {
}
};
- const disconnect = async (connector: Connector) => {
+ // DELETE on the lifecycle verb serves Remove, Cancel and Retry remove alike:
+ // the service decides what a DELETE means from the parent's state.
+ const uninstall = async (type: string, busyKey: string, errorKey: string, errorDefault: string) => {
+ if (busyId) return;
+ setBusyId(busyKey);
+ setError(null);
+ try {
+ await api.del(installableLifecyclePath(type));
+ setConfirmRemove(null);
+ } catch (requestError) {
+ const response = installErrorResponse(requestError);
+ // A live claim refuses Cancel; the row simply reads "Setting up…" again.
+ if (!(response.status === 409 && response.data?.code === 'install_in_progress')) {
+ setError(t(errorKey, { defaultValue: errorDefault }));
+ }
+ } finally {
+ setBusyId(null);
+ await load();
+ }
+ };
+
+ const remove = async (item: ListItem) => {
+ const connector = item.connector;
+ if (item.kind === 'catalog') {
+ await uninstall(item.entry.installableId, item.key, 'connectors.removeError', 'Could not remove the channel.');
+ setSelectedKey(null);
+ return;
+ }
+ if (!connector) return;
setBusyId(connector._id);
try {
if (connector.installationId) {
@@ -331,11 +503,27 @@ const V2ConnectorsPage: React.FC = () => {
} else {
await api.patch(`/api/integrations/${connector._id}`, { isActive: false });
}
- setConfirmDisconnect(null);
- setSelectedId(null);
+ setConfirmRemove(null);
+ setSelectedKey(null);
await load();
} catch {
- setError(t('connectors.disconnectError', { defaultValue: 'Could not disconnect.' }));
+ setError(t('connectors.removeError', { defaultValue: 'Could not remove the channel.' }));
+ } finally {
+ setBusyId(null);
+ }
+ };
+
+ const retry = async (entry: CatalogEntry) => {
+ if (busyId) return;
+ const podId = entry.installation?.boundPodId || connectorPodId(entry.integration) || newPodId;
+ if (!podId) {
+ setAddingType(entry.installableId);
+ return;
+ }
+ setBusyId(entry.installableId);
+ setError(null);
+ try {
+ await install(entry.installableId, podId);
} finally {
setBusyId(null);
}
@@ -356,6 +544,7 @@ const V2ConnectorsPage: React.FC = () => {
const started = `started ${relativeTime(connector.createdAt)}`;
const isTelegram = connector.type === 'telegram';
const isSlack = connector.type === 'slack';
+ const title = connector.config?.chatTitle || TYPE_LABELS[connector.type] || connector.type;
if (connector.status === 'error') {
return {
@@ -370,6 +559,20 @@ const V2ConnectorsPage: React.FC = () => {
}
if (connector.status === 'connected') {
+ const activePodId = connectorPodId(connector);
+ if (!activePodId) {
+ // The owner left the active pod and the prune cleared it (D3). Their
+ // typed messages have nowhere to go until they pick a pod in the aside.
+ return {
+ action: 'pick-pod',
+ actionLabel: t('connectors.pickPod', { defaultValue: 'Pick a pod' }),
+ detail: t('connectors.notLinkedDetail', { defaultValue: 'your messages have nowhere to go' }),
+ dot: 'idle',
+ line: t('connectors.notLinkedLine', { defaultValue: '{{title}} · not linked to a pod', title }),
+ pulse: false,
+ when: `added ${relativeTime(connector.createdAt)}`,
+ };
+ }
const relay = Boolean(connector.config?.liveRelay);
const mirror = connector.config?.relayAllAgentMessages === true;
const recent = connector.updatedAt && (Date.now() - new Date(connector.updatedAt).getTime()) < RECENT_MS;
@@ -379,11 +582,12 @@ const V2ConnectorsPage: React.FC = () => {
detail: relay
? (mirror
? t('connectors.rowMirror', { defaultValue: 'every agent line reaches the channel' })
- : t('connectors.rowAttention', { defaultValue: 'attention mode · escalations reach the channel' }))
+ : t('connectors.rowAttention', { defaultValue: 'attention · escalations reach the channel' }))
: t('connectors.rowRelayOff', { defaultValue: 'relay off · messages stay in the pod' }),
dot: relay ? 'live' : 'idle',
- line: `${connector.config?.chatTitle || TYPE_LABELS[connector.type] || connector.type} · linked to ${podName(connector)}`,
+ line: `${title} · linked to ${podNameById(activePodId, connector)}`,
pulse: relay && Boolean(recent),
+ secondary: true,
when: `added ${relativeTime(connector.createdAt)}`,
};
}
@@ -448,44 +652,222 @@ const V2ConnectorsPage: React.FC = () => {
};
};
- const selectedConnector = connectors.find((connector) => connector._id === selectedId)
- || connectors.find((connector) => {
- const action = rowFor(connector).action;
- return action !== null && action !== 'manage';
- })
- || connectors[0]
+ // D2: the catalog entry and its parent decide the row; the Integration row
+ // only takes over once the parent is `active` and its projection is whole.
+ const rowForEntry = (entry: CatalogEntry, connector: Connector | null): ConnectorRow => {
+ const label = entry.label || TYPE_LABELS[entry.installableId] || entry.installableId;
+ const installation = entry.installation;
+ if (!entry.available) {
+ return {
+ action: null,
+ detail: t('connectors.askOperator', { defaultValue: 'ask your operator' }),
+ dot: 'not-yet',
+ line: t('connectors.notEnabled', { defaultValue: 'Not enabled on this instance.' }),
+ muted: true,
+ pulse: false,
+ when: '—',
+ };
+ }
+ if (!installation) {
+ return {
+ action: 'connect',
+ actionLabel: t('connectors.connect', { defaultValue: 'Connect' }),
+ detail: entry.installableId === 'telegram'
+ ? t('connectors.availableTelegram', { defaultValue: 'one message' })
+ : t('connectors.availableSlack', { defaultValue: 'one click in your workspace' }),
+ dot: 'empty',
+ line: entry.description || t('connectors.availableLine', { defaultValue: 'Connect {{label}} to a pod.', label }),
+ pulse: false,
+ when: t('connectors.notConnected', { defaultValue: 'not connected' }),
+ };
+ }
+ const since = `since ${relativeTime(installation.updatedAt)}`;
+ const started = `started ${relativeTime(installation.claimedAt || installation.updatedAt)}`;
+ const stale = claimIsStale(installation);
+ if (installation.status === 'installing' || installation.status === 'activating') {
+ return stale
+ ? {
+ action: 'cancel',
+ actionLabel: t('connectors.cancel', { defaultValue: 'Cancel' }),
+ detail: t('connectors.serverLetGo', { defaultValue: 'the server let go of it' }),
+ dot: 'pending',
+ line: t('connectors.setupSlow', { defaultValue: 'Setup is taking longer than it should.' }),
+ pulse: true,
+ secondary: true,
+ when: started,
+ }
+ : {
+ action: null,
+ detail: t('connectors.waitingServer', { defaultValue: 'waiting for the server' }),
+ dot: 'pending',
+ line: t('connectors.settingUp', { defaultValue: 'Setting up…' }),
+ pulse: true,
+ when: started,
+ };
+ }
+ if (installation.status === 'uninstalling') {
+ return stale
+ ? {
+ action: 'retry-remove',
+ actionLabel: t('connectors.retryRemove', { defaultValue: 'Retry remove' }),
+ detail: t('connectors.serverLetGo', { defaultValue: 'the server let go of it' }),
+ dot: 'pending',
+ line: t('connectors.removeSlow', { defaultValue: 'Removal is taking longer than it should.' }),
+ pulse: true,
+ secondary: true,
+ when: started,
+ }
+ : {
+ action: null,
+ detail: t('connectors.waitingServer', { defaultValue: 'waiting for the server' }),
+ dot: 'pending',
+ line: t('connectors.removing', { defaultValue: 'Removing…' }),
+ pulse: true,
+ when: started,
+ };
+ }
+ if (installation.status === 'error') {
+ return {
+ action: 'retry',
+ actionLabel: t('connectors.retry', { defaultValue: 'Retry' }),
+ detail: t('connectors.retryOrRemove', { defaultValue: 'retry, or remove it' }),
+ dot: 'empty',
+ line: installation.errorMessage || t('connectors.setupFailed', { defaultValue: 'Setup didn’t finish.' }),
+ pulse: false,
+ when: since,
+ };
+ }
+ if (installation.status === 'paused') {
+ const pause = connector?.config?.adminPause;
+ const reason = pause?.reason ? ` ${pause.reason}` : '';
+ return {
+ action: null,
+ detail: t('connectors.askOperator', { defaultValue: 'ask your operator' }),
+ dot: 'empty',
+ line: `${t('connectors.pausedLine', { defaultValue: 'Paused by an administrator.' })}${reason}`,
+ pulse: false,
+ when: `paused ${relativeTime(pause?.at || installation.updatedAt)}`,
+ };
+ }
+ if (installation.status === 'stale') {
+ return {
+ action: null,
+ detail: t('connectors.notRelaying', { defaultValue: 'not relaying' }),
+ dot: 'empty',
+ line: t('connectors.pausedShort', { defaultValue: 'Paused.' }),
+ pulse: false,
+ when: since,
+ };
+ }
+ if (projectionMissing(entry) || !connector) {
+ return {
+ action: 'retry',
+ actionLabel: t('connectors.retry', { defaultValue: 'Retry' }),
+ detail: t('connectors.retryRebuilds', { defaultValue: 'retry rebuilds it' }),
+ dot: 'empty',
+ line: t('connectors.recordGone', { defaultValue: 'The channel record is gone.' }),
+ pulse: false,
+ when: since,
+ };
+ }
+ return rowFor(connector);
+ };
+
+ const connectorForEntry = (entry: CatalogEntry): Connector | null => {
+ if (!entry.integration) return null;
+ const podId = connectorPodId(entry.integration);
+ return {
+ ...entry.integration,
+ type: entry.integration.type || entry.installableId,
+ installationId: entry.integration.installationId || 'catalog',
+ podId: podId ? { _id: podId, name: podNameById(podId, entry.integration) } : null,
+ };
+ };
+
+ const catalogTypes = new Set((catalog || []).map((entry) => entry.installableId));
+ const items: ListItem[] = [
+ ...(catalog || []).map((entry): ListItem => ({
+ kind: 'catalog',
+ key: `catalog:${entry.installableId}`,
+ entry,
+ connector: connectorForEntry(entry),
+ })),
+ // Rows the catalog does not describe: legacy pod-scoped connectors, and
+ // installable rows only while the catalog itself cannot be read.
+ ...connectors
+ .filter((connector) => !(connector.installationId && catalogTypes.has(connector.type)))
+ .map((connector): ListItem => ({ kind: 'legacy', key: `legacy:${connector._id}`, connector })),
+ ];
+
+ const rowForItem = (item: ListItem): ConnectorRow => (
+ item.kind === 'catalog' ? rowForEntry(item.entry, item.connector) : rowFor(item.connector)
+ );
+
+ const itemLabel = (item: ListItem): string => (
+ item.kind === 'catalog'
+ ? (item.entry.label || TYPE_LABELS[item.entry.installableId] || item.entry.installableId)
+ : (TYPE_LABELS[item.connector.type] || item.connector.type)
+ );
+
+ const itemType = (item: ListItem): string => (item.kind === 'catalog' ? item.entry.installableId : item.connector.type);
+
+ const needsUser = (row: ConnectorRow): boolean => row.action !== null && !row.secondary && row.action !== 'connect';
+ const selectedItem = items.find((item) => item.key === selectedKey)
+ || items.find((item) => needsUser(rowForItem(item)))
+ || items.find((item) => item.connector !== null)
|| null;
- const selectConnector = (connector: Connector) => {
- setSelectedId(connector._id);
- setConfirmDisconnect(null);
+ const selectItem = (item: ListItem) => {
+ setSelectedKey(item.key);
+ setConfirmRemove(null);
+ setExpandedGate(null);
};
- const runAction = (connector: Connector, action: ConnectorAction) => {
- selectConnector(connector);
+ const runAction = (item: ListItem, action: ConnectorAction) => {
+ selectItem(item);
+ const connector = item.connector;
+ if (action === 'connect') {
+ setAddingType(itemType(item));
+ return undefined;
+ }
+ // Pick a pod: the aside's gate list is already expanded for an unlinked
+ // row (D4), so selecting the row is the whole action.
+ if (action === 'pick-pod') return undefined;
+ if (item.kind === 'catalog') {
+ if (action === 'cancel') return uninstall(item.entry.installableId, item.key, 'connectors.cancelError', 'Could not cancel setup.');
+ if (action === 'retry-remove') return uninstall(item.entry.installableId, item.key, 'connectors.removeError', 'Could not remove the channel.');
+ if (action === 'retry') return retry(item.entry);
+ }
+ if (!connector) return undefined;
if (action === 'new-code') return regenerateCode(connector);
if (action === 'authorize') return authorizeSlack(connector);
if (action === 'confirm') return resolveSlackBind(connector, 'confirm');
return undefined;
};
- const renderRow = (connector: Connector) => {
- const row = rowFor(connector);
- const selected = selectedConnector?._id === connector._id;
- const isManage = row.action === 'manage';
+ const busyFor = (item: ListItem): boolean => busyId === item.key || (item.connector !== null && busyId === item.connector._id);
+
+ const renderRow = (item: ListItem) => {
+ const row = rowForItem(item);
+ const selected = selectedItem?.key === item.key;
+ const label = itemLabel(item);
+ const busy = busyFor(item);
return (
-
+
- { void resolveSlackBind(connector, 'confirm'); }}>
+ { void resolveSlackBind(connector, 'confirm'); }}>
{t('connectors.slackConfirm', { defaultValue: 'Confirm connection' })}
- { void resolveSlackBind(connector, 'reject'); }}>
+ { void resolveSlackBind(connector, 'reject'); }}>
{t('connectors.slackReject', { defaultValue: 'This is not me' })}
@@ -568,7 +1125,7 @@ const V2ConnectorsPage: React.FC = () => {
{isSlack && !pendingBind && connector.status !== 'error' && (
<>
{row.line}
- { void authorizeSlack(connector); }}>
+ { void authorizeSlack(connector); }}>
{t('connectors.slackAuthorize', { defaultValue: 'Authorize in Slack' })}
>
@@ -577,7 +1134,7 @@ const V2ConnectorsPage: React.FC = () => {
<>
{row.line}
{row.action && (
- { void runAction(connector, row.action as ConnectorAction); }}>
+ { void runAction(item, row.action as ConnectorAction); }}>
{row.actionLabel}
)}
@@ -602,7 +1159,7 @@ const V2ConnectorsPage: React.FC = () => {
patchConfig(connector, { liveRelay: !connector.config?.liveRelay }, 'connectors.toggleError', 'Could not update live relay.')}
/>
{t('connectors.liveRelay', { defaultValue: 'Relay' })}
@@ -612,7 +1169,7 @@ const V2ConnectorsPage: React.FC = () => {
patchConfig(connector, { relayAllAgentMessages: false }, 'connectors.modeError', 'Could not switch mode.')}
>
{t('connectors.modeAttention', { defaultValue: 'Attention' })}
@@ -620,7 +1177,7 @@ const V2ConnectorsPage: React.FC = () => {
patchConfig(connector, { relayAllAgentMessages: true }, 'connectors.modeError', 'Could not switch mode.')}
>
{t('connectors.modeMirror', { defaultValue: 'Mirror' })}
@@ -629,15 +1186,8 @@ const V2ConnectorsPage: React.FC = () => {
)}
)}
- {confirmDisconnect === connector._id ? (
- { void disconnect(connector); }}>
- {t('connectors.disconnectConfirm', { defaultValue: 'Really disconnect — the channel goes quiet' })}
-
- ) : (
- setConfirmDisconnect(connector._id)}>
- {t('connectors.disconnect', { defaultValue: 'Disconnect' })}
-
- )}
+ {connected && userScoped && renderGates(connector)}
+ {renderRemove(item)}
)}
@@ -647,7 +1197,9 @@ const V2ConnectorsPage: React.FC = () => {
const hasInstallation = (type: string): boolean => connectors.some(
(connector) => connector.type === type && Boolean(connector.installationId),
);
- const availableProviders = ADD_PLATFORMS.filter((provider) => provider.enabled && !hasInstallation(provider.type));
+ const availableProviders = catalog
+ ? catalog.filter((entry) => entry.available && !entry.installation).map((entry) => ({ type: entry.installableId }))
+ : ADD_PLATFORMS.filter((provider) => provider.enabled && !hasInstallation(provider.type));
const renderAddForm = (aside = false) => (
@@ -658,7 +1210,7 @@ const V2ConnectorsPage: React.FC = () => {
className={`v2-connectors__provider${addingType === provider.type ? ' v2-connectors__provider--selected' : ''}`}
onClick={() => setAddingType(provider.type)}
>
- {TYPE_LABELS[provider.type]}
+ {TYPE_LABELS[provider.type] || provider.type}
))}
@@ -676,6 +1228,8 @@ const V2ConnectorsPage: React.FC = () => {
);
+ const selectedAside = selectedItem ? renderAside(selectedItem) : null;
+
return (