diff --git a/devlog/_plan/260904_dashboard_minimal/assets/041_integrations_after.png b/devlog/_plan/260904_dashboard_minimal/assets/041_integrations_after.png new file mode 100644 index 00000000000..26ac0ee1070 Binary files /dev/null and b/devlog/_plan/260904_dashboard_minimal/assets/041_integrations_after.png differ diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index d7202581c2f..2b449bec5bc 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1006,6 +1006,9 @@ 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 73c376938d0..4d91b114930 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1507,6 +1507,9 @@ 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 e60046e060a..92ae481f3cd 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1480,6 +1480,9 @@ 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 f27979e6e0c..2bdef7a65d2 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1440,6 +1440,9 @@ 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 02596d7b467..ba38f480430 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1030,6 +1030,9 @@ 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 9d8437fee41..66650a2a901 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1491,6 +1491,9 @@ 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 0b26fa1a824..596e2987f84 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1498,6 +1498,9 @@ 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 29813ad6254..9517e969373 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2060,6 +2060,9 @@ 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 754e721d182..99038f129c4 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1023,6 +1023,9 @@ 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 b1737711065..3aead591c82 100644 --- a/gui/src/pages/Integrations.tsx +++ b/gui/src/pages/Integrations.tsx @@ -1,6 +1,7 @@ -import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { useCallback, useEffect, useId, 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"; @@ -12,6 +13,7 @@ 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); @@ -56,6 +58,35 @@ 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(); @@ -115,12 +146,16 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec const handleTabKeyDown = (event: KeyboardEvent) => { const index = TABS.findIndex(candidate => candidate.id === tab); - 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; + // 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; event.preventDefault(); selectTab(TABS[nextIndex].id, true); }; @@ -130,7 +165,6 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec

{t("nav.integrations")}

-

{t("integrations.subtitle")}

{connected && (
{t("connection.clients.title")} @@ -139,7 +173,7 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec
)} -
+
{TABS.map(definition => ( ))}
+ {secondaryCount > 0 && ( + + )} {TABS.map(definition => { if (!mounted.has(definition.id)) return null; @@ -177,7 +224,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 fdda39e0293..d96d8f182d0 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 } from "../../data-surface"; +import { useDataSurface, type DataSurfaceResource } from "../../data-surface"; import { DataSurfaceSkeleton } from "../../components/data-surface"; import { navigateHash } from "../../hash-routing"; import { useT } from "../../i18n/shared"; @@ -169,9 +169,16 @@ 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); @@ -191,10 +198,6 @@ 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], @@ -235,12 +238,6 @@ 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], @@ -334,6 +331,23 @@ 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 @@ -416,7 +430,6 @@ 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 @@ -538,10 +551,6 @@ export default function IntegrationsOverview({ {counts.unknown}
)} -
- {t("integrations.summary.lastChange")} - {lastChange ? new Date(lastChange).toLocaleString() : t("integrations.status.unknown")} -