From 5c7df19e9ada72455936ea98a01c6edc16303894 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:57:11 -0700 Subject: [PATCH 1/3] feat(v2): Connectors rows keyed by the capability catalog, parent states as rows, gates in the aside (D8 Phase 2 B+D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rows come from GET /api/installables (D1/D2): an unavailable provider is "Not enabled on this instance" with no control; an available one offers Connect; installing/activating and uninstalling parents read Setting up…/ Removing… and gain Cancel / Retry remove only once the claim is stale (INSTALL_LOCK_TTL_MS); an error parent shows its errorMessage with Retry (POST install { podId: boundPodId }) and Remove in the aside; a missing projection under an active parent is a Retry row; a paused parent shows the reason and `paused {rel}` with no owner control. A connected row whose podId is unset reads "not linked to a pod" with Pick a pod (#1551). The aside's What-the-channel-sees card gains the D4 gate list: one row per pod the user is in, a switch per gate (the PATCH merges config one level deep, so the whole gates map travels with each write, since included), mono since/off, an `active` tag on the active pod, and behind each pod's name the mode segment, a lead picker over the pod's bots, and Make active (PATCH { podId }). Disconnect becomes Remove. The connect form's picker no longer filters pod.type against community/showcase, which are not pod types. Legacy pod-scoped rows still render from /api/integrations/user/all; an installable row is keyed by the catalog and skipped there. If the catalog cannot be read the page falls back to the two providers it drove before. Co-Authored-By: Claude Fable 5.1 --- .../v2/__tests__/V2ConnectorsPage.test.tsx | 267 +++++- .../v2/__tests__/v2-layout-invariants.test.ts | 4 + .../src/v2/components/V2ConnectorsPage.tsx | 759 +++++++++++++++--- frontend/src/v2/v2.css | 27 + 4 files changed, 939 insertions(+), 118 deletions(-) diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index e47a467bb8..afa84ab4aa 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,254 @@ 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('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 f4dc7fb7dc..11c30121e9 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -1130,6 +1130,10 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(v2, '.v2-connector-row__detail')).toContain('white-space: 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__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..516ee5c68a 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,36 @@ 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. + const writeGate = (connector: Connector, podId: string, next: ConnectorGate) => { + const current = connector.config?.gates || {}; + const gates: Record = {}; + Object.entries(current).forEach(([key, gate]) => { + 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 +461,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 +497,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 +538,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 +553,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; @@ -382,8 +579,9 @@ const V2ConnectorsPage: React.FC = () => { : t('connectors.rowAttention', { defaultValue: 'attention mode · 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 +646,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 ( -
+
@@ -509,14 +885,189 @@ const V2ConnectorsPage: React.FC = () => { ); }; - const renderAside = (connector: Connector) => { + // D4: one row per pod the user is in. The switch is the gate; the active pod + // is where the owner's typed messages land, and it is its own control. + const renderGates = (connector: Connector) => { + const gates = connector.config?.gates || {}; + const activePodId = connectorPodId(connector); + const unlinked = !activePodId; + return ( +
+

+ {unlinked + ? t('connectors.pickWhere', { defaultValue: 'Pick where your messages go' }) + : t('connectors.gatesTitle', { defaultValue: 'Pods that reach this channel' })} +

+ {pods.length === 0 && ( +

{t('connectors.noPods', { defaultValue: 'Join a pod first.' })}

+ )} + {pods.map((pod) => { + const gate = gates[pod._id]; + const enabled = gate?.enabled === true; + const active = String(activePodId || '') === String(pod._id); + const open = unlinked || expandedGate === pod._id; + const bots = botMembers(pod); + const disabled = busyId === connector._id; + return ( +
+
+ + + {enabled ? `since ${relativeTime(gate?.since)}` : t('connectors.gateOff', { defaultValue: 'off' })} + + { void writeGate(connector, pod._id, { ...gate, enabled: !enabled }); }} + /> +
+ {open && ( +
+
+ {([ + [undefined, t('connectors.modeDefault', { defaultValue: 'Default' })], + ['attention', t('connectors.modeAttention', { defaultValue: 'Attention' })], + ['mirror', t('connectors.modeMirror', { defaultValue: 'Mirror' })], + ] as [ConnectorGate['mode'], string][]).map(([mode, modeLabel]) => { + const on = (gate?.mode || undefined) === mode; + return ( + + ); + })} +
+ + {!active && ( + + )} +
+ )} +
+ ); + })} +
+ ); + }; + + const renderRemove = (item: ListItem) => ( + confirmRemove === item.key ? ( + + ) : ( + + ) + ); + + // The parent's non-active states own the aside: one sentence, the verbs the + // service would honour, and nothing the owner cannot press. + const renderParentAside = (item: ListItem & { kind: 'catalog' }, row: ConnectorRow) => { + const status = item.entry.installation?.status || ''; + const busy = busyFor(item); + return ( + + ); + }; + + const renderAside = (item: ListItem) => { + const row = rowForItem(item); + if (item.kind === 'catalog') { + const status = item.entry.installation?.status; + if (!item.entry.available || !item.entry.installation) return null; + if (status !== 'active' || !item.connector || row.action === 'retry') return renderParentAside(item, row); + } + const connector = item.connector as Connector; const isTelegram = connector.type === 'telegram'; const isSlack = connector.type === 'slack'; const connected = connector.status === 'connected'; const supportsRelayControls = isTelegram || isSlack; const mirror = connector.config?.relayAllAgentMessages === true; - const row = rowFor(connector); const pendingBind = connector.config?.pendingBind; + const userScoped = connector.scope === 'user' || Boolean(connector.config?.gates); + const busy = busyFor(item); return ( @@ -647,7 +1191,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 +1204,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 +1222,8 @@ const V2ConnectorsPage: React.FC = () => {
); + const selectedAside = selectedItem ? renderAside(selectedItem) : null; + return (
@@ -689,7 +1237,7 @@ const V2ConnectorsPage: React.FC = () => {
- {connectors.map(renderRow)} + {items.map(renderRow)}
)}
- {selectedConnector - ? renderAside(selectedConnector) - : adding && ( + {selectedAside + || (adding && ( - )} + ))}
)} diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index d9115f8dac..21c59e1b8a 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -9744,6 +9744,32 @@ body.modern-ui.v2-canvas { .v2-root button.v2-connector-code__copy { min-height: 30px; padding: 5px 9px; border: 1px solid var(--v2-accent); border-radius: var(--v2-radius-sm); background: var(--v2-accent); color: var(--v2-on-ink); font: 600 12px/16px var(--v2-font); cursor: pointer; } .v2-connectors__error { padding: 10px 12px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); color: var(--v2-ink); font-size: 13px; } +.v2-connector-aside__note { margin-top: 12px !important; color: var(--v2-text-tertiary) !important; font-size: 12px !important; } +/* D4 gate list: one row per pod the user is in. The switch is the gate + (pod → me); the active tag marks where typed messages land (me → pod). */ +.v2-connector-gates { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--v2-border-soft); } +.v2-connector-gates__title { margin: 0 0 6px !important; color: var(--v2-text-tertiary) !important; font-family: var(--v2-font-mono); font-size: 11px !important; line-height: 16px !important; text-transform: lowercase; } +.v2-connector-gates__empty { color: var(--v2-text-tertiary) !important; font-size: 12px !important; } +.v2-connector-gate { border-bottom: 1px solid var(--v2-border-soft); } +.v2-connector-gate:last-child { border-bottom: none; } +.v2-connector-gate__row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 10px; min-height: 36px; } +.v2-root button.v2-connector-gate__name { display: inline-flex; align-items: center; gap: 8px; min-width: 0; padding: 6px 0; border: none; background: transparent; color: var(--v2-ink); font: 500 13px/18px var(--v2-font); text-align: left; cursor: pointer; } +.v2-connector-gate__mark { width: 4px; height: 4px; flex: none; background: var(--v2-ink); } +.v2-connector-gate__pod { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-connector-gate__tag { flex: none; color: var(--v2-ink); font-family: var(--v2-font-mono); font-size: 11px; line-height: 16px; } +.v2-connector-gate__since { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 11px; line-height: 16px; white-space: nowrap; } +.v2-connector-gate__since--off { color: var(--v2-text-placeholder); } +/* The gate switch: a 30×16 pill in the state grammar — idle border when off, + ink when on, no third colour. accent-color keeps the native fallback ink. */ +.v2-connector-gate__switch { position: relative; width: 30px; height: 16px; margin: 0; border: 1px solid var(--v2-border-strong); border-radius: 8px; background: var(--v2-surface); accent-color: var(--v2-ink); appearance: none; -webkit-appearance: none; cursor: pointer; } +.v2-connector-gate__switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 10px; height: 10px; border-radius: 5px; background: var(--v2-border-strong); transition: transform 120ms ease, background 120ms ease; } +.v2-connector-gate__switch:checked { border-color: var(--v2-ink); background: var(--v2-ink); } +.v2-connector-gate__switch:checked::after { background: var(--v2-on-ink); transform: translateX(14px); } +.v2-connector-gate__switch:focus-visible { outline: 2px solid var(--v2-ink); outline-offset: 2px; } +.v2-connector-gate__switch:disabled { cursor: progress; opacity: 0.65; } +.v2-connector-gate__more { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 4px 0 12px 12px; } +.v2-connector-gate__lead { min-height: 30px; padding: 0 8px; font-size: 12px; } + @media (max-width: 760px) { .v2-connectors { min-height: 0; gap: 24px; margin: -12px -18px 0; } .v2-connectors__content { grid-template-columns: minmax(0, 1fr); gap: 24px; } @@ -9759,6 +9785,7 @@ body.modern-ui.v2-canvas { .v2-root button.v2-connector-row__action, .v2-root a.v2-connector-row__action { grid-column: 2; grid-row: 1; z-index: 1; } .v2-connectors__new-row { grid-template-columns: minmax(0, 1fr); } .v2-connectors__providers { width: fit-content; } + .v2-connector-gate__more { padding-left: 0; } } /* Activity queue: reply-in-place on mention rows (Sam, 2026-09-01). The From 21924375a40c6653e6717aa3a0dc2e23d34b46b2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:00:39 -0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(v2):=20a=20gate=20write=20drops=20pods?= =?UTF-8?q?=20the=20owner=20has=20left=20=E2=80=94=20the=20server=20refuse?= =?UTF-8?q?s=20the=20whole=20map=20on=20one=20stale=20key=20(Vera)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../v2/__tests__/V2ConnectorsPage.test.tsx | 27 +++++++++++++++++++ .../src/v2/components/V2ConnectorsPage.tsx | 6 +++++ 2 files changed, 33 insertions(+) diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index afa84ab4aa..e6df15c7d0 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -605,6 +605,33 @@ describe('V2ConnectorsPage', () => { )); }); + 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', diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx index 516ee5c68a..16380a0816 100644 --- a/frontend/src/v2/components/V2ConnectorsPage.tsx +++ b/frontend/src/v2/components/V2ConnectorsPage.tsx @@ -379,10 +379,16 @@ const V2ConnectorsPage: React.FC = () => { // 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 } : {}), From 2151b5651d68890f97166c3e53e2274bdc1c551f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:36:29 -0700 Subject: [PATCH 3/3] fix(v2): the gate switch is the system's square checkbox, and row meta wraps instead of ellipsising (lily-shen, 64026) Co-Authored-By: Claude Fable 5.1 --- docs/plans/connectors-page-signal-diff.md | 2 +- .../src/v2/__tests__/v2-layout-invariants.test.ts | 4 +++- frontend/src/v2/components/V2ConnectorsPage.tsx | 2 +- frontend/src/v2/v2.css | 13 +++++-------- 4 files changed, 10 insertions(+), 11 deletions(-) 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__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 11c30121e9..fce16d326e 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -1127,12 +1127,14 @@ 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)'); diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx index 16380a0816..5858674350 100644 --- a/frontend/src/v2/components/V2ConnectorsPage.tsx +++ b/frontend/src/v2/components/V2ConnectorsPage.tsx @@ -582,7 +582,7 @@ 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: `${title} · linked to ${podNameById(activePodId, connector)}`, diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 21c59e1b8a..428f40b336 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -9663,7 +9663,8 @@ body.modern-ui.v2-canvas { .v2-connector-row__details { display: flex; min-width: 0; flex-direction: column; gap: 2px; color: var(--v2-text-secondary); font-size: 13px; line-height: 18px; } .v2-connector-row__details strong { overflow: hidden; color: var(--v2-ink); font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } .v2-connector-row__detail, .v2-connector-row__when { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 12px; line-height: 16px; } -.v2-connector-row__detail { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Meta wraps to a second line; it never ellipsises (lily-shen, 64026). */ +.v2-connector-row__detail { overflow-wrap: anywhere; } .v2-connector-row__when { padding-right: 16px; } .v2-root button.v2-connector-row__action, .v2-root a.v2-connector-row__action { @@ -9759,13 +9760,9 @@ body.modern-ui.v2-canvas { .v2-connector-gate__tag { flex: none; color: var(--v2-ink); font-family: var(--v2-font-mono); font-size: 11px; line-height: 16px; } .v2-connector-gate__since { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 11px; line-height: 16px; white-space: nowrap; } .v2-connector-gate__since--off { color: var(--v2-text-placeholder); } -/* The gate switch: a 30×16 pill in the state grammar — idle border when off, - ink when on, no third colour. accent-color keeps the native fallback ink. */ -.v2-connector-gate__switch { position: relative; width: 30px; height: 16px; margin: 0; border: 1px solid var(--v2-border-strong); border-radius: 8px; background: var(--v2-surface); accent-color: var(--v2-ink); appearance: none; -webkit-appearance: none; cursor: pointer; } -.v2-connector-gate__switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 10px; height: 10px; border-radius: 5px; background: var(--v2-border-strong); transition: transform 120ms ease, background 120ms ease; } -.v2-connector-gate__switch:checked { border-color: var(--v2-ink); background: var(--v2-ink); } -.v2-connector-gate__switch:checked::after { background: var(--v2-on-ink); transform: translateX(14px); } -.v2-connector-gate__switch:focus-visible { outline: 2px solid var(--v2-ink); outline-offset: 2px; } +/* The gate switch is the system's checkbox — the bordered square the Relay + line already uses, ink when on. No pill: the system has hard edges only. */ +.v2-connector-gate__switch { width: 16px; height: 16px; margin: 0; accent-color: var(--v2-ink); cursor: pointer; } .v2-connector-gate__switch:disabled { cursor: progress; opacity: 0.65; } .v2-connector-gate__more { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 4px 0 12px 12px; } .v2-connector-gate__lead { min-height: 30px; padding: 0 8px; font-size: 12px; }