Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/pr-assets/integrations-restored.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 0 additions & 3 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1002,9 +1002,6 @@ export const de: Record<TKey, string> = {
"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",
Expand Down
3 changes: 0 additions & 3 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 0 additions & 3 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1476,9 +1476,6 @@ export const fr: Record<TKey, string> = {
"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",
Expand Down
3 changes: 0 additions & 3 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1436,9 +1436,6 @@ export const ja: Record<TKey, string> = {
"nav.integrations": "連携",
"nav.openMenu": "メニューを開く",
"nav.closeMenu": "メニューを閉じる",
"integrations.moreClients": "その他のクライアント ({count})",
"integrations.fewerClients": "未インストールのクライアントを隠す",
"integrations.notInstalled": "未インストール ({count})",
"integrations.subtitle": "クライアントを opencodex に接続し、認証情報の管理とクライアント設定の復元を行います。",
"integrations.tabsLabel": "連携画面",
"integrations.tab.overview": "概要",
Expand Down
3 changes: 0 additions & 3 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1026,9 +1026,6 @@ export const ko: Record<TKey, string> = {
"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": "개요",
Expand Down
3 changes: 0 additions & 3 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1487,9 +1487,6 @@ export const ru: Record<TKey, string> = {
"nav.integrations": "Интеграции",
"nav.openMenu": "Открыть меню",
"nav.closeMenu": "Закрыть меню",
"integrations.moreClients": "Другие клиенты ({count})",
"integrations.fewerClients": "Скрыть неустановленные клиенты",
"integrations.notInstalled": "Не установлено ({count})",
"integrations.subtitle": "Подключайте клиенты к opencodex, управляйте учётными данными и восстанавливайте конфигурацию клиентов.",
"integrations.tabsLabel": "Разделы интеграций",
"integrations.tab.overview": "Обзор",
Expand Down
3 changes: 0 additions & 3 deletions gui/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1494,9 +1494,6 @@ export const tr: Record<TKey, string> = {
"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ış",
Expand Down
3 changes: 0 additions & 3 deletions gui/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2058,9 +2058,6 @@ export const zhTW: Record<TKey, string> = {
"pws.capacity.windowPartialA11y": "{window}:帳號覆蓋不完整",
"pws.connectionNotApplicable": "不適用 — 此供應商使用靜態模型目錄。",
"nav.integrations": "整合",
"integrations.moreClients": "更多用戶端 ({count})",
"integrations.fewerClients": "隱藏未安裝的用戶端",
"integrations.notInstalled": "未安裝 ({count})",
"integrations.subtitle": "將客戶端連線到 opencodex、管理憑證,並還原客戶端設定。",
"integrations.tabsLabel": "整合表面",
"integrations.tab.overview": "總覽",
Expand Down
3 changes: 0 additions & 3 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1019,9 +1019,6 @@ export const zh: Record<TKey, string> = {
"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": "概览",
Expand Down
67 changes: 10 additions & 57 deletions gui/src/pages/Integrations.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -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<IntegrationStatus[]>(
`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();
Expand Down Expand Up @@ -146,16 +115,12 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec

const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
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);
};
Expand All @@ -165,6 +130,7 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec
<div className="page-head">
<h2>{t("nav.integrations")}</h2>
</div>
<p className="page-sub">{t("integrations.subtitle")}</p>
{connected && (
<section className="notice" aria-label={t("connection.clients.title")}>
<strong>{t("connection.clients.title")}</strong>
Expand All @@ -173,7 +139,7 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec
</section>
)}

<div className="page-tabs" role="tablist" aria-label={t("integrations.tabsLabel")} id={tablistId}>
<div className="page-tabs" role="tablist" aria-label={t("integrations.tabsLabel")}>
{TABS.map(definition => (
<button
key={definition.id}
Expand All @@ -188,7 +154,6 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec
aria-controls={panelDomId(definition.id)}
tabIndex={tab === definition.id ? 0 : -1}
className={`page-tab${tab === definition.id ? " page-tab--active" : ""}`}
hidden={!showSecondary && isSecondary(definition.id)}
onClick={() => selectTab(definition.id, true)}
onKeyDown={handleTabKeyDown}
>
Expand All @@ -199,18 +164,6 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec
</button>
))}
</div>
{secondaryCount > 0 && (
<button
type="button"
className="btn btn-ghost btn-sm page-tabs-more"
aria-expanded={showSecondary}
aria-controls={tablistId}
disabled={selectedIsSecondary}
onClick={() => setMoreOpen(open => !open)}
>
{t(showSecondary ? "integrations.fewerClients" : "integrations.moreClients", { count: secondaryCount })}
</button>
)}

{TABS.map(definition => {
if (!mounted.has(definition.id)) return null;
Expand All @@ -224,7 +177,7 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec
hidden={!active}
>
{definition.id === "overview" && (
<IntegrationsOverview apiBase={apiBase} active={active} statesResource={statesResource} />
<IntegrationsOverview apiBase={apiBase} active={active} />
)}
{definition.id === "keys" && <ApiKeys apiBase={apiBase} active={active} />}
{definition.id === "codex" && (
Expand Down
73 changes: 31 additions & 42 deletions gui/src/pages/integrations/IntegrationsOverview.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<IntegrationStatus[]>;
}) {
const t = useT();
const [bulkPending, setBulkPending] = useState(false);
Expand All @@ -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],
Expand Down Expand Up @@ -238,6 +235,12 @@ export default function IntegrationsOverview({
[apiBase],
);

const statesResource = useDataSurface<IntegrationStatus[]>(
`integration-states:${apiBase}`,
[apiBase],
fetchStates,
{ isEmpty: rows => rows.length === 0, enabled: active, sessionCacheKey: `ocx.integrations.states.v1:${apiBase}` },
);
const historyResource = useDataSurface<IntegrationJournalRow[]>(
`integration-journal-all:${apiBase}`,
[apiBase],
Expand Down Expand Up @@ -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]) => (
<OverviewCard
key={row.id}
row={row}
pending={cardPending !== null}
result={cardResults[row.id] ?? null}
onOpen={() => 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -551,6 +538,10 @@ export default function IntegrationsOverview({
<strong>{counts.unknown}</strong>
</div>
)}
<div className="integration-summary-cell">
<span className="integration-summary-label">{t("integrations.summary.lastChange")}</span>
<strong>{lastChange ? new Date(lastChange).toLocaleString() : t("integrations.status.unknown")}</strong>
</div>
<button
type="button"
className="btn btn-ghost"
Expand Down Expand Up @@ -602,23 +593,21 @@ export default function IntegrationsOverview({
<p className="page-sub">{t("common.loading")}</p>
)
) : (
<>
<ul className="integration-cards">
{presentRows.map(row => renderCard(row))}
</ul>
{/*
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 && (
<details className="integration-cards-more">
<summary className="muted text-label">{t("integrations.notInstalled", { count: absentRows.length })}</summary>
<ul className="integration-cards">
{absentRows.map(row => renderCard(row))}
</ul>
</details>
)}
</>
<ul className="integration-cards">
{rows.map(row => (
<OverviewCard
key={row.id}
row={row}
pending={cardPending !== null}
result={cardResults[row.id] ?? null}
onOpen={() => 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}
/>
))}
</ul>
)}
{clientsSettled && installedFileClients.length === 0 && (
<div className="integration-empty">
Expand Down
8 changes: 0 additions & 8 deletions gui/src/styles-integrations.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading