diff --git a/docs/pr-assets/integrations-restored.png b/docs/pr-assets/integrations-restored.png new file mode 100644 index 0000000000..138acecd83 Binary files /dev/null and b/docs/pr-assets/integrations-restored.png differ diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 529e00ea49..7a26642061 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1002,9 +1002,6 @@ export const de: Record = { "nav.integrations": "Integrationen", "nav.openMenu": "Menü öffnen", "nav.closeMenu": "Menü schließen", - "integrations.moreClients": "Weitere Clients ({count})", - "integrations.fewerClients": "Nicht installierte Clients ausblenden", - "integrations.notInstalled": "Nicht installiert ({count})", "integrations.subtitle": "Clients mit opencodex verbinden, Zugangsdaten verwalten und Client-Konfigurationen wiederherstellen.", "integrations.tabsLabel": "Integrationsbereiche", "integrations.tab.overview": "Übersicht", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 177c717afb..549bd2b885 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1503,9 +1503,6 @@ export const en = { "nav.integrations": "Integrations", "nav.openMenu": "Open menu", "nav.closeMenu": "Close menu", - "integrations.moreClients": "More clients ({count})", - "integrations.fewerClients": "Hide uninstalled clients", - "integrations.notInstalled": "Not installed ({count})", "integrations.subtitle": "Connect clients to opencodex, manage credentials, and restore client configuration.", "integrations.tabsLabel": "Integration surfaces", "integrations.tab.overview": "Overview", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4859657d65..050b40fa67 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1476,9 +1476,6 @@ export const fr: Record = { "nav.integrations": "Intégrations", "nav.openMenu": "Ouvrir le menu", "nav.closeMenu": "Fermer le menu", - "integrations.moreClients": "Autres clients ({count})", - "integrations.fewerClients": "Masquer les clients non installés", - "integrations.notInstalled": "Non installés ({count})", "integrations.subtitle": "Connectez des clients à opencodex, gérez les identifiants et restaurez la configuration des clients.", "integrations.tabsLabel": "Surfaces d’intégration", "integrations.tab.overview": "Vue d’ensemble", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 522b03e3a1..e9860ff537 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1436,9 +1436,6 @@ export const ja: Record = { "nav.integrations": "連携", "nav.openMenu": "メニューを開く", "nav.closeMenu": "メニューを閉じる", - "integrations.moreClients": "その他のクライアント ({count})", - "integrations.fewerClients": "未インストールのクライアントを隠す", - "integrations.notInstalled": "未インストール ({count})", "integrations.subtitle": "クライアントを opencodex に接続し、認証情報の管理とクライアント設定の復元を行います。", "integrations.tabsLabel": "連携画面", "integrations.tab.overview": "概要", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 3c579a52aa..8d1e3bc8ea 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1026,9 +1026,6 @@ export const ko: Record = { "codexSet.base.externalBlocked": "model_instructions_file이 이미 {path}를 가리키고 있고, opencodex가 쓴 값이 아닙니다. 직접 지운 뒤 여기서 선택하세요.", "nav.openMenu": "메뉴 열기", "nav.closeMenu": "메뉴 닫기", - "integrations.moreClients": "다른 클라이언트 ({count})", - "integrations.fewerClients": "설치되지 않은 클라이언트 숨기기", - "integrations.notInstalled": "설치되지 않음 ({count})", "integrations.subtitle": "클라이언트를 opencodex에 연결하고 자격 증명과 설정 복원을 관리합니다.", "integrations.tabsLabel": "연동 화면", "integrations.tab.overview": "개요", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 045e3093a6..a00d5dcbe4 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1487,9 +1487,6 @@ export const ru: Record = { "nav.integrations": "Интеграции", "nav.openMenu": "Открыть меню", "nav.closeMenu": "Закрыть меню", - "integrations.moreClients": "Другие клиенты ({count})", - "integrations.fewerClients": "Скрыть неустановленные клиенты", - "integrations.notInstalled": "Не установлено ({count})", "integrations.subtitle": "Подключайте клиенты к opencodex, управляйте учётными данными и восстанавливайте конфигурацию клиентов.", "integrations.tabsLabel": "Разделы интеграций", "integrations.tab.overview": "Обзор", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 8eb7e13513..981bacb56b 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1494,9 +1494,6 @@ export const tr: Record = { "nav.integrations": "Entegrasyonlar", "nav.openMenu": "Menüyü aç", "nav.closeMenu": "Menüyü kapat", - "integrations.moreClients": "Diğer istemciler ({count})", - "integrations.fewerClients": "Yüklü olmayan istemcileri gizle", - "integrations.notInstalled": "Yüklü değil ({count})", "integrations.subtitle": "İstemcileri opencodex'e bağlayın, kimlik bilgilerini yönetin.", "integrations.tabsLabel": "Entegrasyon yüzeyleri", "integrations.tab.overview": "Genel Bakış", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 2f338ec09f..5eb6557302 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2058,9 +2058,6 @@ export const zhTW: Record = { "pws.capacity.windowPartialA11y": "{window}:帳號覆蓋不完整", "pws.connectionNotApplicable": "不適用 — 此供應商使用靜態模型目錄。", "nav.integrations": "整合", - "integrations.moreClients": "更多用戶端 ({count})", - "integrations.fewerClients": "隱藏未安裝的用戶端", - "integrations.notInstalled": "未安裝 ({count})", "integrations.subtitle": "將客戶端連線到 opencodex、管理憑證,並還原客戶端設定。", "integrations.tabsLabel": "整合表面", "integrations.tab.overview": "總覽", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index cb5904068a..f53e53ce39 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1019,9 +1019,6 @@ export const zh: Record = { "codexSet.base.externalBlocked": "model_instructions_file 已指向 {path},且不是 opencodex 写的。请先自行清除,再在此处选择。", "nav.openMenu": "打开菜单", "nav.closeMenu": "关闭菜单", - "integrations.moreClients": "更多客户端 ({count})", - "integrations.fewerClients": "隐藏未安装的客户端", - "integrations.notInstalled": "未安装 ({count})", "integrations.subtitle": "将客户端连接到 opencodex,管理凭据并恢复客户端配置。", "integrations.tabsLabel": "集成页面", "integrations.tab.overview": "概览", diff --git a/gui/src/pages/Integrations.tsx b/gui/src/pages/Integrations.tsx index 3aead591c8..b173771106 100644 --- a/gui/src/pages/Integrations.tsx +++ b/gui/src/pages/Integrations.tsx @@ -1,7 +1,6 @@ -import { useCallback, useEffect, useId, useRef, useState, type KeyboardEvent } from "react"; +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import { navigateHash, normalizeHashPath } from "../hash-routing"; import { useT } from "../i18n/shared"; -import { useDataSurface } from "../data-surface"; import ClientMark from "../components/ClientMark"; import { INTEGRATION_MARKS } from "../components/integration-marks"; import ApiKeys from "./ApiKeys"; @@ -13,7 +12,6 @@ import FileIntegrationPage, { type FileIntegrationClientId, } from "./integrations/FileIntegrationPage"; import { FILE_CLIENTS, TABS, type IntegrationTab } from "./integrations/integration-tabs"; -import { loadIntegrationStates, type IntegrationStatus } from "./integrations/integration-api"; function readIntegrationTab(hash = window.location.hash): IntegrationTab { const raw = normalizeHashPath(hash); @@ -58,35 +56,6 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec const [machineSyncing, setMachineSyncing] = useState(false); if (tabRefs.current === null) tabRefs.current = new Map(); - /* - * Eighteen tabs, most of them clients that are not installed on this machine, is the - * page's largest noise source. Tabs for uninstalled file clients hide behind one - * "more" button; everything the operator can actually act on stays in the strip. - * The state comes from the same keyed resource the overview reads, so this is not a - * second fetch. Until it settles every tab is primary — a strip must never flash-hide. - */ - const fetchStates = useCallback( - async (signal: AbortSignal) => (await loadIntegrationStates(apiBase, signal)).clients, - [apiBase], - ); - const statesResource = useDataSurface( - `integration-states:${apiBase}`, - [apiBase], - fetchStates, - { isEmpty: rows => rows.length === 0, sessionCacheKey: `ocx.integrations.states.v1:${apiBase}` }, - ); - const statesSettled = statesResource.state.kind !== "cold" && statesResource.state.kind !== "retrying-cold"; - const installedFileClients = new Set((statesResource.state.data ?? []).filter(c => c.installed).map(c => c.clientId)); - const isSecondary = (id: IntegrationTab) => - statesSettled && FILE_CLIENTS.has(id as FileIntegrationClientId) && !installedFileClients.has(id as FileIntegrationClientId); - const secondaryCount = TABS.filter(d => isSecondary(d.id)).length; - const [moreOpen, setMoreOpen] = useState(false); - const tablistId = useId(); - // The selected tab can never be hidden: a deep link to an uninstalled client opens the - // overflow, and the button is disabled while such a tab is selected. - const selectedIsSecondary = isSecondary(tab); - const showSecondary = moreOpen || selectedIsSecondary; - useEffect(() => { if (!connected) return; const controller = new AbortController(); @@ -146,16 +115,12 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec const handleTabKeyDown = (event: KeyboardEvent) => { const index = TABS.findIndex(candidate => candidate.id === tab); - // Arrows walk the VISIBLE tabs only; a hidden tab is not a stop. - const visible = TABS.map((d, i) => ({ d, i })).filter(({ d }) => showSecondary || !isSecondary(d.id)); - const pos = visible.findIndex(({ i }) => i === index); - let nextPos: number | null = null; - if (event.key === "ArrowLeft") nextPos = (pos - 1 + visible.length) % visible.length; - else if (event.key === "ArrowRight") nextPos = (pos + 1) % visible.length; - else if (event.key === "Home") nextPos = 0; - else if (event.key === "End") nextPos = visible.length - 1; - if (nextPos === null) return; - const nextIndex = visible[nextPos]!.i; + let nextIndex: number | null = null; + if (event.key === "ArrowLeft") nextIndex = (index - 1 + TABS.length) % TABS.length; + else if (event.key === "ArrowRight") nextIndex = (index + 1) % TABS.length; + else if (event.key === "Home") nextIndex = 0; + else if (event.key === "End") nextIndex = TABS.length - 1; + if (nextIndex === null) return; event.preventDefault(); selectTab(TABS[nextIndex].id, true); }; @@ -165,6 +130,7 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec

{t("nav.integrations")}

+

{t("integrations.subtitle")}

{connected && (
{t("connection.clients.title")} @@ -173,7 +139,7 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec
)} -
+
{TABS.map(definition => ( ))}
- {secondaryCount > 0 && ( - - )} {TABS.map(definition => { if (!mounted.has(definition.id)) return null; @@ -224,7 +177,7 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec hidden={!active} > {definition.id === "overview" && ( - + )} {definition.id === "keys" && } {definition.id === "codex" && ( diff --git a/gui/src/pages/integrations/IntegrationsOverview.tsx b/gui/src/pages/integrations/IntegrationsOverview.tsx index d96d8f182d..fdda39e029 100644 --- a/gui/src/pages/integrations/IntegrationsOverview.tsx +++ b/gui/src/pages/integrations/IntegrationsOverview.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useDataSurface, type DataSurfaceResource } from "../../data-surface"; +import { useDataSurface } from "../../data-surface"; import { DataSurfaceSkeleton } from "../../components/data-surface"; import { navigateHash } from "../../hash-routing"; import { useT } from "../../i18n/shared"; @@ -169,16 +169,9 @@ function OverviewCard({ export default function IntegrationsOverview({ apiBase, active = true, - statesResource, }: { apiBase: string; active?: boolean; - /** - * The file-client state list, owned by the Integrations page (it also drives which - * tabs are primary). Lifted rather than subscribed twice so there is exactly one - * owner of the fetch regardless of tab timing. - */ - statesResource: DataSurfaceResource; }) { const t = useT(); const [bulkPending, setBulkPending] = useState(false); @@ -198,6 +191,10 @@ export default function IntegrationsOverview({ if (trigger.isConnected) trigger.focus(); }, [pendingToggle]); + const fetchStates = useCallback( + async (signal: AbortSignal) => (await loadIntegrationStates(apiBase, signal)).clients, + [apiBase], + ); const fetchHistory = useCallback( async (signal: AbortSignal) => (await loadIntegrationJournal(apiBase, undefined, signal)).operations, [apiBase], @@ -238,6 +235,12 @@ export default function IntegrationsOverview({ [apiBase], ); + const statesResource = useDataSurface( + `integration-states:${apiBase}`, + [apiBase], + fetchStates, + { isEmpty: rows => rows.length === 0, enabled: active, sessionCacheKey: `ocx.integrations.states.v1:${apiBase}` }, + ); const historyResource = useDataSurface( `integration-journal-all:${apiBase}`, [apiBase], @@ -331,23 +334,6 @@ export default function IntegrationsOverview({ nativeSettled, }); const counts = countOverviewRows(rows); - // Installed (or applied, or not a file client at all) rows are the grid; the rest fold. - const presentRows = rows.filter(row => row.installed || row.applied || row.status === null); - const presentIds = new Set(presentRows.map(row => row.id)); - const absentRows = rows.filter(row => !presentIds.has(row.id)); - const renderCard = (row: (typeof rows)[number]) => ( - navigateHash(row.hash)} - onToggle={row.toggle ? () => requestToggle(row, !(row.toggleOn ?? row.applied)) : null} - onOverwrite={row.status !== null && row.status.state === "conflict" && row.installed - ? () => setPendingOverwrite(row) - : null} - /> - ); /* * `refresh()` on the resource layer is deliberately fire-and-forget: it @@ -430,6 +416,7 @@ export default function IntegrationsOverview({ : { tone: "err", text: t("integrations.bulk.partial", { clients: failed.join("; ") }) }); }; + const lastChange = history[0]?.at; /* * The card carries its own switch. Sending the user to a sub-page to flip @@ -551,6 +538,10 @@ export default function IntegrationsOverview({ {counts.unknown}
)} +
+ {t("integrations.summary.lastChange")} + {lastChange ? new Date(lastChange).toLocaleString() : t("integrations.status.unknown")} +