From 1d7d2e2c6a8f89f38ce70695c6ebc5034ee78122 Mon Sep 17 00:00:00 2001 From: OpenPets Dev Date: Thu, 18 Jun 2026 05:08:04 +0000 Subject: [PATCH] Split Control Center routes into modules --- FEATURE_REGISTRY.md | 1 + apps/desktop/src/check-packaging-contract.ts | 14 +- apps/desktop/src/renderer/src/codemap.md | 12 +- .../renderer/src/control-center/codemap.md | 32 + .../src/control-center/dashboard-view.tsx | 244 + .../src/control-center/familiars-view.tsx | 526 ++ .../src/control-center/integrations-view.tsx | 942 ++++ .../src/control-center/plugins-view.tsx | 607 +++ .../src/control-center/settings-view.tsx | 1251 +++++ .../renderer/src/control-center/shared.tsx | 967 ++++ apps/desktop/src/renderer/src/main.tsx | 4400 +---------------- apps/desktop/tests/plugin-ui-static.test.ts | 18 +- 12 files changed, 4620 insertions(+), 4394 deletions(-) create mode 100644 apps/desktop/src/renderer/src/control-center/codemap.md create mode 100644 apps/desktop/src/renderer/src/control-center/dashboard-view.tsx create mode 100644 apps/desktop/src/renderer/src/control-center/familiars-view.tsx create mode 100644 apps/desktop/src/renderer/src/control-center/integrations-view.tsx create mode 100644 apps/desktop/src/renderer/src/control-center/plugins-view.tsx create mode 100644 apps/desktop/src/renderer/src/control-center/settings-view.tsx create mode 100644 apps/desktop/src/renderer/src/control-center/shared.tsx diff --git a/FEATURE_REGISTRY.md b/FEATURE_REGISTRY.md index 07a2a149..dc449f3e 100644 --- a/FEATURE_REGISTRY.md +++ b/FEATURE_REGISTRY.md @@ -28,6 +28,7 @@ This is the canonical feature registry for the FamiliarOS working repo. It maps | Custom Familiar name | **merged** | `feat/familiaros-rebrand` | current | `apps/desktop/src/app-state.ts`, `apps/desktop/src/tray.ts`, `apps/desktop/src/renderer/src/main.tsx` | User-defined name for the default Familiar appears in tray tooltip and context menu. | | Curated familiar catalog safety | **merged** | `feat/familiaros-rebrand` | current | `apps/desktop/src/catalog.ts`, `apps/desktop/src/catalog-validation.ts`, `apps/desktop/catalog.v2.fixture.json`, `apps/desktop/contracts/catalog-fixture.contract.ts` | Curated catalog metadata now preserves `original` / `featured` / category fields end-to-end, uses IP-safer FamiliarOS fixture familiars, and keeps surfaceable pagination totals aligned with the real searchable set. | | Control Center bridge hardening | **merged** | `feat/familiaros-rebrand` | current | `apps/desktop/src/renderer/src/main.tsx`, `apps/desktop/tests/plugin-ui-static.test.ts` | The Control Center API bridge now resolves to a required `ControlCenterApi` once at startup instead of leaking optionality through the whole renderer, and the static validation was updated to the stricter bridge shape. | +| Control Center route modularization | **merged** | `feat/familiaros-rebrand` | current | `apps/desktop/src/renderer/src/main.tsx`, `apps/desktop/src/renderer/src/control-center/*.tsx`, `apps/desktop/src/check-packaging-contract.ts`, `apps/desktop/tests/plugin-ui-static.test.ts` | The renderer shell now routes into dedicated dashboard, familiars, settings, integrations, and plugins modules, with shared bridge/types/UI extracted into `control-center/shared.tsx` so future Control Center work no longer has to funnel through one giant file. | ## 2. Floating chat / prompt window diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 7f907a8f..25820fec 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -71,6 +71,10 @@ assert.match(readFileSync(join(appDir, "src", "assets.ts"), "utf8"), /assets["'] const petWindowSource = readFileSync(join(appDir, "src", "familiar-window.ts"), "utf8"); const controlCenterPreloadSource = readFileSync(join(appDir, "control-center-preload.cjs"), "utf8"); const controlCenterRendererSource = readFileSync(join(appDir, "src", "renderer", "src", "main.tsx"), "utf8"); +const controlCenterSharedSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "shared.tsx"), "utf8"); +const controlCenterSettingsSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "settings-view.tsx"), "utf8"); +const controlCenterFamiliarsSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "familiars-view.tsx"), "utf8"); +const controlCenterIntegrationsSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "integrations-view.tsx"), "utf8"); const petPreloadSource = readFileSync(join(appDir, "familiar-preload.cjs"), "utf8"); const promptWindowPreloadSource = readFileSync(join(appDir, "prompt-window-preload.cjs"), "utf8"); const reactionMessagesSource = readFileSync(join(appDir, "src", "reaction-messages.ts"), "utf8"); @@ -190,10 +194,10 @@ assert.match(controlCenterPreloadSource, /saveOpenApiCredential/, "Control Cente assert.match(promptWindowPreloadSource, /submitPrompt/, "Prompt window preload must expose prompt submission."); assert.match(promptWindowSource, /sendOpenApiChatPrompt/, "Prompt window must submit prompts through the main-process OpenAPI chat service."); assert.match(promptWindowSource, /openControlCenterWindow\("settings"\)/, "Prompt window must be able to open Settings when the API key is missing."); -assert.match(controlCenterRendererSource, /API key or token|OpenAI API key/, "Control Center settings must expose OpenAPI chat credential setup."); -assert.match(controlCenterRendererSource, /function SettingsView\(/, "Control Center must include the settings page."); -assert.match(controlCenterRendererSource, /getPetsState/, "Control Center must include the familiars page data bridge."); -assert.match(controlCenterRendererSource, /function IntegrationsView\(\)/, "Control Center must include the integrations page."); +assert.match(`${controlCenterSettingsSource}\n${controlCenterSharedSource}`, /API key or token|OpenAI API key/, "Control Center settings must expose OpenAPI chat credential setup."); +assert.match(controlCenterRendererSource, /from "\.\/control-center\/settings-view"/, "Control Center shell must load the settings page module."); +assert.match(controlCenterFamiliarsSource, /getPetsState/, "Control Center must include the familiars page data bridge."); +assert.match(controlCenterIntegrationsSource, /export function IntegrationsView\(\)/, "Control Center must include the integrations page."); assert.match(petWindowSource, /--bubble-max-width:/, "familiar window must define a scalable bubble max-width variable."); assert.match(petWindowSource, /\.bubble\.is-very-long-message \{[\s\S]*?max-width:\s*min\([^)]*var\(--bubble-max-width\)/, "very long message bubbles must stay capped within the familiar window while allowing larger familiars."); assert.match(petWindowSource, /\.bubble-body \{[\s\S]*?overflow-y:\s*auto/, "bubble bodies must be scrollable for long messages."); @@ -223,7 +227,7 @@ for (const reaction of allowedReactions) { } assert.equal(pickReactionMessage("success", () => 0), reactionMessagePools.success[0], "reaction message picking must be deterministic when random is injected."); assert.doesNotMatch(controlCenterRendererSource, /OnboardingView|getOnboardingSnapshot|completeOnboarding/, "Control Center must not include the removed onboarding route."); -assert.match(controlCenterRendererSource, /function IntegrationsView\(\)/, "Control Center must include integrations."); +assert.match(controlCenterRendererSource, /from "\.\/control-center\/integrations-view"/, "Control Center shell must include the integrations module."); assert.match(enCatalogSource, /Claude Code/, "Control Center integrations must include Claude Code."); assert.match(enCatalogSource, /OpenCode/, "Control Center integrations must include OpenCode."); assert.match(enCatalogSource, /Cursor/, "Control Center integrations must include Cursor."); diff --git a/apps/desktop/src/renderer/src/codemap.md b/apps/desktop/src/renderer/src/codemap.md index 8adfc45b..8f95dc0e 100644 --- a/apps/desktop/src/renderer/src/codemap.md +++ b/apps/desktop/src/renderer/src/codemap.md @@ -6,16 +6,24 @@ React/Tailwind source for the Control Center management UI. This renderer presen ## Design -- **Route Shell**: In-renderer route state supports `dashboard`, `familiars`, `integrations`, `plugins`, and `settings`; tray actions retarget the singleton window through route-change events. +- **Route Shell**: `main.tsx` now owns only the renderer bootstrap, translated route shell, theme wiring, and route switching for `dashboard`, `familiars`, `integrations`, `plugins`, and `settings`. +- **Route Modules**: Each major Control Center route now lives in `control-center/*.tsx`, so dashboard, familiars, integrations, plugins, and settings can evolve independently without re-growing a single renderer god file. - **Dashboard**: Reads a narrowed dashboard snapshot for default familiar preview, install/catalog counts, plugin health, update status, and activity totals. - **Familiars**: Combines installed familiars, catalog v3 pages/search, Codex imports, filters, detail panes, set-default/install/import/remove actions, and animated sprite previews. - **Integrations**: Card-first setup UI for Claude Code, OpenCode, Cursor, and Pi guidance, including command mode/path controls and preview/action flows. - **Plugins**: Gallery-first plugin hub for installed/catalog/local/broken filters, catalog refresh, local load, install/update/uninstall, enable/disable, config modal, command execution, runtime/status display, and broken-state feedback. - **Settings**: Startup, launch-at-login, familiar scale, reaction-animation mapping, update check, default-familiar position reset, and familiar reaction previews. +- **Shared Control Center Surface**: `control-center/shared.tsx` owns the required preload bridge resolution, shared types, route metadata, reusable UI primitives, and safe familiar-preview helpers. - **Bridge Contract**: All data and actions go through `window.familiarOSControlCenter` with `window.openPetsControlCenter` retained as a legacy alias; page snapshots intentionally omit raw install paths and unrelated app state. ## Key Files -- `main.tsx`: Single-file React app containing type definitions, route shell, page components, icons, snapshot loading, and action handlers. +- `main.tsx`: Renderer bootstrap plus translated route shell and theme wiring. +- `control-center/shared.tsx`: Required preload bridge resolver, renderer type surface, route metadata, icons, buttons, and familiar-preview helpers shared across route modules. +- `control-center/dashboard-view.tsx`: Dashboard route. +- `control-center/familiars-view.tsx`: Familiars route with catalog/search/detail workflows. +- `control-center/integrations-view.tsx`: Coding-agent integrations route. +- `control-center/plugins-view.tsx`: Plugin management route. +- `control-center/settings-view.tsx`: Settings, knowledge store, OpenAPI, and TTS route. - `styles.css`: Tailwind base/components/utilities plus glass-card layout, navigation, galleries, modals, status pills, previews, and notifications. - `vite-env.d.ts`: Vite/TypeScript renderer environment declarations. diff --git a/apps/desktop/src/renderer/src/control-center/codemap.md b/apps/desktop/src/renderer/src/control-center/codemap.md new file mode 100644 index 00000000..76f2553e --- /dev/null +++ b/apps/desktop/src/renderer/src/control-center/codemap.md @@ -0,0 +1,32 @@ +# apps/desktop/src/renderer/src/control-center/ + +## Responsibility + +Route-level Control Center renderer modules plus the shared renderer bridge/UI +surface they depend on. + +## Design + +- `shared.tsx` is the stable center of gravity for this folder. It owns the + required `ControlCenterApi` bridge resolution, shared renderer types, route + metadata, reusable buttons/cards/status pills, navigation icons, and safe + familiar-preview helpers. +- `dashboard-view.tsx` owns only the dashboard snapshot and activity UI. +- `familiars-view.tsx` owns familiar browsing, catalog paging/search, Codex + import, install/remove/default actions, and the familiar detail overlay. +- `integrations-view.tsx` owns coding-agent setup cards, MCP server previews, + toolkit installation flows, and command-path controls. +- `plugins-view.tsx` owns plugin catalog/install/config/runtime management. +- `settings-view.tsx` owns the settings tabs, OpenAPI/TTS settings, knowledge + store, memories, update status, and plugin platform settings. + +## Working Notes + +- Keep route-specific state inside the route module unless it truly needs to be + shared across routes. +- Add shared renderer helpers to `shared.tsx` only when at least two routes + need them or when the preload bridge/type contract changes. +- When a packaging or static assertion references the Control Center source + surface, update `apps/desktop/tests/plugin-ui-static.test.ts` and + `apps/desktop/src/check-packaging-contract.ts` so they follow the new module + boundary rather than assuming everything still lives in `main.tsx`. diff --git a/apps/desktop/src/renderer/src/control-center/dashboard-view.tsx b/apps/desktop/src/renderer/src/control-center/dashboard-view.tsx new file mode 100644 index 00000000..c4ba0040 --- /dev/null +++ b/apps/desktop/src/renderer/src/control-center/dashboard-view.tsx @@ -0,0 +1,244 @@ +import { useEffect, useState } from "react"; +import { useI18n } from "../i18n"; +import * as Shared from "./shared"; +import type { DashboardSnapshot, Route } from "./shared"; + +const { api, ActivityIcon, BoxIcon, Button, GlassCard, HeartIcon, MessageIcon, PluginGlyph, RefreshIcon, ShieldIcon, Spinner, SpriteFrame, StarIcon, StatusPill, ZapIcon } = Shared; + +export function DashboardView({ onNavigate }: { onNavigate: (route: Route) => void }) { + const { t } = useI18n(); + const [snapshot, setSnapshot] = useState(null); + const [error, setError] = useState(""); + + const load = async () => { + try { + const next = await api.getDashboardSnapshot(); + setSnapshot(next); + setError(""); + } catch (err) { + setError(String((err as Error)?.message ?? err)); + } + }; + + useEffect(() => { void load(); }, []); + + if (!snapshot) { + return ( +
+ + {!error && } +

{error || t("dashboard.loading")}

+ {error && } +
+
+ ); + } + + const { activity, defaultPet, plugins, installedPetCount, updateStatus, catalog } = snapshot; + + // Find top familiar by activity or fallback to default + const topPetId = Object.entries(activity.perPetActivityCounts).sort(([, a], [, b]) => b - a)[0]?.[0]; + const topPetName = topPetId === defaultPet.id ? defaultPet.displayName : (topPetId || defaultPet.displayName); + + // Find top reaction + const reactionEntries = Object.entries(activity.reactionCounts) + .filter(([, count]) => count > 0) + .sort(([, a], [, b]) => b - a); + const reactionTotal = reactionEntries.reduce((total, [, count]) => total + count, 0); + const reactionColors = ["#3b82f6", "#a855f7", "#f97316", "#14b8a6"]; + const reactionDonutSegments = reactionEntries.slice(0, 4).map(([label, count], index) => ({ + label, + count, + color: reactionColors[index] ?? "#64748b", + })); + const topCompanionEntries = Object.entries(activity.perPetActivityCounts) + .filter(([, count]) => count > 0) + .sort(([, a], [, b]) => b - a) + .slice(0, 4); + const maxCompanionActivity = Math.max(...topCompanionEntries.map(([, count]) => count), 1); + const lastActiveLabel = activity.lastActivityAt ? new Date(activity.lastActivityAt).toLocaleString() : t("dashboard.lastActive.none"); + const updateLabel = updateStatus.state === "available" ? t("dashboard.update.available") : updateStatus.state === "error" ? t("dashboard.update.error") : updateStatus.state === "checking" ? t("dashboard.update.checking") : updateStatus.state === "current" ? t("dashboard.update.current") : t("dashboard.update.notChecked"); + + return ( +
+ {error &&
{error}
} + +
+
+

{t("dashboard.hero.eyebrow")}

+

{defaultPet.displayName}

+

+ {t("dashboard.hero.desc")} +

