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 => (
selectTab(definition.id, true)}
onKeyDown={handleTabKeyDown}
>
@@ -164,6 +199,18 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec
))}
+ {secondaryCount > 0 && (
+
setMoreOpen(open => !open)}
+ >
+ {t(showSecondary ? "integrations.fewerClients" : "integrations.moreClients", { count: secondaryCount })}
+
+ )}
{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")}
-
{t("common.loading")}
)
) : (
-
- {rows.map(row => (
- 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}
- />
- ))}
-
+ <>
+
+ {presentRows.map(row => renderCard(row))}
+
+ {/*
+ Clients that are not on this machine are inventory, not decisions. They stay
+ discoverable behind one disclosure instead of doubling the grid.
+ */}
+ {absentRows.length > 0 && (
+
+ {t("integrations.notInstalled", { count: absentRows.length })}
+
+ {absentRows.map(row => renderCard(row))}
+
+
+ )}
+ >
)}
{clientsSettled && installedFileClients.length === 0 && (
diff --git a/gui/src/styles-integrations.css b/gui/src/styles-integrations.css
index b0c438f3a00..2957cd9397f 100644
--- a/gui/src/styles-integrations.css
+++ b/gui/src/styles-integrations.css
@@ -23,6 +23,14 @@
the floor in `min()` keeps the two-column intent on wide screens while letting a
narrow one fall back to the width it actually has. */
.integration-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: 12px; list-style: none; padding: 0; margin: 14px 0; }
+/* Uninstalled clients fold behind one disclosure below the grid; the "more" button in the tab
+ strip is a plain button outside the tab semantics (aria-expanded on the tablist). */
+.integration-cards-more { margin: 4px 0 14px; }
+.integration-cards-more > summary { cursor: pointer; list-style: none; display: inline-flex; align-items: center; gap: 6px; padding: 4px 0; }
+.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-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
diff --git a/gui/tests/integrations-surfaces.test.tsx b/gui/tests/integrations-surfaces.test.tsx
index 44d53138068..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(
-
+
,
);
});
@@ -1082,3 +1094,100 @@ test("the tab strip marks every client tab and leaves the two non-client tabs ba
// covers the app and SDK too.
expect(codexTab.textContent).toBe("Codex");
});
+
+/*
+ * devlog/_plan/260904_dashboard_minimal/040_integrations.md: tabs for uninstalled file
+ * clients hide behind one "more" button outside the tablist; a deep link to such a
+ * client still opens (and pins) it. Overview cards for uninstalled clients fold under
+ * a disclosure; the "last change" summary cell is gone (the rollback list has it).
+ */
+async function mountIntegrationsPage(): Promise {
+ const [{ createRoot }, { LanguageProvider }, { default: Integrations }] = await Promise.all([
+ import("react-dom/client"),
+ import("../src/i18n/provider"),
+ import("../src/pages/Integrations"),
+ ]);
+ await act(async () => {
+ root = createRoot(container);
+ root.render( );
+ });
+ await act(async () => { await new Promise(r => testWindow.setTimeout(r, 30)); });
+}
+
+test("uninstalled file-client tabs hide behind a more-button; a deep link keeps its tab visible and pins the button", async () => {
+ // Only hermes is installed; every other file client is absent.
+ stateResponse = () => json({ clients: [status({ clientId: "hermes", installed: true, state: "current" })] });
+ testWindow.location.hash = "#integrations";
+ await mountIntegrationsPage();
+
+ const tabs = [...container.querySelectorAll('[role="tab"]')];
+ const visible = (id: string) => !tabs.find(t => t.id.endsWith(`-${id}`))?.hidden;
+ expect(visible("overview")).toBe(true);
+ expect(visible("hermes")).toBe(true);
+ 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);
+ expect(more.getAttribute("aria-expanded")).toBe("true");
+ await act(async () => { more.click(); });
+ expect(visible("omp")).toBe(false);
+
+ // Deep link to an uninstalled client: the tab is shown and the button cannot hide it.
+ await act(async () => { root!.unmount(); });
+ root = null;
+ testWindow.location.hash = "#integrations/omp";
+ await mountIntegrationsPage();
+ const tabs2 = [...container.querySelectorAll('[role="tab"]')];
+ const omp = tabs2.find(t => t.id.endsWith("-omp"))!;
+ expect(omp.hidden).toBe(false);
+ expect(omp.getAttribute("aria-selected")).toBe("true");
+ expect(container.querySelector(".page-tabs-more")!.disabled).toBe(true);
+});
+
+test("overview folds uninstalled client cards under a disclosure and drops the last-change cell", async () => {
+ stateResponse = () => json({ clients: [
+ status({ clientId: "hermes", installed: true, state: "current" }),
+ status({ clientId: "omp", installed: false, state: "absent", configPath: "/tmp/home/.omp/agent/models.yml" }),
+ status({ clientId: "pi", installed: false, state: "absent", configPath: "/tmp/home/.pi/agent/models.json" }),
+ ] });
+ await mountOverview();
+ await act(async () => { await new Promise(r => testWindow.setTimeout(r, 30)); });
+
+ const details = container.querySelector("details.integration-cards-more");
+ expect(details).not.toBeNull();
+ expect(details!.open).toBe(false);
+ expect(details!.querySelector("summary")!.textContent).toContain("2");
+ // The installed client is in the primary grid, not inside the disclosure.
+ const primary = container.querySelector("ul.integration-cards")!;
+ expect(primary.closest("details")).toBeNull();
+ expect(primary.textContent).toContain("Hermes");
+ expect(details!.textContent).toContain("OMP");
+ // No "last change" summary cell.
+ expect([...container.querySelectorAll(".integration-summary-label")].map(e => e.textContent))
+ .not.toContain("Last change");
+});