From 89c70057c275385b51b17bf1057e5fa8183cc997 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 4 Sep 2026 03:24:37 +0900 Subject: [PATCH 1/4] feat(gui): Integrations shows the clients on this machine first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page opened with an eighteen-tab strip and a card for every supported client, most of which are not installed on this machine, plus a subtitle and a "last change" cell. Now: - Tabs for uninstalled file clients hide behind one "다른 클라이언트 (N)" button that sits outside the tablist (aria-expanded on the strip). Arrow keys walk visible tabs only. A deep link to an uninstalled client shows its tab and disables the button while it is selected, so the selected tab can never be hidden. The state comes from the same keyed resource the overview reads — no second fetch — and until it settles every tab is primary, so the strip never flash-hides. - Overview cards for uninstalled clients fold under a closed "설치되지 않음 (N)" details. - The subtitle and the summary's last-change cell are gone (the rollback list carries the chronology). Plan: devlog/_plan/260904_dashboard_minimal/040_integrations.md. --- gui/src/i18n/de.ts | 3 + gui/src/i18n/en.ts | 3 + gui/src/i18n/fr.ts | 3 + gui/src/i18n/ja.ts | 3 + gui/src/i18n/ko.ts | 3 + gui/src/i18n/ru.ts | 3 + gui/src/i18n/tr.ts | 3 + gui/src/i18n/zh-TW.ts | 3 + gui/src/i18n/zh.ts | 3 + gui/src/pages/Integrations.tsx | 65 +++++++++++++--- .../integrations/IntegrationsOverview.tsx | 53 ++++++++----- gui/src/styles-integrations.css | 8 ++ gui/tests/integrations-surfaces.test.tsx | 76 +++++++++++++++++++ 13 files changed, 200 insertions(+), 29 deletions(-) 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..def0e3345e1 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 => { diff --git a/gui/src/pages/integrations/IntegrationsOverview.tsx b/gui/src/pages/integrations/IntegrationsOverview.tsx index fdda39e0293..ae9d9a1ffa5 100644 --- a/gui/src/pages/integrations/IntegrationsOverview.tsx +++ b/gui/src/pages/integrations/IntegrationsOverview.tsx @@ -334,6 +334,22 @@ 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 absentRows = rows.filter(row => !presentRows.includes(row)); + 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 +432,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 +553,6 @@ export default function IntegrationsOverview({ {counts.unknown}
)} -
- {t("integrations.summary.lastChange")} - {lastChange ? new Date(lastChange).toLocaleString() : t("integrations.status.unknown")} -
))} - {secondaryCount > 0 && ( - - )} + {secondaryCount > 0 && ( + + )} {TABS.map(definition => { if (!mounted.has(definition.id)) return null; @@ -224,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 4733e4252c1..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], diff --git a/gui/tests/integrations-surfaces.test.tsx b/gui/tests/integrations-surfaces.test.tsx index eb55b5bdd2f..9f3628c4f7c 100644 --- a/gui/tests/integrations-surfaces.test.tsx +++ b/gui/tests/integrations-surfaces.test.tsx @@ -503,16 +503,28 @@ test("a hidden panel makes no request at all", async () => { }); async function mountOverview(): Promise { - const [{ createRoot }, { LanguageProvider }, { default: IntegrationsOverview }] = await Promise.all([ + const [{ createRoot }, { LanguageProvider }, { default: IntegrationsOverview }, { useDataSurface }, { loadIntegrationStates }] = await Promise.all([ import("react-dom/client"), import("../src/i18n/provider"), import("../src/pages/integrations/IntegrationsOverview"), + import("../src/data-surface"), + import("../src/pages/integrations/integration-api"), ]); + // The page owns the states resource and hands it down (040); mirror that here. + function OverviewHost() { + const statesResource = useDataSurface( + `integration-states:${apiBase}`, + [apiBase], + async (signal: AbortSignal) => (await loadIntegrationStates(apiBase, signal)).clients, + { isEmpty: rows => rows.length === 0 }, + ); + return ; + } await act(async () => { root = createRoot(container); root.render( - + , ); }); @@ -1115,8 +1127,29 @@ test("uninstalled file-client tabs hide behind a more-button; a deep link keeps expect(visible("omp")).toBe(false); const more = container.querySelector(".page-tabs-more")!; expect(more).not.toBeNull(); + // External to the tablist: a non-tab child would break the tab semantics. + expect(more.closest('[role="tablist"]')).toBeNull(); + expect(more.getAttribute("aria-controls")).toBe(container.querySelector('[role="tablist"]')!.id); expect(more.getAttribute("aria-expanded")).toBe("false"); expect(more.disabled).toBe(false); + // One owner of the states fetch: the page subscribes and hands the resource down. + expect(requests.filter(r => r.url.endsWith("/api/client-integrations") && r.method === "GET")).toHaveLength(1); + // Keyboard walks visible tabs only: from the last visible primary tab, ArrowRight wraps + // to overview instead of landing on a hidden client. + const visibleTabs = tabs.filter(t => !t.hidden); + const last = visibleTabs[visibleTabs.length - 1]!; + await act(async () => { last.click(); }); + await act(async () => { + last.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + }); + expect(testWindow.location.hash).toBe("#integrations"); + await act(async () => { + tabs[0]!.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "End", bubbles: true })); + }); + // End lands on the last VISIBLE tab (hermes, the one installed file client), never on + // one of the hidden clients that come after it in TABS order. + expect(testWindow.location.hash).toBe("#integrations/hermes"); + expect(last.id.endsWith("-hermes")).toBe(true); await act(async () => { more.click(); }); expect(visible("omp")).toBe(true); From 4c4a157287284f3ae7e60adcaaf5ad98b03a9f32 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 4 Sep 2026 03:33:13 +0900 Subject: [PATCH 4/4] style(gui): the more-button is a tablist sibling; fix its selector --- gui/src/styles-integrations.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/styles-integrations.css b/gui/src/styles-integrations.css index 9f76ac3a98d..2957cd9397f 100644 --- a/gui/src/styles-integrations.css +++ b/gui/src/styles-integrations.css @@ -30,7 +30,7 @@ .integration-cards-more > summary::-webkit-details-marker { display: none; } .integration-cards-more > summary::before { content: ""; width: 0; height: 0; border-left: 5px solid var(--muted); border-top: 4px solid transparent; border-bottom: 4px solid transparent; transition: transform var(--motion-fast); } .integration-cards-more[open] > summary::before { transform: rotate(90deg); } -.page-tabs .page-tabs-more { align-self: center; margin-left: 4px; } +.page-tabs-more { margin: -8px 0 14px; } /* One full-width row, not a wide card: no grid cell, no hover border, no stretched title. `flex-wrap` is what keeps long German/Russian action copy