+
+ +
+
+
+ +
+
+ +
+
+
+
+ {t("dashboard.stat.messages")} +
+
{activity.messagesSent.toLocaleString()}
+
{t("dashboard.stat.messages.footer")}
+
+ +
+
+
+ {t("dashboard.stat.reactions")} +
+
{activity.reactionsSent.toLocaleString()}
+
{t("dashboard.stat.reactions.footer")}
+
+ +
+
+
+ {t("dashboard.stat.topCompanion")} +
+
{topPetName}
+
{t("dashboard.stat.topCompanion.footer")}
+
+
+ +
+ +
{t("dashboard.activity.title")}
+
+
+ {t("dashboard.activity.topReactions")} +
+ {reactionEntries.length > 0 ? ( + reactionEntries.slice(0, 6) + .map(([label, count]) => ( +
+ {count} + {label} +
+ )) + ) : ( +
{t("dashboard.activity.noReactions")}
+ )} +
+
+ +
+
+
+ {t("dashboard.reactionMix.title")} + {reactionTotal ? t("dashboard.reactionMix.total", { count: reactionTotal.toLocaleString() }) : t("dashboard.reactionMix.waiting")} +
+
+
+ + + {reactionTotal > 0 && reactionDonutSegments.map((segment, index) => { + const circumference = 251.327; + const previousTotal = reactionDonutSegments.slice(0, index).reduce((total, item) => total + item.count, 0); + const dash = (segment.count / reactionTotal) * circumference; + const offset = -(previousTotal / reactionTotal) * circumference; + return ; + })} + +
+ {reactionTotal.toLocaleString()} + {t("dashboard.reactionMix.reactions")} +
+
+
+ {reactionDonutSegments.length ? reactionDonutSegments.map((segment) => ( +
+ + {segment.label} + {segment.count} +
+ )) :

{t("dashboard.reactionMix.empty")}

} +
+
+
+ +
+
+ {t("dashboard.companions.title")} + {t("dashboard.companions.subtitle")} +
+
+ {topCompanionEntries.length ? topCompanionEntries.map(([petId, count]) => { + const label = petId === defaultPet.id ? defaultPet.displayName : petId.replace(/[-_]/g, " "); + return ( +
+
+ {label} + {count} +
+
+
+ ); + }) :

{t("dashboard.companions.empty")}

} +
+
+ +
{t("dashboard.lastActive.label")}{lastActiveLabel}
+
+
+
+ + +
{t("dashboard.system.title")}
+
+
+
+
+ {t("dashboard.system.familiars")} +
+ {t("dashboard.system.familiars.value", { count: installedPetCount })} +
+ +
+
+
+ {t("dashboard.system.plugins")} +
+
+ {t("dashboard.system.plugins.enabled", { count: plugins.enabled })} + {plugins.broken > 0 && {plugins.broken}} +
+
+ +
+
+
+ {t("dashboard.system.catalog")} +
+ {catalog.error ? t("dashboard.system.catalog.offline") : catalog.total ? t("dashboard.system.catalog.familiars", { count: catalog.total }) : t("dashboard.system.catalog.ready")} +
+ +
+
+
+ {t("dashboard.system.updates")} +
+ + {updateLabel} + +
+
+ +
+
+ {t("dashboard.system.version")} + {updateStatus.currentVersion} +
+
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/control-center/familiars-view.tsx b/apps/desktop/src/renderer/src/control-center/familiars-view.tsx new file mode 100644 index 00000000..bbbcda66 --- /dev/null +++ b/apps/desktop/src/renderer/src/control-center/familiars-view.tsx @@ -0,0 +1,526 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useI18n } from "../i18n"; +import * as Shared from "./shared"; +import type { CatalogState, CodexState, Filter, PetEntry, Route, SearchPetEntry, StateSnapshot } from "./shared"; + +const { + api, + Button, + CloseIcon, + defaultThumbUrl, + EyeIcon, + filterIcons, + filterLabelKeys, + FolderPlusIcon, + GlassCard, + HeartIcon, + imageDebug, + ImportIcon, + installedPetSpritesheetUrl, + InstallIcon, + logPetsError, + logPetsEvent, + NextIcon, + PetImage, + PrevIcon, + RefreshIcon, + RemoveIcon, + safePetImage, + SearchInput, + SetDefaultIcon, + SpriteFrame, + StatusPill, +} = Shared; + +export function FamiliarsView() { + const { t } = useI18n(); + const currentRoute: Route = "familiars"; + const [state, setState] = useState(null); + const [catalog, setCatalog] = useState(null); + const [catalogPages, setCatalogPages] = useState>({}); + const [catalogSearch, setCatalogSearch] = useState(null); + const [catalogPage, setCatalogPage] = useState(0); + const [codex, setCodex] = useState({ familiars: [] }); + const [selectedId, setSelectedId] = useState(""); + const [filter, setFilter] = useState("all"); + const [query, setQuery] = useState(""); + const [busy, setBusy] = useState(""); + const [error, setError] = useState(""); + const petDetailDialogRef = useRef(null); + const previouslyFocusedElementRef = useRef(null); + + async function loadPetsData() { + setError(""); + const [nextState, nextCatalog, nextCodex] = await Promise.all([api.getPetsState(), api.getCatalog(), api.getCodexPets()]); + logPetsEvent("load-complete", { installed: nextState.familiars.installed.length, defaultPetId: nextState.preferences.defaultPetId, catalogSource: nextCatalog.source, catalogPets: nextCatalog.familiars.length, catalogPage: nextCatalog.page, catalogPageCount: nextCatalog.pageCount, codexPets: nextCodex.familiars.length, catalogError: nextCatalog.error, codexError: nextCodex.error, firstCatalogPet: nextCatalog.familiars[0] ? { id: nextCatalog.familiars[0].id, preview: imageDebug(nextCatalog.familiars[0].preview), thumbnail: imageDebug(nextCatalog.familiars[0].thumbnail), spritesheet: imageDebug(nextCatalog.familiars[0].spritesheet) } : null }); + setState(nextState); setCatalog(nextCatalog); setCodex(nextCodex); + setCatalogPage(nextCatalog.page ?? 0); + setCatalogPages({ [nextCatalog.page ?? 0]: nextCatalog.familiars }); + const visiblePetIds = new Set([...nextState.familiars.installed.map((familiar) => familiar.id), ...nextCatalog.familiars.map((familiar) => familiar.id), ...nextCodex.familiars.map((familiar) => familiar.id)]); + setSelectedId((current) => current && visiblePetIds.has(current) ? current : ""); + } + useEffect(() => { + if (currentRoute !== "familiars") return; + void loadPetsData().catch((err) => setError(String(err?.message ?? err))); + }, [currentRoute]); + + const familiars = useMemo(() => { + const installed = new Map((state?.familiars.installed ?? []).map((p) => [p.id, p])); + const catalogMap = new Map(); + for (const pagePets of Object.values(catalogPages)) { + for (const p of pagePets) { + catalogMap.set(p.id, p); + } + } + const codexMap = new Map((codex.familiars ?? []).map((p) => [p.id, p])); + + const rows: PetEntry[] = (state?.familiars.installed ?? []).map((p) => { + const catalogPet = catalogMap.get(p.id); + const codexPet = codexMap.get(p.id); + const localSpritesheet = p.id && !catalogPet && !codexPet && !p.builtIn ? installedPetSpritesheetUrl(p.id) : undefined; + const spritesheet = safePetImage(codexPet?.spritesheet) || safePetImage(catalogPet?.spritesheet) || safePetImage(localSpritesheet); + const preview = safePetImage(codexPet?.preview) || safePetImage(catalogPet?.preview) || safePetImage(catalogPet?.thumbnail) || safePetImage(p.source && "preview" in p.source ? (p.source as { preview?: string }).preview : undefined) || safePetImage(localSpritesheet) || defaultThumbUrl; + const category = catalogPet?.category; + const original = catalogPet?.original; + const featured = catalogPet?.featured; + return { + ...p, + spritesheet, + preview, + category, + original, + featured, + sourceKind: "installed" as const, + installed: true, + }; + }); + + for (const p of catalogMap.values()) { + if (!installed.has(p.id)) { + rows.push({ + ...p, + preview: safePetImage(p.preview) || safePetImage(p.thumbnail) || defaultThumbUrl, + spritesheet: safePetImage(p.spritesheet), + sourceKind: "catalog", + installed: false, + }); + } + } + + for (const p of codexMap.values()) { + if (!installed.has(p.id) && !catalogMap.has(p.id)) { + rows.push({ + ...p, + preview: safePetImage(p.preview), + spritesheet: safePetImage(p.spritesheet), + sourceKind: "codex", + installed: false, + }); + } + } + + return rows.filter((p) => { + if (filter === "installed" && !p.installed) return false; + if (filter === "codex" && p.sourceKind !== "codex" && !(installed.get(p.id)?.source?.kind === "codex")) return false; + if (filter === "originals" && !p.original && !p.builtIn) return false; + if (filter === "featured" && (!p.featured || p.original)) return false; + const q = query.trim().toLowerCase(); + return !q || `${p.displayName} ${p.description ?? ""} ${p.searchText ?? ""} ${p.id}`.toLowerCase().includes(q); + }); + }, [state, catalogPages, catalogSearch, codex, filter, query]); + + const selected = selectedId ? familiars.find((p) => p.id === selectedId) ?? null : null; + const defaultId = state?.preferences.defaultPetId; + + useEffect(() => { + if (!selected) return; + + const dialog = petDetailDialogRef.current; + if (!dialog) return; + + previouslyFocusedElementRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const focusableSelector = "button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])"; + requestAnimationFrame(() => { + dialog.querySelector(focusableSelector)?.focus(); + }); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + setSelectedId(""); + return; + } + + if (event.key !== "Tab") return; + const focusable = Array.from(dialog.querySelectorAll(focusableSelector)).filter((element) => element.offsetParent !== null); + if (!focusable.length) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } + if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + previouslyFocusedElementRef.current?.focus(); + }; + }, [selected]); + + useEffect(() => { + if (!selected) return; + logPetsEvent("selected-familiar", { id: selected.id, sourceKind: selected.sourceKind, installed: selected.installed, builtIn: selected.builtIn, preview: imageDebug(selected.preview), spritesheet: imageDebug(selected.spritesheet), hasSafePreview: Boolean(safePetImage(selected.preview)), hasSafeSpritesheet: Boolean(safePetImage(selected.spritesheet)), catalogPages: Object.keys(catalogPages).join(",") }); + }, [selected]); + + const statusText = useMemo(() => { + if (!selected) return ""; + const isDefault = selected.id === defaultId; + const isCodex = selected.sourceKind === "codex" || (state?.familiars.installed.find(p => p.id === selected.id)?.source?.kind === "codex"); + if (selected.broken) return selected.brokenReason || t("familiars.status.broken"); + if (isDefault) return selected.protected ? t("familiars.status.defaultProtected") : t("familiars.status.default"); + if (selected.installed) { + if (isCodex) return t("familiars.status.installedCodex"); + return t("familiars.status.installed"); + } + if (selected.sourceKind === "codex") return t("familiars.status.availableCodex"); + return t("familiars.status.availableCatalog"); + }, [selected, defaultId, state, t]); + + async function act(label: string, fn: () => Promise) { + try { setBusy(label); setError(""); await fn(); await loadPetsData(); } + catch (err) { setError(String((err as Error)?.message ?? err)); } + finally { setBusy(""); } + } + + useEffect(() => { + if (currentRoute !== "familiars") return; + if (catalogSearch) return; + void api.getCatalogSearch().then((result) => { + if (result.error) setError(result.error); + setCatalogSearch(result.familiars ?? []); + }).catch((err) => setError(String(err?.message ?? err))); + }, [catalogSearch, currentRoute]); + + useEffect(() => { + if (currentRoute !== "familiars") return; + if (!catalogSearch) return; + const q = query.trim().toLowerCase(); + const needsRemotePages = !!q || filter === "featured" || filter === "originals"; + + const pages = new Set(); + + if (state?.familiars.installed) { + for (const p of state.familiars.installed) { + const searchPet = catalogSearch.find(sp => sp.id === p.id); + if (searchPet && typeof searchPet.catalogPage === "number" && !catalogPages[searchPet.catalogPage]) { + pages.add(searchPet.catalogPage); + } + } + } + + if (needsRemotePages) { + for (const familiar of catalogSearch) { + if (pages.size >= 12) break; + if (filter === "originals" && !familiar.original) continue; + if (filter === "featured" && (!familiar.featured || familiar.original)) continue; + if (q && !`${familiar.displayName} ${familiar.searchText ?? ""} ${familiar.id}`.toLowerCase().includes(q)) continue; + if (typeof familiar.catalogPage === "number" && !catalogPages[familiar.catalogPage]) pages.add(familiar.catalogPage); + } + } + + if (!pages.size) return; + let cancelled = false; + void Promise.all([...pages].map((page) => api.getCatalogPage(page).catch((err) => ({ source: "error", familiars: [], error: String((err as Error)?.message ?? err), page } as CatalogState)))).then((results) => { + if (cancelled) return; + setCatalogPages((current) => { + const next = { ...current }; + for (const result of results) if (result.source !== "error") next[result.page ?? 0] = result.familiars; + return next; + }); + const firstError = results.find((result) => result.source === "error")?.error; + if (firstError) setError(firstError); + }); + return () => { cancelled = true; }; + }, [catalogPages, catalogSearch, filter, query, state, currentRoute]); + + async function loadCatalogPage(page: number) { + if (catalogPages[page]) { setCatalogPage(page); return; } + try { + setBusy(t("familiars.busy.loadingPage")); setError(""); + const next = await api.getCatalogPage(page); + setCatalog(next); setCatalogPage(next.page ?? page); setCatalogPages((pages) => ({ ...pages, [next.page ?? page]: next.familiars })); + } catch (err) { setError(String((err as Error)?.message ?? err)); } + finally { setBusy(""); } + } + + return ( +
+ +
setQuery(e.target.value)} />
+
+
+ {(["all", "installed", "featured", "originals", "codex"] as Filter[]).map((f) => ( + + ))} +
+
+ + +
+
+
{familiars.map((familiar) => { + const isBuiltIn = familiar.builtIn; + const hasDistinctPreview = familiar.preview && familiar.preview !== familiar.spritesheet; + const useSpritesheetFrame = !isBuiltIn && !hasDistinctPreview && !!familiar.spritesheet; + const isDefault = familiar.id === defaultId; + const canInstall = !familiar.installed && familiar.sourceKind === "catalog"; + const canImport = !familiar.installed && familiar.sourceKind === "codex"; + const canSetDefault = familiar.installed && !isDefault && !familiar.broken; + const canRemove = familiar.installed && !familiar.builtIn && !familiar.protected; + + return ( +
+ + {useSpritesheetFrame ? ( + + ) : ( + + )} + +
+ + {familiar.displayName} + +

{familiar.description || familiar.id}

+
{isDefault && {t("familiars.badge.default")}}{familiar.original || familiar.builtIn ? {t("familiars.badge.original")} : familiar.featured ? {t("familiars.badge.featured")} : null}{familiar.installed && {t("familiars.badge.installed")}}{familiar.sourceKind === "codex" && {t("familiars.badge.codex")}}
+ +
event.stopPropagation()}> + + {canInstall && ( + + )} + {canImport && ( + + )} + {canSetDefault && ( + + )} + {canRemove && ( + + )} +
+
+
+ ); + })}
+
+ {!!catalog?.pageCount && catalog.pageCount > 1 ? ( + + ) : } + {t("familiars.pager.count", { count: familiars.length })}{!!catalog?.pageCount && catalog.pageCount > 1 ? t("familiars.pager.page", { page: catalogPage + 1, pageCount: catalog.pageCount }) : ""} + {!!catalog?.pageCount && catalog.pageCount > 1 ? ( + + ) : } +
+
+ + {selected ? ( +
+ +
+ +
+
+

{selected.description || selected.id}

+
+ {safePetImage(selected.spritesheet) ? ( + + ) : ( + + )} +
+
+ {selected.broken && {t("familiars.badge.broken")}} + {selected.installed && !selected.broken && {t("familiars.badge.ready")}} + {selected.builtIn && {t("familiars.badge.originals")}} + {selected.original && !selected.builtIn && {t("familiars.badge.original")}} + {selected.featured && !selected.original && {t("familiars.badge.featured")}} +
+ {statusText &&

{statusText}

} +
+ + +
+ +
+ {/* Main Action (Install, Import, Set Default) */} + {!selected.installed && selected.sourceKind === "catalog" && ( + + )} + {!selected.installed && selected.sourceKind === "codex" && ( + + )} + {selected.installed && selected.id !== defaultId && !selected.broken && ( + + )} + +
+ {selected.installed && !selected.builtIn && !selected.protected && ( + + )} + +
+
+ +
+ ) : null} + + ); +} diff --git a/apps/desktop/src/renderer/src/control-center/integrations-view.tsx b/apps/desktop/src/renderer/src/control-center/integrations-view.tsx new file mode 100644 index 00000000..39308e0a --- /dev/null +++ b/apps/desktop/src/renderer/src/control-center/integrations-view.tsx @@ -0,0 +1,942 @@ +import { useEffect, useState } from "react"; +import { useI18n } from "../i18n"; +import { getMcpToolkitEntry, mcpToolkitEntries, mcpToolkitTierLabels } from "../mcp-toolkit-catalog"; +import claudeLogoUrl from "../../../../assets/integrations/claude.svg"; +import opencodeLogoUrl from "../../../../assets/integrations/opencode.svg"; +import cursorLogoUrl from "../../../../assets/integrations/cursor.svg"; +import piLogoUrl from "../../../../assets/integrations/pi.svg"; +import vscodeLogoUrl from "../../../../assets/integrations/vscode.svg"; +import windsurfLogoUrl from "../../../../assets/integrations/windsurf.svg"; +import zedLogoUrl from "../../../../assets/integrations/zed.svg"; +import * as Shared from "./shared"; +import type { + AgentSetupAction, + AgentSetupCommandPaths, + AgentSetupSnapshot, + ClaudeCodeStatus, + CursorSetupStatus, + FamiliarOSMcpServerHealth, + FamiliarOSMcpServerPreview, + McpToolkitInstallMode, + McpToolkitInstallResult, + McpToolkitPersistentTarget, + OpenCodeSetupStatus, + StatusTone, +} from "./shared"; + +const { + api, + BoxIcon, + buildPersistentToolkitBundle, + Button, + CloseIcon, + commandModeLabelKeys, + ConfigureIcon, + CopyIcon, + ExternalLinkIcon, + FolderPlusIcon, + getPersistentToolkitFollowUps, + GlassCard, + HookIcon, + InstallIcon, + MemoryIcon, + NextIcon, + PluginGlyph, + RefreshIcon, + RemoveIcon, + ReplaceIcon, + SaveIcon, + ServerIcon, + Spinner, + StatusPill, +} = Shared; + +export function PathField({ label, value, placeholder, onSave, disabled }: { label: string; value: string; placeholder: string; onSave: (v: string) => void; disabled?: boolean }) { + const { t } = useI18n(); + const [draft, setDraft] = useState(value); + useEffect(() => { setDraft(value); }, [value]); + return ( +
+ +
+ setDraft(e.target.value)} + placeholder={placeholder} + disabled={disabled} + /> + +
+
+ ); +} + +export function IntegrationIcon({ id }: { id: string }) { + const logos: Record = { + claude: claudeLogoUrl, + opencode: opencodeLogoUrl, + cursor: cursorLogoUrl, + pi: piLogoUrl, + vscode: vscodeLogoUrl, + windsurf: windsurfLogoUrl, + zed: zedLogoUrl, + }; + const src = logos[id]; + if (src) return ; + if (id === "mcp-toolkit" || id === "mcp-tool-servers") return ; + if (id === "familiaros-mcp-server") return ; + return ; +} + +export function claudeStatusTone(state: ClaudeCodeStatus["state"]): StatusTone { + if (state === "configured") return "green"; + if (state === "error") return "red"; + if (state === "needs_setup" || state === "detected") return "blue"; + return "slate"; +} + +export function opencodeStatusTone(state: OpenCodeSetupStatus["state"]): StatusTone { + if (state === "configured") return "green"; + if (state === "error") return "red"; + if (state === "needs_setup") return "blue"; + return "slate"; +} + +export function cursorStatusTone(state: CursorSetupStatus["state"]): StatusTone { + if (state === "configured") return "green"; + if (state === "error" || state === "conflict") return "red"; + if (state === "needs_update") return "orange"; + if (state === "needs_setup") return "blue"; + return "slate"; +} + +export function mcpToolkitTrustTone(trust: "official" | "hosted" | "gateway" | "community"): StatusTone { + if (trust === "official") return "green"; + if (trust === "hosted") return "blue"; + if (trust === "gateway") return "orange"; + return "slate"; +} + +export function IntegrationsView() { + const { t } = useI18n(); + const [snapshot, setSnapshot] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [selectedToolkitId, setSelectedToolkitId] = useState(mcpToolkitEntries[0]?.id ?? ""); + const [toolkitInstallMode, setToolkitInstallMode] = useState("manual"); + const [toolkitPersistentTarget, setToolkitPersistentTarget] = useState("claude-user"); + const [toolkitLastInstall, setToolkitLastInstall] = useState(null); + const [vanillaChatTools, setVanillaChatTools] = useState([]); + const [vanillaChatSaving, setVanillaChatSaving] = useState(false); + const [busy, setBusy] = useState(""); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + const [familiarosMcpPreview, setFamiliarosMcpPreview] = useState(null); + const [familiarosMcpTest, setFamiliarosMcpTest] = useState<{ busy: boolean; result: FamiliarOSMcpServerHealth | null }>({ busy: false, result: null }); + + const load = async (selectedPetId?: string, commandMode?: AgentSetupSnapshot["commandMode"]) => { + try { + const petId = selectedPetId === undefined ? snapshot?.selectedPetId : selectedPetId; + const mode = commandMode === undefined ? snapshot?.commandMode : commandMode; + const next = await api.getIntegrationsState(petId, mode); + setSnapshot(next); + setError(""); + } catch (err) { + setError(String((err as Error)?.message ?? err)); + } + }; + + useEffect(() => { void load(); }, []); + + useEffect(() => { + if (!message) return; + const timeout = window.setTimeout(() => setMessage(""), 3000); + return () => window.clearTimeout(timeout); + }, [message]); + + useEffect(() => { + void (async () => { + try { + const state = await api.getVanillaChatMcpTools(); + setVanillaChatTools(state.enabled); + } catch { + // ignore + } + })(); + }, []); + + useEffect(() => { + if (!snapshot) return; + void api.getFamiliarOSMcpServerPreview(snapshot.selectedPetId, snapshot.commandMode) + .then(setFamiliarosMcpPreview) + .catch(() => setFamiliarosMcpPreview(null)); + }, [snapshot?.selectedPetId, snapshot?.commandMode, snapshot?.commandPaths.node]); + + const run = async (label: string, action: AgentSetupAction) => { + try { + setBusy(label); + setError(""); + setMessage(""); + const next = await api.runIntegrationAction(action, snapshot?.selectedPetId, snapshot?.commandMode); + setSnapshot(next); + if (next.lastAction) { + if (next.lastAction.ok) setMessage(next.lastAction.message); + else setError(next.lastAction.message); + } + } catch (err) { + setError(String((err as Error)?.message ?? err)); + } finally { + setBusy(""); + } + }; + + const updatePath = async (key: keyof AgentSetupCommandPaths, value: string) => { + try { + setBusy(t("integrations.busy.savingPath")); + await api.updateIntegrationCommandPaths({ [key]: value }); + await load(); + setMessage(t("integrations.toast.pathSaved")); + } catch (err) { + setError(String((err as Error)?.message ?? err)); + } finally { + setBusy(""); + } + }; + + const changeCommandMode = (mode: AgentSetupSnapshot["commandMode"]) => { + void load(snapshot?.selectedPetId, mode); + }; + + const copyText = async (label: string, value: string) => { + try { + await api.copyText(value); + setMessage(`${label} copied.`); + setError(""); + } catch (err) { + setError(String((err as Error)?.message ?? err)); + } + }; + + const openDocs = async (url: string) => { + try { + await api.openExternalUrl(url); + setError(""); + } catch (err) { + setError(String((err as Error)?.message ?? err)); + } + }; + + const testFamiliarOSMcpServer = async () => { + if (!snapshot) return; + try { + setFamiliarosMcpTest({ busy: true, result: null }); + setError(""); + setMessage(""); + const result = await api.testFamiliarOSMcpServer(snapshot.selectedPetId, snapshot.commandMode); + setFamiliarosMcpTest({ busy: false, result }); + if (result.ok) { + setMessage(t("integrations.familiarosMcpServer.testOk", { output: result.output })); + } else { + setError(t("integrations.familiarosMcpServer.testError", { error: result.error || "unknown" })); + } + } catch (err) { + setFamiliarosMcpTest({ busy: false, result: null }); + setError(String((err as Error)?.message ?? err)); + } + }; + + const copyFamiliarOSMcpJson = async () => { + if (!familiarosMcpPreview) return; + await copyText("MCP JSON", JSON.stringify(familiarosMcpPreview.mcpJson, null, 2)); + }; + + const saveVanillaChatTools = async (toolIds: string[]) => { + try { + setVanillaChatSaving(true); + setError(""); + const saved = await api.setVanillaChatMcpTools(toolIds); + setVanillaChatTools(saved); + setMessage(saved.length > 0 ? t("integrations.mcpToolServers.saved", { count: saved.length }) : "Vanilla chat tools deactivated."); + } catch (err) { + setError(String((err as Error)?.message ?? err)); + } finally { + setVanillaChatSaving(false); + } + }; + + const toggleVanillaChatTool = (id: string) => { + setVanillaChatTools((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]); + }; + + const installToolkitHost = async () => { + try { + setBusy("Installing toolkit"); + setError(""); + setMessage(""); + const result = await api.installMcpToolkit(toolkitPersistentTarget); + setToolkitLastInstall(result); + if (result.installed.length > 0) { + const installedSummary = result.installed.map((entry) => entry.name).join(", "); + setMessage(result.skipped.length > 0 + ? `Installed ${installedSummary}. Some lanes still need manual follow-up.` + : `Installed ${installedSummary}.`); + } else { + setMessage("No toolkit MCPs were installed automatically. Check the details below."); + } + } catch (err) { + setError(String((err as Error)?.message ?? err)); + } finally { + setBusy(""); + } + }; + + if (!snapshot) { + return ( + + {!error && } +

{error || t("integrations.loading")}

+ {error && } +
+ ); + } + + const isBusy = Boolean(busy) || snapshot.busy; + const integrationDialogTitleId = selectedId ? `integration-detail-title-${selectedId}` : undefined; + const selectedToolkit = getMcpToolkitEntry(selectedToolkitId) ?? mcpToolkitEntries[0]; + const persistentBundle = buildPersistentToolkitBundle(toolkitPersistentTarget); + const persistentFollowUps = getPersistentToolkitFollowUps(); + + const mcpToolServersActive = vanillaChatTools.length; + const integrations = [ + { id: "mcp-tool-servers", name: t("integrations.mcpToolServers.name"), icon: "mcp-tool-servers", status: mcpToolServersActive > 0 ? t("integrations.mcpToolServers.status", { count: mcpToolServersActive }) : t("integrations.mcpToolServers.noTools"), tone: mcpToolServersActive > 0 ? "green" : "slate", description: t("integrations.mcpToolServers.description") }, + { id: "familiaros-mcp-server", name: t("integrations.familiarosMcpServer.name"), icon: "familiaros-mcp-server", status: t("integrations.familiarosMcpServer.status"), tone: "blue" satisfies StatusTone, description: t("integrations.familiarosMcpServer.description") }, + { id: "claude", name: t("integrations.claude.name"), icon: "claude", status: snapshot.status.label, tone: claudeStatusTone(snapshot.status.state), description: t("integrations.claude.description") }, + { id: "opencode", name: t("integrations.opencode.name"), icon: "opencode", status: snapshot.opencodeStatus.label, tone: opencodeStatusTone(snapshot.opencodeStatus.state), description: t("integrations.opencode.description") }, + { id: "cursor", name: t("integrations.cursor.name"), icon: "cursor", status: snapshot.cursorStatus.label, tone: cursorStatusTone(snapshot.cursorStatus.state), description: t("integrations.cursor.description") }, + { id: "pi", name: t("integrations.pi.name"), icon: "pi", status: t("integrations.pi.status"), tone: "blue" satisfies StatusTone, description: t("integrations.pi.description") }, + { id: "mcp-toolkit", name: t("integrations.curatedToolkit.name"), icon: "mcp-toolkit", status: "Curated", tone: "purple" satisfies StatusTone, description: t("integrations.curatedToolkit.description") }, + ] as const; + + const soon = [ + { name: t("integrations.soon.vscode"), icon: "vscode" }, + { name: t("integrations.soon.windsurf"), icon: "windsurf" }, + { name: t("integrations.soon.zed"), icon: "zed" }, + ]; + + const selectedIntegrationName = selectedId === "pi" + ? t("integrations.pi.name") + : selectedId === "mcp-toolkit" + ? t("integrations.curatedToolkit.name") + : selectedId === "mcp-tool-servers" + ? t("integrations.mcpToolServers.name") + : selectedId === "familiaros-mcp-server" + ? t("integrations.familiarosMcpServer.name") + : integrations.find((item) => item.id === selectedId)?.name; + + return ( +
+ {error &&
{error}
} + {message &&
{message}
} + +
+ {integrations.map((item) => ( +
+
+
+ +
+
+
+ {item.name} + {item.status} +
+ {item.description} +
+
+
+
+ {item.id === "claude" && snapshot.status.canConfigure && } + {item.id === "opencode" && snapshot.opencodeStatus.canInstall && } + {item.id === "cursor" && snapshot.cursorStatus.canInstall && } + +
+
+
+ ))} + {soon.map((item) => ( +
+
+
+ +
+
+
+ {item.name} + {t("integrations.soon.status")} +
+ {t("integrations.soon.description")} +
+
+
+ +
+
+ ))} +
+ + {selectedId && ( +
+ +
+ +
+ {selectedId === "familiaros-mcp-server" && ( +
+
{t("integrations.commandSource")}{t("integrations.cliMode")}
+ +

{t("integrations.familiarosMcpServer.commandModeHelp")}

+
+ )} + + {(selectedId === "claude" || selectedId === "opencode" || selectedId === "cursor") && ( +
+
{t("integrations.commandSource")}{t("integrations.cliMode")}
+

{t("integrations.linkToCentralPanel")}

+

{t(commandModeLabelKeys[snapshot.commandMode])}

+
+ )} + + {selectedId === "mcp-tool-servers" && ( +
+
{t("integrations.mcpToolServers.builtInChat")}{t("integrations.mcpToolServers.name")}
+

+ Select which MCP tools the familiar can use directly in the floating chat window. When active, the familiar can run terminal commands, browse files, fetch web pages, and more. +

+
+ {(["starter", "system", "advanced"] as const).map((tier) => { + const entries = mcpToolkitEntries.filter((entry) => entry.tier === tier); + return ( +
+
+ {mcpToolkitTierLabels[tier]} + {entries.length} options +
+
+ {entries.map((entry) => { + const isSelected = vanillaChatTools.includes(entry.id); + return ( + + ); + })} +
+
+ ); + })} +
+
+ + +
+ + Only tools with a green dot are active in the vanilla chat. The familiar will only use tools that are both selected and successfully started by the system. + +
+ )} + + {selectedId === "familiaros-mcp-server" && ( + <> +
+
{t("integrations.configuration")}{t("integrations.commandPaths")}
+
+ updatePath("node", v)} disabled={isBusy || familiarosMcpTest.busy} /> +
+
+ +
+
{t("integrations.connection")}{t("integrations.statusRouting")}
+
+ + +
+
+ +
+
{t("integrations.actions")}{t("integrations.management")}
+
+ + +
+ {familiarosMcpTest.result && ( +
+ {familiarosMcpTest.result.ok ? familiarosMcpTest.result.output : (familiarosMcpTest.result.error || "Unknown error")} +
+ )} +
+ +
+ +
{t("integrations.advanced")}{t("integrations.mcpJsonPreview")}
+ +
+
+                      {familiarosMcpPreview ? JSON.stringify(familiarosMcpPreview.mcpJson, null, 2) : (
+                        
+                          
+                          {t("integrations.mcpJsonPreviewLoading")}
+                        
+                      )}
+                    
+
+ + )} + + {selectedId === "claude" && ( + <> +
+
{t("integrations.connection")}{t("integrations.statusRouting")}
+
+
+ {snapshot.status.label} + {snapshot.status.details} +
+ {snapshot.status.state} +
+
+ +
+
{t("integrations.configuration")}{t("integrations.commandPaths")}
+
+ updatePath("claude", v)} disabled={isBusy} /> +
+
+ +
+
+
{t("integrations.optional")}{t("integrations.claudeHooks")}
+
+ {snapshot.hookStatus.status} +
+
+ + +
+
+
+
{t("integrations.included")}{t("integrations.instructions")}
+
+ {snapshot.memoryStatus.state} +
+ +
+
+ +
+
{t("integrations.actions")}{t("integrations.management")}
+
+ {snapshot.status.canConfigure && } + {snapshot.status.canReplace && } + {snapshot.status.canRemove && } + +
+
+ + )} + + {selectedId === "opencode" && ( + <> +
+
{t("integrations.connection")}{t("integrations.globalSetup")}
+
+
+ {snapshot.opencodeStatus.label} + {snapshot.opencodeStatus.details} +
+ {snapshot.opencodeStatus.state} +
+
+ +
+
{t("integrations.configuration")}{t("integrations.commandPaths")}
+
+ updatePath("opencode", v)} disabled={isBusy} /> +
+
+ +
+
{t("integrations.actions")}{t("integrations.management")}
+
+ {snapshot.opencodeStatus.canInstall && } + {snapshot.opencodeStatus.canRemove && } + +
+
+ +
+ +
{t("integrations.advanced")}{t("integrations.configPreview")}
+ +
+
+                      {JSON.stringify(snapshot.opencodePreview.configPreview, null, 2)}
+                    
+
+ + )} + + {selectedId === "cursor" && ( + <> +
+
{t("integrations.connection")}{t("integrations.globalMcp")}
+
+
+ {snapshot.cursorStatus.label} + {snapshot.cursorStatus.details} +
+ {snapshot.cursorStatus.state} +
+
+ +
+
{t("integrations.actions")}{t("integrations.management")}
+
+ {snapshot.cursorStatus.canInstall && } + {snapshot.cursorStatus.canReplace && } + {snapshot.cursorStatus.canRemove && } + +
+
+ +
+ +
{t("integrations.advanced")}{t("integrations.rulesPreview")}
+ +
+

{snapshot.cursorPreview.rulesPath}

+
+                      {snapshot.cursorPreview.rulesContent}
+                    
+
+ + )} + + {selectedId === "pi" && ( +
+
{t("integrations.pi.manualSetup")}{t("integrations.pi.extension")}
+

+ {t("integrations.pi.intro")} +

+
+
+ {t("integrations.pi.globalInstall")} + pi install npm:@familiaros/pi +
+
+ {t("integrations.pi.projectInstall")} + pi install -l npm:@familiaros/pi +
+
+ {t("integrations.pi.remove")} + pi remove npm:@familiaros/pi +
+
+
+ {t("integrations.pi.slashCommands")} + /familiaros status + /familiaros test + /familiaros react <reaction> + /familiaros say <message> +
+

+ {t("integrations.pi.outro")} +

+
+ )} + + {selectedId === "mcp-toolkit" && ( + <> +
+
Install ChoiceToolkit behavior
+

+ Choose how you want to set up MCP tools for external host agents like Claude Code or Codex CLI. +

+
+ + +
+ + Manual Setup shows copy-paste commands for each tool so you can install them yourself into your host agent. Persistent Full Access lets FamiliarOS register the supported baseline automatically into Claude Code or Codex CLI. + +
+ +
+
CatalogBrowse & Details
+

+ Click any tool below to view its details, installation snippets, and safety notes. +

+
+ {(["starter", "system", "advanced"] as const).map((tier) => { + const entries = mcpToolkitEntries.filter((entry) => entry.tier === tier); + return ( +
+
+ {mcpToolkitTierLabels[tier]} + {entries.length} options +
+
+ {entries.map((entry) => ( + + ))} +
+
+ ); + })} +
+
+ + {toolkitInstallMode === "manual" ? ( + <> +
+
+
+ {mcpToolkitTierLabels[selectedToolkit.tier]} + {selectedToolkit.name} +
+
+ {selectedToolkit.badge} + +
+
+ +

{selectedToolkit.summary}

+ +
+
+ Why it matters +

{selectedToolkit.whyItMatters}

+
+
+ VectorShell fit +

{selectedToolkit.vectorShellFit}

+
+
+ +
+ Permission boundary +

{selectedToolkit.safety}

+
+ +
+ {selectedToolkit.tags.map((tag) => ( + {tag} + ))} +
+
+ + {selectedToolkit.snippets?.length ? ( +
+
Install SurfaceReady-to-paste snippets
+
+ {selectedToolkit.snippets.map((snippet) => ( +
+
+
+ {snippet.label} + {snippet.description && {snippet.description}} +
+ +
+
{snippet.value}
+
+ ))} +
+
+ ) : null} + + {selectedToolkit.notes?.length ? ( +
+
NotesSelection guidance
+
    + {selectedToolkit.notes.map((note) =>
  • {note}
  • )} +
+
+ ) : null} + + ) : ( + <> +
+
Persistent InstallChoose your host
+

+ This mode is for the “install it for good” path. FamiliarOS can install the supported persistent baseline for you now, and it also shows the exact matching bundle if you prefer to run it yourself. +

+
+ + +
+
+ +
+
+
+ Persistent Full Access + {persistentBundle.label} +
+
+ + +
+
+

{persistentBundle.description}

+
{persistentBundle.value}
+
+ +
+
Included NowStable persistent baseline
+
+ {["filesystem", "playwright", "browser-use", "memory", "fetch-web", "sequential-thinking"].map((id) => { + const entry = getMcpToolkitEntry(id); + return entry ? {entry.name} : null; + })} + Context7 / Docs +
+ + These are the lanes FamiliarOS can attempt automatically today. Browser Use and Fetch / Web still depend on `uvx`, and Browser Use still needs its own runtime credential in the host environment. + +
+ + {toolkitLastInstall && toolkitLastInstall.target === toolkitPersistentTarget && ( +
+
Last Install{toolkitLastInstall.label}
+ {toolkitLastInstall.installed.length > 0 && ( +
+ {toolkitLastInstall.installed.map((entry) => ( +
+
+ {entry.name} + Installed +
+ {entry.detail} +
+ ))} +
+ )} + {toolkitLastInstall.skipped.length > 0 && ( +
+ {toolkitLastInstall.skipped.map((entry) => ( +
+
+ {entry.name} + Skipped +
+ {entry.detail} +
+ ))} +
+ )} + {toolkitLastInstall.notes.length > 0 && ( +
    + {toolkitLastInstall.notes.map((note) =>
  • {note}
  • )} +
+ )} +
+ )} + +
+
Manual Follow-UpStill better done explicitly
+

+ These lanes stay outside the one-shot persistent bundle for now because they are more dependent on auth, provider choice, host environment, or high-risk permission boundaries. +

+
+ {persistentFollowUps.map((name) => ( + {name} + ))} +
+
+ + )} + + )} +
+ +
+ )} + + ); +} diff --git a/apps/desktop/src/renderer/src/control-center/plugins-view.tsx b/apps/desktop/src/renderer/src/control-center/plugins-view.tsx new file mode 100644 index 00000000..5d612dc3 --- /dev/null +++ b/apps/desktop/src/renderer/src/control-center/plugins-view.tsx @@ -0,0 +1,607 @@ +import { useEffect, useMemo, useState } from "react"; +import { useI18n } from "../i18n"; +import * as Shared from "./shared"; +import type { + PluginCommandForm, + PluginCommandFormField, + PluginConfig, + PluginConfigField, + PluginConfigSchema, + PluginCatalogSnapshot, + PluginEntry, + PluginFilter, + PluginIconName, + PluginPermission, + PluginServiceResult, + PluginServiceSnapshot, + PluginStatus, + SafeCatalogPluginRecord, + SafePluginRecord, +} from "./shared"; + +const { + api, + Button, + CloseIcon, + ConfigureIcon, + FolderPlusIcon, + GlassCard, + HeartIcon, + InstallIcon, + RefreshIcon, + RemoveIcon, + SaveIcon, + StarIcon, + StatusPill, + statusPillToneClass, +} = Shared; + +export const pluginFilterLabelKeys: Record = { + all: "plugins.filter.all", + installed: "plugins.filter.installed", + catalog: "plugins.filter.catalog", + local: "plugins.filter.local", + broken: "plugins.filter.broken", +}; + +export const pluginPermissionLabelKeys: Record = { + "familiar:speak": "plugins.permission.familiar:speak", + "familiar:reaction": "plugins.permission.familiar:reaction", + "familiar:move": "plugins.permission.familiar:move", + timer: "plugins.permission.timer", + schedule: "plugins.permission.schedule", + storage: "plugins.permission.storage", + status: "plugins.permission.status", + commands: "plugins.permission.commands", + network: "plugins.permission.network", + "familiar:interact": "plugins.permission.familiar:interact", + "familiar:pin": "plugins.permission.familiar:pin", + "familiar:animate": "plugins.permission.familiar:animate", + "familiar:speak:dynamic": "plugins.permission.familiar:speak:dynamic", + "familiar:drop": "plugins.permission.familiar:drop", + "familiars:read": "plugins.permission.familiars:read", + "familiars:manage": "plugins.permission.familiars:manage", + audio: "plugins.permission.audio", + events: "plugins.permission.events", + "ui:toast": "plugins.permission.ui:toast", + "ui:panel": "plugins.permission.ui:panel", + notify: "plugins.permission.notify", + bus: "plugins.permission.bus", + ai: "plugins.permission.ai", + secrets: "plugins.permission.secrets", + "voice:speak": "plugins.permission.voice:speak", + "voice:listen": "plugins.permission.voice:listen", + auth: "plugins.permission.auth", + files: "plugins.permission.files", + "system:openExternal": "plugins.permission.system:openExternal", + "system:metrics": "plugins.permission.system:metrics", + clipboard: "plugins.permission.clipboard", + "network:write": "plugins.permission.network:write", +}; +export const sensitivePermissionSet = new Set(["voice:listen", "clipboard", "familiar:speak:dynamic"]); + +export const pluginStatusTone: Record, keyof typeof statusPillToneClass> = { + info: "blue", + success: "green", + warning: "orange", + error: "red", +}; + +export function PluginGlyph({ className = "plugin-glyph" }: { className?: string }) { + return + + + + ; +} + +export function PluginIcon({ icon = "plugin", className = "plugin-glyph" }: { icon?: PluginIconName; className?: string }) { + if (icon === "bell") return ; + if (icon === "timer") return ; + if (icon === "github") return ; + if (icon === "heart") return ; + if (icon === "sparkles") return ; + if (icon === "coffee") return ; + if (icon === "focus") return ; + if (icon === "droplet") return ; + return ; +} + +export function isPluginIconDataUrl(value: string | undefined): value is string { + return typeof value === "string" && /^data:image\/svg\+xml;base64,[a-z0-9+/=]+$/iu.test(value); +} + +export function PluginIconImage({ entry, className = "plugin-glyph" }: { entry: PluginEntry; className?: string }) { + const iconDataUrl = isPluginIconDataUrl(entry.installed?.iconDataUrl) ? entry.installed.iconDataUrl : isPluginIconDataUrl(entry.catalog?.iconDataUrl) ? entry.catalog.iconDataUrl : undefined; + if (iconDataUrl) return ; + return ; +} + +export function pluginIcon(entry: PluginEntry): PluginIconName { + return entry.installed?.icon || entry.catalog?.icon || "plugin"; +} + +export function pluginName(entry: PluginEntry): string { + return entry.installed?.name || entry.catalog?.name || entry.id; +} + +export function pluginDescription(entry: PluginEntry, t: (key: string, vars?: Record) => string): string { + if (entry.installed?.brokenReason) return entry.installed.brokenReason; + return entry.installed?.description || entry.catalog?.description || (entry.installed ? t("plugins.description.installedReady") : t("plugins.description.availableCatalog")); +} + +export function pluginPrimaryTone(entry: PluginEntry): keyof typeof statusPillToneClass { + if (entry.installed?.brokenReason) return "red"; + if (entry.installed?.catalogDisabled) return "orange"; + if (entry.installed?.enabled) return "green"; + if (entry.installed) return "slate"; + return "blue"; +} + +export function pluginPrimaryLabel(entry: PluginEntry, t: (key: string, vars?: Record) => string): string { + if (entry.installed?.brokenReason) return t("plugins.status.broken"); + if (entry.installed?.catalogDisabled) return t("plugins.status.catalogDisabled"); + if (entry.installed?.enabled) return t("plugins.status.active"); + if (entry.installed) return t("plugins.status.disabled"); + return t("plugins.status.available"); +} + +export function mergePluginEntries(snapshot: PluginServiceSnapshot | null, catalog: PluginCatalogSnapshot | null): PluginEntry[] { + const merged = new Map(); + for (const installed of snapshot?.plugins ?? []) merged.set(installed.id, { id: installed.id, installed }); + for (const catalogPlugin of catalog?.plugins ?? []) { + const current = merged.get(catalogPlugin.id) ?? { id: catalogPlugin.id }; + merged.set(catalogPlugin.id, { ...current, catalog: catalogPlugin }); + } + return [...merged.values()].sort((a, b) => { + const installedDelta = Number(Boolean(b.installed)) - Number(Boolean(a.installed)); + if (installedDelta) return installedDelta; + return pluginName(a).localeCompare(pluginName(b)); + }); +} + +export function initialConfigValue(field: PluginConfigField): unknown { + if (field.default !== undefined) return field.default; + if (field.type === "boolean") return false; + if (field.type === "number") return field.min ?? 0; + if (field.type === "multiSelect" || field.type === "list") return []; + return ""; +} + +export function commandFieldToConfigField(field: PluginCommandFormField): PluginConfigField { + return { type: field.type, label: field.label, default: field.default, options: field.options, min: field.min, max: field.max, maxLength: field.maxLength }; +} + +export function materializeCommandDraft(form: PluginCommandForm | undefined, values: Record | undefined): Record { + const next: Record = {}; + for (const field of form?.fields ?? []) next[field.id] = values?.[field.id] ?? initialConfigValue(commandFieldToConfigField(field)); + return next; +} + +export function materializeListItemDefaults(schema: PluginConfigSchema, value: Record = {}): Record { + const next: Record = {}; + for (const [key, field] of Object.entries(schema)) next[key] = materializeConfigValue(field, value[key]); + return next; +} + +export function materializeConfigValue(field: PluginConfigField, value: unknown): unknown { + if (field.type === "list" && field.itemSchema) { + const items = Array.isArray(value) ? value.filter((item): item is Record => item !== null && typeof item === "object" && !Array.isArray(item)) : []; + return items.map((item) => materializeListItemDefaults(field.itemSchema ?? {}, item)); + } + return value ?? initialConfigValue(field); +} + +export function materializeConfigDraft(schema: PluginConfigSchema | undefined, config: PluginConfig | undefined): PluginConfig { + const next: PluginConfig = {}; + for (const [key, field] of Object.entries(schema ?? {})) next[key] = materializeConfigValue(field, config?.[key]); + return next; +} + +export function ConfigFieldEditor({ pluginId, fieldKey, field, value, onChange, onPickSound }: { pluginId?: string; fieldKey: string; field: PluginConfigField; value: unknown; onChange: (value: unknown) => void; onPickSound?: (pluginId: string) => Promise }) { + const { t } = useI18n(); + const label = field.label || fieldKey; + const description = field.description; + const textValue = typeof value === "string" ? value : typeof field.default === "string" ? field.default : ""; + + if (field.type === "boolean") { + return ; + } + + if (field.type === "list" && field.itemSchema) { + const items = Array.isArray(value) ? value.filter((item): item is Record => item !== null && typeof item === "object" && !Array.isArray(item)) : []; + const maxed = typeof field.maxItems === "number" && items.length >= field.maxItems; + + const isReminders = (pluginId === "familiaros.break-buddy" || fieldKey === "reminders") && ["reminders", "breaks"].includes(fieldKey); + const addLabel = isReminders ? t("plugins.config.addReminder") : t("plugins.config.addItem"); + + return
+ {label}{description && {description}} +
+ {items.map((item, index) => { + let itemTitle = t("plugins.config.item", { index: index + 1 }); + let removeLabel = t("plugins.config.remove"); + + if (isReminders) { + removeLabel = t("plugins.config.removeReminder"); + const id = String(item.id || "").trim(); + const scheduleType = item.scheduleType; + if (scheduleType === "daily") { + const time = String(item.time || "09:00"); + itemTitle = t("plugins.config.dailyAt", { id: id || t("plugins.config.reminder"), time }); + } else if (scheduleType === "interval") { + const mins = Number(item.intervalMinutes) || 60; + itemTitle = t("plugins.config.everyMin", { id: id || t("plugins.config.reminder"), mins }); + } else if (id) { + itemTitle = id; + } + } + + const schemaEntries = Object.entries(field.itemSchema ?? {}); + const messageField = schemaEntries.find(([k]) => k === "message"); + const scheduleFields = schemaEntries.filter(([k]) => ["scheduleType", "time", "days", "intervalMinutes"].includes(k)); + const behaviorFields = schemaEntries.filter(([k]) => ["id", "enabled", "reaction"].includes(k)); + const otherFields = schemaEntries.filter(([k]) => !["message", "scheduleType", "time", "days", "intervalMinutes", "id", "enabled", "reaction"].includes(k)); + + const renderField = ([childKey, childField]: [string, PluginConfigField]) => { + if (isReminders) { + const scheduleType = item.scheduleType; + if (scheduleType === "daily" && childKey === "intervalMinutes") return null; + if (scheduleType === "interval" && (childKey === "time" || childKey === "days")) return null; + } + return onChange(items.map((existing, itemIndex) => itemIndex === index ? { ...existing, [childKey]: nextValue } : existing))} />; + }; + + return ( +
+
+ {itemTitle} + +
+
+ {isReminders ? ( + <> + {behaviorFields.length > 0 && ( +
+
{t("plugins.config.group.identity")}
+ {behaviorFields.map(renderField)} +
+ )} + {messageField && ( +
+
{t("plugins.config.group.message")}
+ {renderField(messageField)} +
+ )} + {scheduleFields.length > 0 && ( +
+
{t("plugins.config.group.schedule")}
+ {scheduleFields.map(renderField)} +
+ )} + {otherFields.map(renderField)} + + ) : ( + schemaEntries.map(renderField) + )} +
+
+ ); + })} + +
+
; + } + + if (field.type === "sound") { + const soundLabel = typeof value === "string" ? (value || t("plugins.config.defaultSound")) : value && typeof value === "object" && "name" in value && typeof value.name === "string" ? value.name : t("plugins.config.defaultSound"); + return ; + } + + return