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 => (
selectTab(definition.id, true)}
onKeyDown={handleTabKeyDown}
>
@@ -199,18 +164,6 @@ 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;
@@ -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")}
+
{t("common.loading")}
)
) : (
- <>
-
- {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))}
-
-
- )}
- >
+
+ {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}
+ />
+ ))}
+
)}
{clientsSettled && installedFileClients.length === 0 && (
diff --git a/gui/src/styles-integrations.css b/gui/src/styles-integrations.css
index 2957cd9397..b0c438f3a0 100644
--- a/gui/src/styles-integrations.css
+++ b/gui/src/styles-integrations.css
@@ -23,14 +23,6 @@
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 9f3628c4f7..44d5313806 100644
--- a/gui/tests/integrations-surfaces.test.tsx
+++ b/gui/tests/integrations-surfaces.test.tsx
@@ -503,28 +503,16 @@ test("a hidden panel makes no request at all", async () => {
});
async function mountOverview(): Promise {
- const [{ createRoot }, { LanguageProvider }, { default: IntegrationsOverview }, { useDataSurface }, { loadIntegrationStates }] = await Promise.all([
+ const [{ createRoot }, { LanguageProvider }, { default: IntegrationsOverview }] = 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(
-
+
,
);
});
@@ -1094,100 +1082,3 @@ 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");
-});